diff --git a/rust/coverage/collect_rust_coverage.rs b/rust/coverage/collect_rust_coverage.rs index 7fbe08a35b..f13ba09fde 100644 --- a/rust/coverage/collect_rust_coverage.rs +++ b/rust/coverage/collect_rust_coverage.rs @@ -129,7 +129,9 @@ fn main() { None => debug_log!("RUNFILES_DIR: not set (split coverage postprocessing)"), } - let coverage_output_file = coverage_dir.join("coverage.dat"); + let coverage_output_file = env::var("COVERAGE_OUTPUT_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| coverage_dir.join("coverage.dat")); let profdata_file = coverage_dir.join("coverage.profdata"); let llvm_cov_path = env::var("RUST_LLVM_COV").unwrap(); let llvm_profdata_path = env::var("RUST_LLVM_PROFDATA").unwrap(); @@ -141,15 +143,50 @@ fn main() { Some(ref rd) => find_metadata_file(&execroot, rd, &llvm_profdata_path), None => execroot.join(&llvm_profdata_path), }; - let test_binary = match runfiles_dir { - Some(ref rd) => find_test_binary(&execroot, rd), - None => { - let bin_dir = config_bin_dir(&execroot, &coverage_dir); - let test_binary = execroot - .join(bin_dir) - .join(env::var("TEST_BINARY").unwrap()); - debug_log!("Resolved TEST_BINARY to: {}", test_binary.display()); - test_binary + // When the JUnit runner wraps the test, TEST_BINARY points to the runner + // and RUST_TEST_BIN holds the actual instrumented binary that llvm-cov needs. + // + // RUST_TEST_BIN is a runfiles path (workspace-prefixed) and only resolves + // against RUNFILES_DIR. When RUNFILES_DIR is gone -- Bazel drops it under + // --experimental_split_coverage_postprocessing -- we need the binary's + // location relative to the execroot instead. Rather than try to rebuild + // that here by stitching the workspace-prefixed runfiles path onto the + // bin dir (which mislays the workspace segment and points at a file that + // isn't there), read RUST_TEST_BIN_EXECROOT_PATH, which the test rule + // already knows exactly. + let test_binary = if let Ok(rust_test_bin) = env::var("RUST_TEST_BIN") { + debug_log!("Using RUST_TEST_BIN: {}", rust_test_bin); + let execroot_path = || { + env::var("RUST_TEST_BIN_EXECROOT_PATH") + .map(|p| execroot.join(p)) + .unwrap_or_else(|_| { + let bin_dir = config_bin_dir(&execroot, &coverage_dir); + execroot.join(bin_dir).join(&rust_test_bin) + }) + }; + match runfiles_dir { + Some(ref rd) => { + let candidate = rd.join(&rust_test_bin); + if candidate.exists() { + candidate + } else { + debug_log!("RUST_TEST_BIN missing under RUNFILES_DIR; using execroot path"); + execroot_path() + } + } + None => execroot_path(), + } + } else { + match runfiles_dir { + Some(ref rd) => find_test_binary(&execroot, rd), + None => { + let bin_dir = config_bin_dir(&execroot, &coverage_dir); + let test_binary = execroot + .join(bin_dir) + .join(env::var("TEST_BINARY").unwrap()); + debug_log!("Resolved TEST_BINARY to: {}", test_binary.display()); + test_binary + } } }; let profraw_files: Vec = fs::read_dir(coverage_dir) @@ -199,7 +236,8 @@ fn main() { .arg("-format=lcov") .arg("-instr-profile") .arg(&profdata_file) - .arg("-ignore-filename-regex=.*external/.+") + .arg(r"-ignore-filename-regex=.*external[/\\].+") + .arg(r"-ignore-filename-regex=.*rustc[/\\].+") .arg("-ignore-filename-regex=/tmp/.+") .arg(format!("-path-equivalence=.,{}", execroot.display())) .arg(test_binary) @@ -234,7 +272,8 @@ fn main() { coverage_output_file, report_str .replace("#/proc/self/cwd/", "") - .replace(&execroot.display().to_string(), ""), + .replace(&execroot.display().to_string(), "") + .replace('\\', "/"), ) .unwrap(); diff --git a/rust/private/rust.bzl b/rust/private/rust.bzl index 54178d739f..275d63ddb3 100644 --- a/rust/private/rust.bzl +++ b/rust/private/rust.bzl @@ -420,6 +420,22 @@ def get_rust_test_flags(attr): return rust_flags +def _is_junit_enabled(ctx): + """Resolve whether the JUnit XML wrapper should be applied to this test. + + Tri-state, mirroring `experimental_use_cc_common_link`: + * `experimental_junit = 1` -> always wrap this target. + * `experimental_junit = 0` -> never wrap this target. + * `experimental_junit = -1` (default) -> defer to the + `//rust/settings:experimental_emit_junit_xml` build setting. + """ + junit_attr = ctx.attr.experimental_junit + if junit_attr == 1: + return True + if junit_attr == 0: + return False + return ctx.attr._experimental_emit_junit_xml[BuildSettingInfo].value + def _rust_test_impl(ctx): """The implementation of the `rust_test` rule. @@ -619,6 +635,39 @@ def _rust_test_impl(ctx): env["CC_CODE_COVERAGE_SCRIPT"] = ctx.executable._collect_cc_coverage.path components = "{}/{}".format(ctx.label.workspace_root, ctx.label.package).split("/") env["CARGO_MANIFEST_DIR"] = "/".join([c for c in components if c]) + + if _is_junit_enabled(ctx): + test_bin_short = output.short_path + if test_bin_short.startswith("../"): + rust_test_bin_rloc = test_bin_short[len("../"):] + else: + rust_test_bin_rloc = ctx.workspace_name + "/" + test_bin_short + env["RUST_TEST_BIN"] = rust_test_bin_rloc + + # RUST_TEST_BIN above is a runfiles path and only resolves against + # RUNFILES_DIR. Coverage postprocessing may run with RUNFILES_DIR + # unset (--experimental_split_coverage_postprocessing), so also hand + # over the binary's execroot-relative path directly instead of making + # collect_coverage reconstruct it. + env["RUST_TEST_BIN_EXECROOT_PATH"] = output.path + + junit_runner = ctx.actions.declare_file(ctx.label.name + "_junit_runner" + toolchain.binary_ext) + ctx.actions.symlink( + output = junit_runner, + target_file = ctx.executable._junit_runner, + is_executable = True, + ) + + original_default_info = providers[0] + runner_runfiles = ctx.attr._junit_runner[DefaultInfo].default_runfiles + test_bin_runfiles = ctx.runfiles(files = [output]) + merged_runfiles = original_default_info.default_runfiles.merge(runner_runfiles).merge(test_bin_runfiles) + providers[0] = DefaultInfo( + files = original_default_info.files, + runfiles = merged_runfiles, + executable = junit_runner, + ) + providers.append(RunEnvironmentInfo( environment = env, inherited_environment = ctx.attr.env_inherit, @@ -1026,6 +1075,33 @@ _RUST_TEST_ATTRS = { E.g. `bazel test //src:rust_test --test_arg=foo::test::test_fn`. """), ), + "experimental_junit": attr.int( + doc = ( + "Experimental. Whether to wrap the test binary with a runner that emits a " + + "JUnit XML report parsed from the test's `libtest` output. " + + "Possible values: [-1, 0, 1]. " + + "-1 means use the value of the " + + "`--@rules_rust//rust/settings:experimental_emit_junit_xml` build setting to determine. " + + "0 means do not wrap the test (run it directly). " + + "1 means wrap the test and emit JUnit XML." + ), + values = [-1, 0, 1], + default = -1, + ), + "_experimental_emit_junit_xml": attr.label( + default = Label("//rust/settings:experimental_emit_junit_xml"), + doc = "The build setting consulted when `experimental_junit = -1`.", + ), + "_junit_runner": attr.label( + default = Label("//util/junit_runner"), + executable = True, + # Built for the exec platform, like the other test-support tools + # (process_wrapper, collect_coverage). This keeps the runner off the + # target configuration, so it doesn't inherit target-only settings such + # as a custom #[global_allocator] or cc_common.link, which it has no way + # to satisfy and which would otherwise fail to link. + cfg = "exec", + ), } | _COVERAGE_ATTRS | _EXPERIMENTAL_USE_CC_COMMON_LINK_ATTRS rust_library = rule( diff --git a/rust/runfiles/BUILD.bazel b/rust/runfiles/BUILD.bazel index 695fa918f9..ad5cbe2854 100644 --- a/rust/runfiles/BUILD.bazel +++ b/rust/runfiles/BUILD.bazel @@ -16,6 +16,13 @@ rust_test( name = "runfiles_test", crate = ":runfiles", data = ["data/sample.txt"], + # These tests inspect the process's own runfiles -- the manifest layout and + # the repo mapping that rlocation! resolves against. The junit wrapper runs + # the test as a child under the wrapper's runfiles, which is a different + # ambient environment than the one being asserted on, so the runfiles + # library's own tests need to run unwrapped. Force off even if the + # //rust/settings:experimental_emit_junit_xml build setting is enabled. + experimental_junit = 0, ) rust_doc( diff --git a/rust/settings/BUILD.bazel b/rust/settings/BUILD.bazel index d497a55b30..9b91114ccb 100644 --- a/rust/settings/BUILD.bazel +++ b/rust/settings/BUILD.bazel @@ -13,6 +13,7 @@ load( "default_allocator_library", "error_format", "experimental_compile_rustdoc_tests", + "experimental_emit_junit_xml", "experimental_link_std_dylib", "experimental_use_allocator_libraries_with_mangled_symbols", "experimental_use_cc_common_link", @@ -86,6 +87,8 @@ clippy_error_format() experimental_compile_rustdoc_tests() +experimental_emit_junit_xml() + experimental_link_std_dylib() experimental_use_cc_common_link() diff --git a/rust/settings/settings.bzl b/rust/settings/settings.bzl index 4365d29e07..c7373365e6 100644 --- a/rust/settings/settings.bzl +++ b/rust/settings/settings.bzl @@ -272,6 +272,23 @@ def experimental_link_std_dylib(): build_setting_default = False, ) +def experimental_emit_junit_xml(): + """A flag to control whether `rust_test` targets emit JUnit XML test reports. + + When enabled, each `rust_test` is wrapped with a runner that parses the test + binary's `libtest` output and writes a JUnit XML report to the path named by the + `$XML_OUTPUT_FILE` environment variable that `bazel test` provides. When + `$XML_OUTPUT_FILE` is unset (for example under `bazel run`), the runner execs the + test binary directly and adds no overhead. + + This flag is off by default. Individual targets may override it with the + `experimental_junit` attribute on `rust_test`. + """ + bool_flag( + name = "experimental_emit_junit_xml", + build_setting_default = False, + ) + def experimental_use_sh_toolchain_for_bootstrap_process_wrapper(): """A flag to control whether the shell path from a shell toolchain (`@bazel_tools//tools/sh:toolchain_type`) \ is embedded into the bootstrap process wrapper for the `.sh` file. diff --git a/test/junit/BUILD.bazel b/test/junit/BUILD.bazel new file mode 100644 index 0000000000..c5ca206c16 --- /dev/null +++ b/test/junit/BUILD.bazel @@ -0,0 +1,19 @@ +load("//rust:defs.bzl", "rust_test") + +# Force the JUnit wrapper on regardless of the +# //rust/settings:experimental_emit_junit_xml build setting, so this coverage +# runs even when the (default-off) setting is not enabled. +rust_test( + name = "junit_test", + srcs = ["lib.rs"], + edition = "2021", + experimental_junit = 1, +) + +# Force the JUnit wrapper off regardless of the build setting. +rust_test( + name = "no_junit_test", + srcs = ["lib.rs"], + edition = "2021", + experimental_junit = 0, +) diff --git a/test/junit/lib.rs b/test/junit/lib.rs new file mode 100644 index 0000000000..38b2fa80fa --- /dev/null +++ b/test/junit/lib.rs @@ -0,0 +1,11 @@ +#[cfg(test)] +mod tests { + #[test] + fn test_passing() { + assert_eq!(2 + 2, 4); + } + + #[test] + #[ignore] + fn test_ignored() {} +} diff --git a/test/unit/emit_junit_xml/BUILD.bazel b/test/unit/emit_junit_xml/BUILD.bazel new file mode 100644 index 0000000000..f31eae2a7c --- /dev/null +++ b/test/unit/emit_junit_xml/BUILD.bazel @@ -0,0 +1,4 @@ +load(":emit_junit_xml_test.bzl", "emit_junit_xml_test_suite") + +############################ UNIT TESTS ############################# +emit_junit_xml_test_suite(name = "emit_junit_xml_test_suite") diff --git a/test/unit/emit_junit_xml/emit_junit_xml_test.bzl b/test/unit/emit_junit_xml/emit_junit_xml_test.bzl new file mode 100644 index 0000000000..31a2f31367 --- /dev/null +++ b/test/unit/emit_junit_xml/emit_junit_xml_test.bzl @@ -0,0 +1,117 @@ +"""Analysis tests for the experimental JUnit XML wrapper on `rust_test`. + +These verify which executable `rust_test` selects across the tri-state +`experimental_junit` attribute and the +`//rust/settings:experimental_emit_junit_xml` build setting. They run in the +analysis phase only (no test binary is executed): a wrapped target exposes the +`_junit_runner` executable, an unwrapped target exposes the plain test +binary. +""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load("//rust:defs.bzl", "rust_test") + +_JUNIT_RUNNER_MARKER = "_junit_runner" + +_FLAG = str(Label("//rust/settings:experimental_emit_junit_xml")) + +def _executable_basename(env): + tut = analysistest.target_under_test(env) + return tut[DefaultInfo].files_to_run.executable.basename + +def _wrapped_test_impl(ctx): + env = analysistest.begin(ctx) + basename = _executable_basename(env) + asserts.true( + env, + _JUNIT_RUNNER_MARKER in basename, + "expected the JUnit runner to wrap the test, but the executable was {}".format(basename), + ) + return analysistest.end(env) + +def _unwrapped_test_impl(ctx): + env = analysistest.begin(ctx) + basename = _executable_basename(env) + asserts.false( + env, + _JUNIT_RUNNER_MARKER in basename, + "expected the test to run unwrapped, but the executable was {}".format(basename), + ) + return analysistest.end(env) + +wrapped_test = analysistest.make(_wrapped_test_impl) +unwrapped_test = analysistest.make(_unwrapped_test_impl) +wrapped_with_flag_test = analysistest.make( + _wrapped_test_impl, + config_settings = {_FLAG: True}, +) +unwrapped_with_flag_test = analysistest.make( + _unwrapped_test_impl, + config_settings = {_FLAG: True}, +) + +def emit_junit_xml_test_suite(name): + """Defines the JUnit-gating analysis-test suite. + + Args: + name: name of the resulting `test_suite`. + """ + rust_test( + name = "attr_on", + srcs = ["lib.rs"], + edition = "2021", + experimental_junit = 1, + ) + rust_test( + name = "attr_off", + srcs = ["lib.rs"], + edition = "2021", + experimental_junit = 0, + ) + rust_test( + name = "attr_default", + srcs = ["lib.rs"], + edition = "2021", + ) + + # experimental_junit = 1 -> always wrapped, whatever the build setting is. + wrapped_test( + name = "attr_on_wraps_test", + target_under_test = ":attr_on", + ) + wrapped_with_flag_test( + name = "attr_on_wraps_with_flag_test", + target_under_test = ":attr_on", + ) + + # experimental_junit = 0 -> never wrapped, whatever the build setting is. + unwrapped_test( + name = "attr_off_unwrapped_test", + target_under_test = ":attr_off", + ) + unwrapped_with_flag_test( + name = "attr_off_unwrapped_with_flag_test", + target_under_test = ":attr_off", + ) + + # experimental_junit = -1 (default) -> defers to the build setting. + unwrapped_test( + name = "default_off_without_flag_test", + target_under_test = ":attr_default", + ) + wrapped_with_flag_test( + name = "default_on_with_flag_test", + target_under_test = ":attr_default", + ) + + native.test_suite( + name = name, + tests = [ + ":attr_on_wraps_test", + ":attr_on_wraps_with_flag_test", + ":attr_off_unwrapped_test", + ":attr_off_unwrapped_with_flag_test", + ":default_off_without_flag_test", + ":default_on_with_flag_test", + ], + ) diff --git a/test/unit/emit_junit_xml/lib.rs b/test/unit/emit_junit_xml/lib.rs new file mode 100644 index 0000000000..6dfe3a2c33 --- /dev/null +++ b/test/unit/emit_junit_xml/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +mod tests { + #[test] + fn trivial() { + assert_eq!(2 + 2, 4); + } +} diff --git a/util/junit_runner/BUILD.bazel b/util/junit_runner/BUILD.bazel new file mode 100644 index 0000000000..dc86326ba9 --- /dev/null +++ b/util/junit_runner/BUILD.bazel @@ -0,0 +1,22 @@ +load("//rust:defs.bzl", "rust_binary", "rust_test") + +rust_binary( + name = "junit_runner", + srcs = ["junit_runner.rs"], + edition = "2021", + rustc_flags = select({ + "@platforms//os:linux": ["-Cstrip=debuginfo"], + "@platforms//os:macos": ["-Cstrip=symbols"], + "//conditions:default": [], + }), + visibility = ["//visibility:public"], +) + +rust_test( + name = "junit_runner_test", + srcs = ["junit_runner.rs"], + edition = "2021", + # The runner must not wrap its own unit tests. Force off regardless of the + # //rust/settings:experimental_emit_junit_xml build setting. + experimental_junit = 0, +) diff --git a/util/junit_runner/junit_runner.rs b/util/junit_runner/junit_runner.rs new file mode 100644 index 0000000000..122f6cd03b --- /dev/null +++ b/util/junit_runner/junit_runner.rs @@ -0,0 +1,1072 @@ +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +fn resolve_runfiles(rlocation_path: &str) -> PathBuf { + if let Ok(manifest) = env::var("RUNFILES_MANIFEST_FILE") { + if let Ok(contents) = fs::read_to_string(&manifest) { + let prefix = format!("{} ", rlocation_path); + for line in contents.lines() { + if let Some(abs_path) = line.strip_prefix(&prefix) { + let p = PathBuf::from(abs_path); + if p.exists() { + return p; + } + } + } + } + } + + if let Ok(dir) = env::var("RUNFILES_DIR") { + let candidate = PathBuf::from(&dir).join(rlocation_path); + if candidate.exists() { + return candidate; + } + } + + if let Ok(dir) = env::var("TEST_SRCDIR") { + let candidate = PathBuf::from(&dir).join(rlocation_path); + if candidate.exists() { + return candidate; + } + } + + eprintln!( + "ERROR: junit_runner: cannot resolve runfiles path: {}", + rlocation_path + ); + eprintln!( + " RUNFILES_MANIFEST_FILE={:?}", + env::var("RUNFILES_MANIFEST_FILE").ok() + ); + eprintln!(" RUNFILES_DIR={:?}", env::var("RUNFILES_DIR").ok()); + eprintln!(" TEST_SRCDIR={:?}", env::var("TEST_SRCDIR").ok()); + std::process::exit(1); +} + +fn exec_passthrough(test_bin: &PathBuf, args: &[String]) -> ! { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = Command::new(test_bin).args(args).exec(); + eprintln!("ERROR: junit_runner: exec failed: {}", err); + std::process::exit(1); + } + + #[cfg(not(unix))] + { + let status = Command::new(test_bin) + .args(args) + .status() + .unwrap_or_else(|e| { + eprintln!("ERROR: junit_runner: failed to spawn test binary: {}", e); + std::process::exit(1); + }); + std::process::exit(status.code().unwrap_or(1)); + } +} + +fn nocapture_in_args(args: &[String]) -> bool { + args.iter().any(|a| a == "--nocapture") +} + +/// True when libtest would run with output capture disabled: either the +/// `--nocapture` flag or the `RUST_TEST_NOCAPTURE` env var set to anything but +/// "0", matching libtest's own precedence. In that mode libtest interleaves +/// each test's own output into its result line, so the pretty output can no +/// longer be parsed back into per-test results and we step aside instead of +/// writing a broken report. +fn wants_nocapture(args: &[String]) -> bool { + nocapture_in_args(args) || matches!(env::var("RUST_TEST_NOCAPTURE"), Ok(v) if v != "0") +} + +#[derive(Debug, PartialEq)] +struct TestResult { + name: String, + status: String, +} + +/// The counts libtest prints on its `test result:` summary line. +#[derive(Debug, PartialEq)] +struct Summary { + passed: usize, + failed: usize, + ignored: usize, +} + +struct ParsedOutput { + results: Vec, + failures: HashMap, + suite_time: f64, + /// `None` if libtest never printed its summary line, which is our main + /// signal that the binary didn't finish running as a test harness. + summary: Option, +} + +/// A `---- stdout ----` (or `stderr`) banner that opens a failure +/// detail block; returns the test name it belongs to. +fn failure_header(line: &str) -> Option<&str> { + let inner = line.strip_prefix("---- ")?; + inner + .strip_suffix(" stdout ----") + .or_else(|| inner.strip_suffix(" stderr ----")) +} + +fn record_failure(failures: &mut HashMap, name: &str, lines: &[String]) { + let body = lines.join("\n").trim_end().to_string(); + // A test can have both stdout and stderr blocks; keep both. + match failures.get_mut(name) { + Some(existing) => { + existing.push('\n'); + existing.push_str(&body); + } + None => { + failures.insert(name.to_string(), body); + } + } +} + +fn parse_libtest_output(output: &str) -> ParsedOutput { + let mut results = Vec::new(); + let mut failures = HashMap::new(); + let mut current_failure: Option = None; + let mut failure_lines: Vec = Vec::new(); + let mut suite_time = 0.0; + let mut summary = None; + + for line in output.lines() { + // A new detail banner both opens a block and closes the previous one. + if let Some(name) = failure_header(line) { + if let Some(prev) = current_failure.take() { + record_failure(&mut failures, &prev, &failure_lines); + } + current_failure = Some(name.to_string()); + failure_lines.clear(); + continue; + } + + if let Some(name) = ¤t_failure { + // A detail block runs until the trailing `failures:` name list, the + // `test result:` summary, or a bare `----` terminator (older + // libtest). Everything else is part of the captured output/panic. + // Note we must not stop the block until here, or the summary line + // gets swallowed and we lose the run totals entirely. + if line == "failures:" || line.starts_with("test result: ") { + record_failure(&mut failures, name, &failure_lines); + current_failure = None; + failure_lines.clear(); + // fall through so the summary line is still parsed below + } else if line.starts_with("----") { + record_failure(&mut failures, name, &failure_lines); + current_failure = None; + failure_lines.clear(); + continue; + } else { + // Drop the blank line libtest prints right after the banner. + if !(line.trim().is_empty() && failure_lines.is_empty()) { + failure_lines.push(line.to_string()); + } + continue; + } + } + + // A per-test result: "test ... ok|FAILED|ignored|bench" + if line.starts_with("test ") && line.contains(" ... ") { + if let Some(result) = parse_test_result_line(line) { + results.push(result); + continue; + } + } + + // The run summary: "test result: ok. N passed; M failed; ..." + if line.starts_with("test result: ") { + if let Some(time) = parse_suite_time(line) { + suite_time = time; + } + if let Some(counts) = parse_suite_counts(line) { + summary = Some(counts); + } + } + } + + if let Some(name) = ¤t_failure { + record_failure(&mut failures, name, &failure_lines); + } + + ParsedOutput { + results, + failures, + suite_time, + summary, + } +} + +fn parse_test_result_line(line: &str) -> Option { + // Format: "test ... " + // The name can contain spaces in some edge cases, but typically doesn't. + // We split on " ... " to separate name from status. + let after_test = line.strip_prefix("test ")?; + let sep_pos = after_test.find(" ... ")?; + // `#[should_panic]` tests print as `test - should panic ... `, + // but the `---- stdout ----` failure banner uses the bare name. Strip + // the suffix so the testcase name is right and the failure-body lookup in + // build_junit_xml (keyed on the bare name) matches. + let raw_name = &after_test[..sep_pos]; + let name = raw_name.strip_suffix(" - should panic").unwrap_or(raw_name); + let rest = &after_test[sep_pos + " ... ".len()..]; + + // Status is the first word of rest. `#[ignore = "reason"]` prints + // `... ignored, `, so that first token carries a trailing comma; + // strip it before matching or the test is silently dropped from the report. + let status = rest.split_whitespace().next()?.trim_end_matches(','); + match status { + "ok" | "FAILED" | "ignored" | "bench" => Some(TestResult { + name: name.to_string(), + status: status.to_string(), + }), + _ => None, + } +} + +fn parse_suite_time(line: &str) -> Option { + // Format: "test result: ok. N passed; M failed; K ignored; ... finished in X.XXXs" + let finished_marker = "finished in "; + let pos = line.find(finished_marker)?; + let after = &line[pos + finished_marker.len()..]; + let time_str = after.strip_suffix('s')?; + time_str.parse::().ok() +} + +fn parse_suite_counts(line: &str) -> Option { + // "test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; ..." + // Each ';'-separated clause ends in "