Repository: Canop/bacon (language: rust)

Issue:
No output when upgrading nextest to 0.9.86
I'm running bacon `3.6.0` with nextest `0.9.86 (046e241b1 2024-12-12)`.

Since bacon 3.6.0 I no longer see any error output from failing tests:
```
 1  failed: parser::test::var
 no output
```

Using cargo test (from bacon) I get
```
 1  failed: parser::test::var
 thread 'parser::test::var' panicked at src/parser.rs:36:79:
 called `Result::unwrap()` on an `Err` value: Error { variant: ParsingError { positives: [identifier], negativ
 es: [] }, location: Pos(3), line_col: Pos((1, 4)), path: None, line: "var a = 2;", continued_line: None, pars
 e_attempts: None }
```

Running nextest outside bacon:
```
──── STDERR:             llvm::bin/llvm parser::test::var
thread 'parser::test::var' panicked at src/parser.rs:36:79:
called `Result::unwrap()` on an `Err` value: Error { variant: ParsingError { positives: [identifier], negatives: [] }, location: Pos(3), line_col: Pos((1, 4)), path: None, line: "var a = 2;", continued_line: None, parse_attempts: None }
```

Am I missing something or is there a setting I can't find to enable output from nextest in bacon?


Discussion / hints:
You mean the same configuration which was working before 3.6 isn't working with 3.6 ?

Standard configuration is

```
[jobs.nextest]
command = [
    "cargo", "nextest", "run",
    "--hide-progress-bar", "--failure-output", "final"
]
need_stdout = true
analyzer = "nextest"
```
Well, I don't have any custom bacon config.
Can you run bacon with log enabled (eg `BACON_LOG=debug bacon nextest`) then paste the content of the bacon.log file ?
```
17:25:51.028 [INFO] cli_log::init: Starting bacon v3.6.0 with log level DEBUG
17:25:51.029 [INFO] bacon::cli: args: Args {
    help: false,
    version: false,
    prefs: false,
    summary: false,
    no_summary: false,
    wrap: false,
    no_wrap: false,
    reverse: false,
    no_reverse: false,
    help_line: false,
    no_help_line: false,
    list_jobs: false,
    offline: false,
    init: false,
    job: Some(
        ConcreteJobRef {
            name_or_alias: Name(
                "nextest",
            ),
            scope: Scope {
                tests: [],
            },
        },
    ),
    no_default_features: false,
    features: None,
    all_features: false,
    export_locations: false,
    no_export_locations: false,
    watch: None,
    project: None,
    args: [],
    additional_job_args: [],
}
17:25:51.075 [DEBUG] bacon::cli: mission context: Context {
    name: "llvm",
    nature: Cargo,
    package_directory: "/home/frja/rust/llvm",
    workspace_root: None,
    cargo_mission_location: Some(
        CargoContext {
            cargo_toml_file: "/home/frja/rust/llvm/Cargo.toml",
            ..
        },
    ),
    path_to_watch: None,
}
17:25:51.076 [INFO] bacon::conf::settings: settings: Settings {
    additional_alias_args: None,
    additional_job_args: [],
    all_features: false,
    arg_job: Some(
        ConcreteJobRef {
            name_or_alias: Name(
                "nextest",
            ),
            scope: Scope {
                tests: [],
            },
        },
    ),
    config_files: [],
    default_job: ConcreteJobRef {
        name_or_alias: Name(
            "check",
        ),
        scope: Scope {
            tests: [],
        },
    },
    default_watch: true,
    exports: ExportsSettings {
        exports: {},
    },
    features: None,
    grace_period: Period {
        duration: 5ms,
    },
    help_line: true,
    ignore: [],
    ignored_lines: None,
    jobs: {
        "ex": Job {
            allow_failures: false,
            allow_warnings: true,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "run",
                "--example",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: true,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "run": Job {
            allow_failures: false,
            allow_warnings: true,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "run",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: true,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "doc-open": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "doc",
                "--no-deps",
                "--open",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: Some(
                Internal(
                    Back,
                ),
            ),
            watch: None,
        },
        "doc": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "doc",
                "--no-deps",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "check-all": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "check",
                "--all-targets",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "nextest": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: Some(
                Nextest,
            ),
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "nextest",
                "run",
                "--hide-progress-bar",
                "--failure-output",
                "final",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: true,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "clippy": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "clippy",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "run-long": Job {
            allow_failures: false,
            allow_warnings: true,
            analyzer: None,
            apply_gitignore: None,
            background: false,
            command: [
                "cargo",
                "run",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: true,
            on_change_strategy: Some(
                KillThenRestart,
            ),
            on_success: None,
            watch: None,
        },
        "test": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "test",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: true,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "clippy-all": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "clippy",
                "--all-targets",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
        "check": Job {
            allow_failures: false,
            allow_warnings: false,
            analyzer: None,
            apply_gitignore: None,
            background: true,
            command: [
                "cargo",
                "check",
            ],
            default_watch: None,
            env: {},
            expand_env_vars: true,
            extraneous_args: true,
            ignore: [],
            ignored_lines: None,
            kill: None,
            need_stdout: false,
            on_change_strategy: None,
            on_success: None,
            watch: None,
        },
    },
    keybindings: KeyBindings {
        F5: "refresh",
        Esc: "back",
        PageDown: "scroll-pages(1)",
        d: "job:doc-open",
        r: "job:run",
        Ctrl-q: "quit",
        s: "toggle-summary",
        ?: "help",
        p: "toggle-pause",
        b: "toggle-backtrace(1)",
        Down: "scroll-lines(1)",
        i: "job:initial",
        w: "toggle-wrap",
        Home: "scroll-to-top",
        q: "quit",
        Ctrl-d: "job:default",
        a: "job:check-all",
        f: "scope-to-failures",
        Ctrl-c: "quit",
        c: "job:clippy-all",
        Space: "scroll-pages(.8)",
        n: "job:nextest",
        t: "job:test",
        PageUp: "scroll-pages(-1)",
        h: "help",
        Up: "scroll-lines(-1)",
        End: "scroll-to-bottom",
    },
    no_default_features: false,
    on_change_strategy: None,
    reverse: false,
    show_changes_count: false,
    summary: false,
    watch: [],
    wrap: true,
    env: {
        "CARGO_TERM_COLOR": "always",
    },
}
17:25:51.083 [DEBUG] bacon::jobs::job_stack: picking job Initial
17:25:51.083 [DEBUG] bacon::context: watches: ["src", "tests", "benches", "examples", "build.rs"]
17:25:51.084 [INFO] bacon::app: mission.ignorer() took 183.884µs
17:25:51.084 [DEBUG] bacon::watcher: add watch dir "/home/frja/rust/llvm/src"
17:25:51.084 [DEBUG] bacon::watcher: add watch file "/home/frja/rust/llvm/Cargo.toml"
17:25:51.084 [DEBUG] bacon::mission: command builder: CommandBuilder {
    exe: "cargo",
    current_dir: Some(
        "/home/frja/rust/llvm",
    ),
    args: [
        "nextest",
        "run",
        "--hide-progress-bar",
        "--failure-output",
        "final",
    ],
    with_stdout: true,
    envs: {
        "CARGO_TERM_COLOR": "always",
    },
}
17:25:51.084 [INFO] bacon::exec::executor: start task Task { backtrace: None, grace_period: Period { duration: 5ms } }
17:25:51.153 [DEBUG] bacon::watcher: ignoring access event: Event { kind: Access(Open(Any)), paths: ["/home/frja/rust/llvm/Cargo.toml"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
17:25:51.176 [DEBUG] bacon::watcher: ignoring access event: Event { kind: Access(Open(Any)), paths: ["/home/frja/rust/llvm/Cargo.toml"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
17:25:51.202 [INFO] bacon::app: execution finished with status: Some(ExitStatus(unix_wait_status(25600)))
17:25:51.203 [DEBUG] bacon::analysis::standard::standard_report_building: stats: Stats {
    warnings: 1,
    errors: 0,
    test_fails: 1,
    passed_tests: 5,
    location_lines: 1,
    normal_lines: 7,
}
17:25:51.204 [DEBUG] bacon::result::command_result: report stats: Stats { warnings: 1, errors: 0, test_fails: 1, passed_tests: 5, location_lines: 1, normal_lines: 7 }
17:25:51.204 [DEBUG] bacon::state: Got report
17:25:51.204 [INFO] bacon::state: Stats: Stats {
    warnings: 1,
    errors: 0,
    test_fails: 1,
    passed_tests: 5,
    location_lines: 1,
    normal_lines: 7,
}
17:25:51.204 [DEBUG] bacon::result::wrapped_report: wrapping report
17:25:52.938 [DEBUG] bacon::app: key combination pressed: q
17:25:52.938 [DEBUG] bacon::app: requested action: Internal(Quit)
17:25:52.939 [DEBUG] bacon::exec::executor: failed to send 'die' signal: sending on a disconnected channel
17:25:52.939 [INFO] bacon: bye
```
Sorry for the log, it's not needed.

I updated cargo nextest to the last version and I can reproduce your problem. They changed the output :\
Got it, thanks for all help (again)!!
If their format isn't stabilized, the nextest analyzer should probably be replaced to use their JSON output.

If anybody's willing to dive into this, they're welcome.
I can't make any promises, but sounds like a good challenge for the upcoming Holidays for me :)
If you try this, and you have questions, you're welcome to come and chat on [Miaou](https://miaou.dystroy.org/4683).

Two approaches seem possible: either make a new version of [the existing nextest analyzer](https://github.com/Canop/bacon/tree/main/src/analysis/nextest), or make a new nextest_json analyser or libtest_analyzer, similar to [the cargo_json analyzer](https://github.com/Canop/bacon/blob/main/src/analysis/cargo_json/mod.rs), to be used with `cargo nextest run --message-format libtest-json` (this would imply there's enough data in that JSON output, I didn't check).
Hi there, creator of nextest here.

> I updated cargo nextest to the last version and I can reproduce your problem. They changed the output :\

Sorry about that! The human-readable output format, or in general anything nextest prints out to stderr, is explicitly [not part of the stable guarantees](https://nexte.st/docs/stability/) -- I don't want to make too many tweaks to it, but there will likely be a few here and there.

The [libtest-json format is functional](https://nexte.st/docs/machine-readable/libtest-json/) but not quite full-fidelity, because cargo test doesn't support many of the features that nextest does. I would love to support a more full-featured machine-readable format, but that will require a fair bit of design and implementation work (see nextest [issue #20](https://github.com/nextest-rs/nextest/issues/20)). The work isn't on my employer's radar so I don't expect to get to it, but if you'd like to help out (even by just commenting there about what your needs are) it would be really helpful!
Hello @sunshowers, thanks for coming here!

I suppose we'll try to see what we manage to obtain with the `libtest-json` format, or maybe the `libtest-json-plus` one.

Regarding the human-readable format, bacon doesn't need a lot of stability, just the ability to [recognize a few line types](https://github.com/Canop/bacon/blob/main/src/analysis/nextest/nextest_line_analyser.rs) which should be easy as we can rely on  styling which is unlikely to appear otherwise. We'd just need, for each specific kind of line (location, start of stderr, etc.) a frozen ansi escape sequence and a basic shape.

If I'm unclear, an input line for bacon is an array of (`csi`, `raw`) strings, and recognizing a line is usually something like this:

```rust
const CSI_LINE_COL: &str = "\u{1b}[2m";
const CSI_ERROR: &str = "\u{1b}[31m";

/// Return true when the line is like
///    "67:52  error  Unnecessary escape character: \/  no-useless-escape"
fn is_line_col_error_line(content: &TLine) -> bool {
    let mut strings = content.strings.iter();
    let (Some(first), Some(second), Some(third), Some(fourth)) = (
        strings.next(),
        strings.next(),
        strings.next(),
        strings.next(),
    ) else {
        return false;
    };
    first.is_blank()
        && second.csi == CSI_LINE_COL
        && regex_is_match!(r"^\d+:\d+$", &second.raw)
        && third.is_blank()
        && fourth.csi == CSI_ERROR
        && fourth.raw == "error"
}
```
> Regarding the human-readable format, bacon doesn't need a lot of stability, just the ability to [recognize a few line types](https://github.com/Canop/bacon/blob/main/src/analysis/nextest/nextest_line_analyser.rs) which should be easy as we can rely on styling which is unlikely to appear otherwise. We'd just need, for each specific kind of line (location, start of stderr, etc.) a frozen ansi escape sequence and a basic shape.

Ah gotcha. So I guess the change that broke you was the one where we started producing Unicode characters.

There are a couple more changes I'd like to make to the output soon: another minor tweak to the Unicode, and indenting standard output/standard error to make sure it stands out.

Is there some way nextest can produce some kind of custom CSI escape that would help?  I'm fine with that as a stopgap.

Fix patch:
diff --git a/src/analysis/standard/standard_line_analyser.rs b/src/analysis/standard/standard_line_analyser.rs
index 4a8c8aa..e46a1c4 100644
--- a/src/analysis/standard/standard_line_analyser.rs
+++ b/src/analysis/standard/standard_line_analyser.rs
@@ -65,55 +65,71 @@ fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
             LineType::Normal
         }
     } else {
-        if let (Some(title), Some(body)) = (content.strings.get(0), content.strings.get(1)) {
-            match (
-                title.csi.as_ref(),
-                title.raw.as_ref(),
-                body.csi.as_ref(),
-                body.raw.as_ref(),
-            ) {
-                (CSI_BOLD_RED, "error", CSI_ERROR_BODY, body_raw)
-                    if body_raw.starts_with(": aborting due to") =>
-                {
-                    LineType::Title(Kind::Sum)
-                }
-                (CSI_BOLD_RED, title_raw, CSI_ERROR_BODY, _) if title_raw.starts_with("error") => {
-                    LineType::Title(Kind::Error)
-                }
-                #[cfg(not(windows))]
-                (CSI_BOLD_YELLOW, "warning", _, body_raw) => {
-                    determine_warning_type(body_raw, content)
-                }
-                #[cfg(windows)]
-                (CSI_BOLD_YELLOW | CSI_BOLD_4BIT_YELLOW, "warning", _, body_raw) => {
-                    determine_warning_type(body_raw, content)
-                }
-                ("", title_raw, CSI_BOLD_BLUE, "--> ") if is_spaces(title_raw) => {
-                    LineType::Location
-                }
-                ("", title_raw, CSI_BOLD_BLUE, "::: ") if is_spaces(title_raw) => {
-                    LineType::Location
-                }
-                ("", k, CSI_BOLD_RED | CSI_RED, "FAILED") if content.strings.len() == 2 => {
-                    if let Some(k) = as_test_name(k) {
-                        key = Some(k.to_string());
-                        LineType::TestResult(false)
-                    } else {
-                        LineType::Normal
+        let ts0 = content.strings.get(0);
+        let ts1 = content.strings.get(1);
+        match (ts0, ts1) {
+            (Some(title), Some(body)) => {
+                match (
+                    title.csi.as_ref(),
+                    title.raw.as_ref(),
+                    body.csi.as_ref(),
+                    body.raw.as_ref(),
+                ) {
+                    (CSI_BOLD_RED, "error", CSI_ERROR_BODY, body_raw)
+                        if body_raw.starts_with(": aborting due to") =>
+                    {
+                        LineType::Title(Kind::Sum)
                     }
-                }
-                ("", k, CSI_GREEN, "ok") => {
-                    if let Some(k) = as_test_name(k) {
-                        key = Some(k.to_string());
-                        LineType::TestResult(true)
-                    } else {
-                        LineType::Normal
+                    (CSI_BOLD_RED, title_raw, CSI_ERROR_BODY, _)
+                        if title_raw.starts_with("error") =>
+                    {
+                        LineType::Title(Kind::Error)
+                    }
+                    #[cfg(not(windows))]
+                    (CSI_BOLD_YELLOW, "warning", _, body_raw) => {
+                        determine_warning_type(body_raw, content)
                     }
+                    #[cfg(windows)]
+                    (CSI_BOLD_YELLOW | CSI_BOLD_4BIT_YELLOW, "warning", _, body_raw) => {
+                        determine_warning_type(body_raw, content)
+                    }
+                    ("", title_raw, CSI_BOLD_BLUE, "--> ") if is_spaces(title_raw) => {
+                        LineType::Location
+                    }
+                    ("", title_raw, CSI_BOLD_BLUE, "::: ") if is_spaces(title_raw) => {
+                        LineType::Location
+                    }
+                    ("", k, CSI_BOLD_RED | CSI_RED, "FAILED") if content.strings.len() == 2 => {
+                        if let Some(k) = as_test_name(k) {
+                            key = Some(k.to_string());
+                            LineType::TestResult(false)
+                        } else {
+                            LineType::Normal
+                        }
+                    }
+                    ("", k, CSI_GREEN, "ok") => {
+                        if let Some(k) = as_test_name(k) {
+                            key = Some(k.to_string());
+                            LineType::TestResult(true)
+                        } else {
+                            LineType::Normal
+                        }
+                    }
+                    _ => LineType::Normal,
                 }
-                _ => LineType::Normal,
             }
-        } else {
-            LineType::Normal // empty line
+            (Some(content), None) => {
+                if regex_is_match!(
+                    r#"^thread '.+' panicked at [^:\s'"]+:\d+:\d+:$"#,
+                    &content.raw
+                ) {
+                    // this comes up in nextest failures
+                    LineType::Location
+                } else {
+                    LineType::Normal
+                }
+            }
+            _ => LineType::Normal, // empty line
         }
     };
     LineAnalysis { line_type, key }


Test patch:
diff --git a/src/analysis/nextest/nextest_line_analyser.rs b/src/analysis/nextest/nextest_line_analyser.rs
index 4ce0057..5f99964 100644
--- a/src/analysis/nextest/nextest_line_analyser.rs
+++ b/src/analysis/nextest/nextest_line_analyser.rs
@@ -21,7 +21,7 @@ impl LineAnalyzer for NextestLineAnalyzer {
         cmd_line: &CommandOutputLine,
     ) -> LineAnalysis {
         let content = &cmd_line.content;
-        if let Some(key) = title_key(content) {
+        if let Some(key) = title_key(content).or_else(|| title_key_v2(content)) {
             return LineAnalysis::title_key(Kind::TestFail, key);
         }
         if let Some((key, pass)) = as_test_result(content) {
@@ -40,23 +40,42 @@ impl LineAnalyzer for NextestLineAnalyzer {
             if content == "------------" {
                 return LineAnalysis::of_type(LineType::SectionEnd);
             }
+            if content == "────────────" {
+                return LineAnalysis::of_type(LineType::SectionEnd);
+            }
         }
         // compilation warnings and errors are still composed with the standard cargo tool
         self.default_analyzer.analyze_line(cmd_line)
     }
 }
 
-/// Return the key when the line is like "--- STD(OUT|ERR): somekey ---"
 fn title_key(content: &TLine) -> Option<String> {
+    title_key_v1(content).or_else(|| title_key_v2(content))
+}
+/// Return the key when the line is like "--- STD(OUT|ERR): somekey ---"
+fn title_key_v1(content: &TLine) -> Option<String> {
     let mut strings = content.strings.iter();
     let first = strings.next()?;
     if !regex_is_match!(r"^--- STD(OUT|ERR):\s*", &first.raw) {
         return None;
     }
-    extract_key_after_crate_name(strings)
+    extract_key_after_crate_name_v1(strings)
+}
+/// Return the key when the line is like "──── STD(OUT|ERR): cratename some::key"
+fn title_key_v2(content: &TLine) -> Option<String> {
+    let mut strings = content.strings.iter();
+    let ts = strings.next()?;
+    if ts.csi != CSI_ERROR || ts.raw != "────" {
+        return None;
+    }
+    let ts = strings.find(|ts| ts.raw != " ")?;
+    if !regex_is_match!(r"^STD(OUT|ERR):\s*", &ts.raw) {
+        return None;
+    }
+    extract_key_after_crate_name_v2(strings)
 }
 
-fn extract_key_after_crate_name(mut strings: std::slice::Iter<'_, TString>) -> Option<String> {
+fn extract_key_after_crate_name_v1(mut strings: std::slice::Iter<'_, TString>) -> Option<String> {
     let _ = strings.next(); // skip blank
     let mut key = String::new();
     for s in &mut strings {
@@ -73,6 +92,19 @@ fn extract_key_after_crate_name(mut strings: std::slice::Iter<'_, TString>) -> O
     }
     if key.is_empty() { None } else { Some(key) }
 }
+fn extract_key_after_crate_name_v2(mut strings: std::slice::Iter<'_, TString>) -> Option<String> {
+    // skip blank and crate name
+    let ts = strings.find(|ts| ts.csi != CSI_TITLE && ts.raw != " ")?;
+    let mut key = String::new();
+    key.push_str(&ts.raw);
+    for s in &mut strings {
+        if s.csi.is_empty() || s.csi == CSI_TITLE {
+            break;
+        }
+        key.push_str(&s.raw);
+    }
+    if key.is_empty() { None } else { Some(key) }
+}
 
 fn is_error_test_run_failed(content: &TLine) -> bool {
     let mut strings = content.strings.iter();
@@ -96,6 +128,28 @@ fn is_canceling(content: &TLine) -> bool {
 ///
 /// In the future, we might want to return the duration too.
 fn as_test_result(content: &TLine) -> Option<(String, bool)> {
+    as_test_result_v1(content).or_else(|| as_test_result_v2(content))
+}
+fn as_test_result_v1(content: &TLine) -> Option<(String, bool)> {
+    let mut strings = content.strings.iter();
+    let first = strings.next()?;
+    let pass = match (first.csi.as_str(), first.raw.trim()) {
+        (CSI_PASS, "PASS") => true,
+        (CSI_ERROR, "FAIL") => false,
+        _ => return None,
+    };
+    let _duration = match strings.next() {
+        Some(s) if s.csi.is_empty() => s.raw.trim(),
+        _ => return None,
+    };
+    let key = extract_key_after_crate_name_v1(strings)?;
+    Some((key, pass))
+}
+/// return the key and whether the tests passes, when the line is a test
+/// result (like "    PASS [   0.003s] bacon tests::failing_test3")
+///
+/// In the future, we might want to return the duration too.
+fn as_test_result_v2(content: &TLine) -> Option<(String, bool)> {
     let mut strings = content.strings.iter();
     let first = strings.next()?;
     let pass = match (first.csi.as_str(), first.raw.trim()) {
@@ -107,12 +161,12 @@ fn as_test_result(content: &TLine) -> Option<(String, bool)> {
         Some(s) if s.csi.is_empty() => s.raw.trim(),
         _ => return None,
     };
-    let key = extract_key_after_crate_name(strings)?;
+    let key = extract_key_after_crate_name_v1(strings)?;
     Some((key, pass))
 }
 
 #[test]
-fn test_title_key() {
+fn test_title_key_v1() {
     let content = TLine {
         strings: vec![
             TString::new("\u{1b}[35;1m", "--- STDOUT:              bacon-test"),
@@ -123,7 +177,7 @@ fn test_title_key() {
         ],
     };
     assert_eq!(
-        title_key(&content),
+        title_key_v1(&content),
         Some("tests::failing_test3".to_string())
     );
     let content = TLine {
@@ -136,7 +190,7 @@ fn test_title_key() {
         ],
     };
     assert_eq!(
-        title_key(&content),
+        title_key_v1(&content),
         Some("analysis::nextest_analyzer::test_as_test_result".to_string())
     );
 }