Repository: Canop/bacon (language: rust)

Issue:
Synergy with cargo nextest
I find both `bacon` and [`cargo nextest`](https://nexte.st/) to be very useful, and I would like to use them together.

I can persuade `bacon` to run tests via `cargo nextest` by specifying this in `bacon.toml`:

```toml

[jobs.test]
command = [
    "cargo", "nextest", "run", "--color", "always",
]
need_stdout = true
```

When the tests pass I get to see nextest's highly-informative output (in colour, even though it doesn't show up here):
```

    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.01s
    Starting 1 test across 1 binary (run ID: f68cbdcf-7b7a-4af8-a5f6-01a0005a7d89, nextest profile: default)
        PASS [   0.003s] rust tests::it_works
------------
     Summary [   0.003s] 1 test run: 1 passed, 0 skipped
```

 but when the tests fail, I get the very unhelpful and disapponting

```
 1  failed: tests::it_is_broken
 no output
```
Can `bacon` be made to work with `cargo nextest`?


Discussion / hints:
It seems that the `nextest` output is shown, but then immediately overwritten with the less interesting output.

This also happens when the tests pass, if the compiler generates warnings: the warnings completely overwrite the `nextest` output.
I'm also very interested in this and was just about to publish the same issue.
Note that I see the same behavior with `cargo test`, so I don't think it's specific to `nextest`
I made a few tests, it looks like there's no deep problem: I just must find enough time to define the necessary patterns to analyze the output of nextest.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b8c58d..6d9336b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,5 @@
 ### next
+- support for nextest output & default nextest job, bound by default to the 'n' key - Fix #196
 - new `exports` structure in configuration. New `analysis` export bound by default to `ctrl-e`. The old syntax defining locations export is still supported but won't appear in documentations anymore.
 - recognize panic location in test - Fix #208
 
diff --git a/Cargo.lock b/Cargo.lock
index ec78c94..7e03279 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -126,7 +126,7 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
 
 [[package]]
 name = "bacon"
-version = "2.21.0"
+version = "2.22.0-nextest"
 dependencies = [
  "anyhow",
  "cargo_metadata",
@@ -139,6 +139,7 @@ dependencies = [
  "gix",
  "lazy-regex",
  "notify",
+ "rustc-hash",
  "serde",
  "serde_json",
  "termimad",
@@ -1903,6 +1904,12 @@ version = "0.8.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
 
+[[package]]
+name = "rustc-hash"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
+
 [[package]]
 name = "rustix"
 version = "0.38.37"
diff --git a/Cargo.toml b/Cargo.toml
index 39235cf..e6923a7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "bacon"
-version = "2.21.0"
+version = "2.22.0-nextest"
 authors = ["dystroy <denys.seguret@gmail.com>"]
 repository = "https://github.com/Canop/bacon"
 description = "background rust compiler"
@@ -10,7 +10,7 @@ license = "AGPL-3.0"
 categories = ["command-line-utilities", "development-tools"]
 readme = "README.md"
 rust-version = "1.76"
-
+        
 [dependencies]
 anyhow = "1.0.88"
 cargo_metadata = "0.18"
@@ -23,6 +23,7 @@ directories-next = "2.0.0"
 gix = "0.58"
 lazy-regex = "3.3"
 notify = "6.1"
+rustc-hash = "2"
 serde = { version = "1.0.210", features = ["derive"] }
 serde_json = "1.0"
 termimad = "0.30"
diff --git a/bacon.toml b/bacon.toml
index 71d3493..1d224dc 100644
--- a/bacon.toml
+++ b/bacon.toml
@@ -64,10 +64,11 @@ command = [
 	"-A", "clippy::collapsible_else_if",
 	"-A", "clippy::collapsible_if",
 	"-A", "clippy::derive_partial_eq_without_eq",
-	"-A", "clippy::len_without_is_empty",
 	"-A", "clippy::get_first",
-	"-A", "clippy::while_let_on_iterator",
 	"-A", "clippy::if_same_then_else",
+	"-A", "clippy::len_without_is_empty",
+	"-A", "clippy::map_entry",
+	"-A", "clippy::while_let_on_iterator",
 ]
 need_stdout = false
 
diff --git a/defaults/default-bacon.toml b/defaults/default-bacon.toml
index f70da24..578d253 100644
--- a/defaults/default-bacon.toml
+++ b/defaults/default-bacon.toml
@@ -55,6 +55,11 @@ command = [
 ]
 need_stdout = true
 
+[jobs.nextest]
+command = ["cargo", "nextest", "run", "--color", "always", "--hide-progress-bar", "--failure-output", "final"]
+need_stdout = true
+analyzer = "nextest"
+
 [jobs.doc]
 command = ["cargo", "doc", "--color", "always", "--no-deps"]
 need_stdout = false
diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs
new file mode 100644
index 0000000..782f4d4
--- /dev/null
+++ b/src/analysis/analyzer.rs
@@ -0,0 +1,178 @@
+use {
+    super::{
+        nextest_analyzer,
+        standard_analyzer,
+    },
+    crate::*,
+    rustc_hash::FxHashMap,
+    serde::{
+        Deserialize,
+        Serialize,
+    },
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Analyzer {
+    #[default]
+    Standard,
+    Nextest,
+}
+
+impl Analyzer {
+    pub fn analyze_line(
+        self,
+        line: &CommandOutputLine,
+    ) -> LineAnalysis {
+        match self {
+            Self::Standard => standard_analyzer::analyze_line(line),
+            Self::Nextest => nextest_analyzer::analyze_line(line),
+        }
+    }
+    pub fn build_report(
+        &self,
+        cmd_lines: &[CommandOutputLine],
+    ) -> anyhow::Result<Report> {
+        #[derive(Debug, Default)]
+        struct Failure {
+            has_title: bool,
+        }
+        // we first accumulate warnings, test fails and errors in separate vectors
+        let mut warnings = Vec::new();
+        let mut errors = Vec::new();
+        let mut fails = Vec::new();
+        let mut failures: FxHashMap<String, Failure> = Default::default();
+        let mut passed_tests = 0;
+        let mut cur_err_kind = None; // the current kind among stderr lines
+        let mut is_in_out_fail = false;
+        let mut suggest_backtrace = false;
+        for cmd_line in cmd_lines {
+            let line_analysis = self.analyze_line(cmd_line);
+            let line_type = line_analysis.line_type;
+            let mut line = Line {
+                item_idx: 0, // will be filled later
+                line_type,
+                content: cmd_line.content.clone(),
+            };
+            match (line_type, line_analysis.key) {
+                (LineType::Garbage, _) => {
+                    continue;
+                }
+                (LineType::TestResult(r), Some(key)) => {
+                    if r {
+                        passed_tests += 1;
+                    } else if !failures.contains_key(&key) {
+                        // we should receive the test failure section later,
+                        // right now we just whitelist it
+                        failures.insert(key, Failure::default());
+                    }
+                }
+                (LineType::Title(Kind::TestFail), Some(key)) => {
+                    let failure = failures.entry(key.clone()).or_default();
+                    is_in_out_fail = true;
+                    cur_err_kind = Some(Kind::TestFail);
+                    if failure.has_title {
+                        // we already have a title for this failure
+                        // (for nextest, we have a title for stdout and one for stderr)
+                        continue;
+                    }
+                    failure.has_title = true;
+                    line.content = TLine::failed(&key);
+                    fails.push(line);
+                }
+                (LineType::Normal, None) => {
+                    if line.content.is_blank() {
+                        if cur_err_kind == Some(Kind::TestFail) {
+                            // beautification: we remove some blank lines
+                            if let Some(last) = fails.last() {
+                                if last.line_type != LineType::Normal || last.content.is_blank() {
+                                    continue;
+                                }
+                            }
+                        } else {
+                            is_in_out_fail = false;
+                        }
+                    }
+                    if is_in_out_fail {
+                        fails.push(line);
+                    } else {
+                        match cur_err_kind {
+                            Some(Kind::Warning) => warnings.push(line),
+                            Some(Kind::Error) => errors.push(line),
+                            _ => {}
+                        }
+                    }
+                }
+                (LineType::Title(Kind::Sum), None) => {
+                    // we're not interested in this section
+                    cur_err_kind = None;
+                    is_in_out_fail = false;
+                }
+                (LineType::SectionEnd, None) => {
+                    cur_err_kind = None;
+                    is_in_out_fail = false;
+                }
+                (LineType::Title(kind), _) => {
+                    cur_err_kind = Some(kind);
+                    match cur_err_kind {
+                        Some(Kind::Warning) => warnings.push(line),
+                        Some(Kind::Error) => errors.push(line),
+                        _ => {} // before warnings and errors, or in a sum
+                    }
+                }
+                (LineType::BacktraceSuggestion, _) => {
+                    suggest_backtrace = true;
+                }
+                (LineType::Location, _) => {
+                    match cur_err_kind {
+                        Some(Kind::Warning) => warnings.push(line),
+                        Some(Kind::Error) => errors.push(line),
+                        Some(Kind::TestFail) => fails.push(line),
+                        _ => {} // before warnings and errors, or in a sum
+                    }
+                }
+                _ => {}
+            }
+        }
+        for (key, failure) in failures.drain() {
+            // if we know of a failure but there was no content, we add some
+            if failure.has_title {
+                continue;
+            }
+            fails.push(Line {
+                item_idx: 0, // will be filled later
+                line_type: LineType::Title(Kind::TestFail),
+                content: TLine::failed(&key),
+            });
+            fails.push(Line {
+                item_idx: 0,
+                line_type: LineType::Normal,
+                content: TLine::italic("no output".to_string()),
+            });
+        }
+        // we now build a common vector, with errors first
+        let mut lines = errors;
+        lines.append(&mut fails);
+        lines.append(&mut warnings);
+        // and we assign the indexes
+        let mut item_idx = 0;
+        for line in &mut lines {
+            if matches!(line.line_type, LineType::Title(_)) {
+                item_idx += 1;
+            }
+            line.item_idx = item_idx;
+        }
+        // we compute the stats at end because some lines may
+        // have been read but not added (at start or end)
+        let mut stats = Stats::from(&lines);
+        stats.passed_tests = passed_tests;
+        debug!("stats: {:#?}", &stats);
+        let report = Report {
+            lines,
+            stats,
+            suggest_backtrace,
+            output: Default::default(),
+        };
+        Ok(report)
+    }
+}
diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs
new file mode 100644
index 0000000..f6b5b83
--- /dev/null
+++ b/src/analysis/mod.rs
@@ -0,0 +1,5 @@
+mod analyzer;
+mod nextest_analyzer;
+mod standard_analyzer;
+
+pub use analyzer::*;
diff --git a/src/analysis/standard_analyzer.rs b/src/analysis/standard_analyzer.rs
new file mode 100644
index 0000000..3a97c8d
--- /dev/null
+++ b/src/analysis/standard_analyzer.rs
@@ -0,0 +1,169 @@
+use {
+    crate::*,
+    lazy_regex::*,
+};
+
+#[cfg(not(windows))]
+const CSI_ERROR_BODY: &str = CSI_BOLD;
+#[cfg(windows)]
+const CSI_ERROR_BODY: &str = CSI_BOLD_WHITE;
+
+pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
+    let content = &cmd_line.content;
+    //debug!("content: {:?}", &content);
+    let mut key = None;
+    let line_type = if cmd_line.content.is_blank() {
+        LineType::Normal
+    } else if let Some(content) = cmd_line.content.if_unstyled() {
+        if let Some((k, r)) = as_test_result(content) {
+            key = Some(k.to_string());
+            LineType::TestResult(r)
+        } else if let Some(k) = as_fail_result_title(content) {
+            key = Some(k.to_string());
+            LineType::Title(Kind::TestFail)
+        } else if regex_is_match!("^failures:$", content) {
+            // this isn't very discriminant...
+            LineType::Title(Kind::Sum)
+        } else if regex_is_match!("^note: run with `RUST_BACKTRACE=", content) {
+            LineType::BacktraceSuggestion
+        } else if regex_is_match!(r#", [^:\s'"]+:\d+:\d+$"#, content) {
+            // this kind of location comes up in test failures
+            LineType::Location
+        } else if regex_is_match!(r#"^\s+--> [^:\s'"]+:\d+:\d+$"#, content) {
+            // this comes up in test failures to compile
+            LineType::Location
+        } else if regex_is_match!(r#"^thread '.+' panicked at [^:\s'"]+:\d+:\d+:$"#, content) {
+            // this comes up in test failures
+            LineType::Location
+        } else {
+            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
+                }
+                ("", 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,
+            }
+        } else {
+            LineType::Normal // empty line
+        }
+    };
+    LineAnalysis { line_type, key }
+}
+
+fn determine_warning_type(
+    body_raw: &str,
+    content: &TLine,
+) -> LineType {
+    if is_n_warnings_emitted(body_raw)
+        || is_generated_n_warnings(&content.strings)
+        || is_build_failed(content.strings.get(2))
+    {
+        LineType::Title(Kind::Sum)
+    } else {
+        LineType::Title(Kind::Warning)
+    }
+}
+
+fn is_spaces(s: &str) -> bool {
+    s.chars().all(|c| c.is_ascii_whitespace())
+}
+
+/// check if the string starts with something like ": 15 warnings emitted"
+fn is_n_warnings_emitted(s: &str) -> bool {
+    regex_is_match!(r#"^: \d+ warnings? emitted"#, s)
+}
+fn is_generated_n_warnings(ts: &[TString]) -> bool {
+    ts.iter()
+        .any(|ts| regex_is_match!(r#"generated \d+ warnings?"#, &ts.raw))
+}
+fn is_build_failed(ts: Option<&TString>) -> bool {
+    ts.map_or(false, |ts| regex_is_match!(r#"^\s*build failed"#, &ts.raw))
+}
+
+/// similar to as_test_result but without the FAILED|ok part
+/// This is used in case of styled output (because the FAILED|ok
+/// part is in another TString)
+fn as_test_name(s: &str) -> Option<&str> {
+    regex_captures!(
+        r#"^(?:test\s+)?(.+?)(?: - should panic\s*)?(?: - compile\s*)?\s+...\s*$"#,
+        s
+    )
+    .map(|(_, key)| key)
+}
+
+/// return Some when the line is the non detailled
+/// result of a test, for example
+///
+///  "test str_fit::fitting_count_tests::test_count_fitting ... FAILED"
+/// or
+///  "test wrap::wrap_tests::check_space_removing ... ok"
+/// or
+///  "test src/lib.rs - (line 6) ... FAILED"
+/// or
+///  "test src/lib.rs - (line 10) ... ok"
+/// or
+///  "test src/mode.rs - mode::Mode::new (line 121) - compile ... FAILED"
+/// (in this case, the " - compile" part isn't in the key, see #64)
+/// or
+///  "test tests::another - should panic ... FAILED"
+/// (in this case, the " - should panic" part isn't in the key, see #95)
+fn as_test_result(s: &str) -> Option<(&str, bool)> {
+    regex_captures!(
+        r#"^(?:test\s+)?(.+?)(?: - should panic\s*)?(?: - compile\s*)?\s+...\s+(\w+)$"#,
+        s
+    )
+    .and_then(|(_, key, outcome)| match outcome {
+        "ok" => Some((key, true)),
+        "FAILED" => Some((key, false)),
+        other => {
+            warn!("unrecognized doctest outcome: {:?}", other);
+            None
+        }
+    })
+}
+
+/// return Some(key) when the line is like this:
+/// "---- key stdout ----"
+fn as_fail_result_title(s: &str) -> Option<&str> {
+    regex_captures!(r#"^---- (.+) stdout ----$"#, s).map(|(_, key)| key)
+}
diff --git a/src/app.rs b/src/app.rs
index 6bdda97..8355d08 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -90,6 +90,7 @@ pub fn run(
 
     let mut executor = MissionExecutor::new(&mission)?;
 
+    let analyzer = mission.analyzer();
     let mut state = AppState::new(mission)?;
     state.computation_starts();
     state.draw(w)?;
@@ -120,7 +121,7 @@ pub fn run(
                             info!("execution finished with status: {:?}", status);
                             // computation finished
                             let output = state.take_output().unwrap_or_default();
-                            let cmd_result = CommandResult::new(output, status)?;
+                            let cmd_result = CommandResult::build(output, status, analyzer)?;
                             state.set_result(cmd_result);
                             action = state.action();
                         }
diff --git a/src/command_result.rs b/src/command_result.rs
index d5b45ff..40c1079 100644
--- a/src/command_result.rs
+++ b/src/command_result.rs
@@ -20,13 +20,14 @@ pub enum CommandResult {
 }
 
 impl CommandResult {
-    pub fn new(
+    pub fn build(
         output: CommandOutput,
         exit_status: Option<ExitStatus>,
+        analyzer: Analyzer,
     ) -> Result<Self> {
         let lines = &output.lines;
         let error_code = exit_status.and_then(|s| s.code()).filter(|&c| c != 0);
-        let mut report = Report::from_lines(lines)?;
+        let mut report = analyzer.build_report(lines)?;
         debug!("report stats: {:?}", &report.stats);
         if let Some(error_code) = error_code {
             if report.stats.errors + report.stats.test_fails == 0 {
diff --git a/src/export/analysis_export.rs b/src/export/analysis_export.rs
index 3b8f395..ffd4bd1 100644
--- a/src/export/analysis_export.rs
+++ b/src/export/analysis_export.rs
@@ -8,6 +8,9 @@ use {
 
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct AnalysisExport {
+    #[serde(default)]
+    pub analyzer: Analyzer,
+    pub result: CommandResultKind,
     pub lines: Vec<LineAnalysisExport>,
 }
 
@@ -17,16 +20,37 @@ pub struct LineAnalysisExport {
     pub analysis: LineAnalysis,
 }
 
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum CommandResultKind {
+    Report,
+    Failure,
+}
+
 impl AnalysisExport {
-    pub fn build(cmd_output_lines: &[CommandOutputLine]) -> Self {
+    pub fn build(
+        analyzer: Analyzer,
+        cmd_result: &CommandResult,
+    ) -> Option<Self> {
         let mut lines = Vec::new();
+        let (result, cmd_output_lines) = match cmd_result {
+            CommandResult::Report(report) => (CommandResultKind::Report, &report.output.lines),
+            CommandResult::Failure(failure) => (CommandResultKind::Failure, &failure.output.lines),
+            CommandResult::None => {
+                return None;
+            }
+        };
         for line in cmd_output_lines {
-            let analysis = LineAnalysis::from(line);
+            let analysis = analyzer.analyze_line(line);
             lines.push(LineAnalysisExport {
                 line: line.clone(),
                 analysis,
             });
         }
-        Self { lines }
+        Some(Self {
+            analyzer,
+            result,
+            lines,
+        })
     }
 }
diff --git a/src/export/export_settings.rs b/src/export/export_settings.rs
index a6a0850..158ed49 100644
--- a/src/export/export_settings.rs
+++ b/src/export/export_settings.rs
@@ -19,10 +19,6 @@ impl ExportSettings {
         &self,
         state: &AppState<'_>,
     ) -> anyhow::Result<()> {
-        let Some(report) = state.cmd_result.report() else {
-            info!("No report to export");
-            return Ok(());
-        };
         let path = if self.path.is_relative() {
             state.mission.workspace_root.join(&self.path)
         } else {
@@ -31,15 +27,28 @@ impl ExportSettings {
         info!("exporting to {:?}", path);
         match self.exporter {
             Exporter::Analysis => {
-                let analysis_export = AnalysisExport::build(&report.output.lines);
+                let analysis_export =
+                    AnalysisExport::build(state.mission.analyzer(), &state.cmd_result);
+                let Some(analysis_export) = analysis_export else {
+                    info!("No analysis to export");
+                    return Ok(());
+                };
                 let json = serde_json::to_string_pretty(&analysis_export)?;
                 std::fs::write(&path, json)?;
             }
             Exporter::JsonReport => {
+                let Some(report) = state.cmd_result.report() else {
+                    info!("No report to export");
+                    return Ok(());
+                };
                 let json = serde_json::to_string_pretty(&report)?;
                 std::fs::write(&path, json)?;
             }
             Exporter::Locations => {
+                let Some(report) = state.cmd_result.report() else {
+                    info!("No report to export");
+                    return Ok(());
+                };
                 let mut file = File::create(path)?;
                 report.write_locations(&mut file, &state.mission, &self.line_format)?;
             }
diff --git a/src/job.rs b/src/job.rs
index ffd04d4..c25e20e 100644
--- a/src/job.rs
+++ b/src/job.rs
@@ -83,6 +83,10 @@ pub struct Job {
     /// then restart it, or wait for the current job to finish before
     /// restarting it.
     pub on_change_strategy: Option<OnChangeStrategy>,
+
+    /// The analyzer interpreting the output of the command, the
+    /// standard cargo dedicated one if not provided
+    pub analyzer: Option<Analyzer>,
 }
 
 static DEFAULT_ARGS: &[&str] = &["--color", "always"];
@@ -123,6 +127,7 @@ impl Job {
             background: true,
             extraneous_args: true,
             on_change_strategy: None,
+            analyzer: Some(Analyzer::Standard),
         }
     }
 }
diff --git a/src/keybindings.rs b/src/keybindings.rs
index 90c7096..fc02397 100644
--- a/src/keybindings.rs
+++ b/src/keybindings.rs
@@ -47,6 +47,7 @@ impl Default for KeyBindings {
         bindings.set(key!(c), JobRef::from_job_name("clippy"));
         bindings.set(key!(d), JobRef::from_job_name("doc-open"));
         bindings.set(key!(t), JobRef::from_job_name("test"));
+        bindings.set(key!(n), JobRef::from_job_name("nextest"));
         bindings.set(key!(r), JobRef::from_job_name("run"));
         bindings.set(key!(ctrl - e), Action::Export("analysis".to_string()));
         bindings
diff --git a/src/lib.rs b/src/lib.rs
index 18381d3..233e75b 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,4 +1,5 @@
 mod action;
+mod analysis;
 mod app;
 mod args;
 mod auto_refresh;
@@ -39,6 +40,7 @@ mod wrapped_report;
 
 pub use {
     action::*,
+    analysis::*,
     args::*,
     auto_refresh::*,
     cli::*,
diff --git a/src/line_analysis.rs b/src/line_analysis.rs
index b6573ff..9b165a9 100644
--- a/src/line_analysis.rs
+++ b/src/line_analysis.rs
@@ -1,184 +1,45 @@
 use {
     crate::*,
-    lazy_regex::*,
     serde::{
         Deserialize,
         Serialize,
     },
 };
 
-#[cfg(not(windows))]
-const CSI_ERROR_BODY: &str = CSI_BOLD;
-#[cfg(windows)]
-const CSI_ERROR_BODY: &str = CSI_BOLD_WHITE;
-
 /// result of the "parsing" of the line
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 pub struct LineAnalysis {
     pub line_type: LineType,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub key: Option<String>,
 }
 
-impl From<&CommandOutputLine> for LineAnalysis {
-    fn from(cmd_line: &CommandOutputLine) -> Self {
-        let content = &cmd_line.content;
-        //debug!("content: {:?}", &content);
-        let mut key = None;
-        let line_type = if cmd_line.content.is_blank() {
-            LineType::Normal
-        } else if let Some(content) = cmd_line.content.if_unstyled() {
-            if let Some((k, r)) = as_test_result(content) {
-                key = Some(k.to_string());
-                LineType::TestResult(r)
-            } else if let Some(k) = as_fail_result_title(content) {
-                key = Some(k.to_string());
-                LineType::Title(Kind::TestFail)
-            } else if regex_is_match!("^failures:$", content) {
-                // this isn't very discriminant...
-                LineType::Title(Kind::Sum)
-            } else if regex_is_match!("^note: run with `RUST_BACKTRACE=", content) {
-                LineType::BacktraceSuggestion
-            } else if regex_is_match!(r#", [^:\s'"]+:\d+:\d+$"#, content) {
-                // this kind of location comes up in test failures
-                LineType::Location
-            } else if regex_is_match!(r#"^\s+--> [^:\s'"]+:\d+:\d+$"#, content) {
-                // this comes up in test failures to compile
-                LineType::Location
-            } else if regex_is_match!(r#"^thread '.+' panicked at [^:\s'"]+:\d+:\d+:$"#, content) {
-                // this comes up in test failures
-                LineType::Location
-            } else {
-                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
-                    }
-                    ("", 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,
-                }
-            } else {
-                LineType::Normal // empty line
-            }
-        };
-        LineAnalysis { line_type, key }
+impl LineAnalysis {
+    pub fn of_type(line_type: LineType) -> Self {
+        Self {
+            line_type,
+            key: None,
+        }
     }
-}
-
-fn determine_warning_type(
-    body_raw: &str,
-    content: &TLine,
-) -> LineType {
-    if is_n_warnings_emitted(body_raw)
-        || is_generated_n_warnings(&content.strings)
-        || is_build_failed(content.strings.get(2))
-    {
-        LineType::Title(Kind::Sum)
-    } else {
-        LineType::Title(Kind::Warning)
+    pub fn normal() -> Self {
+        Self::of_type(LineType::Normal)
     }
-}
-
-fn is_spaces(s: &str) -> bool {
-    s.chars().all(|c| c.is_ascii_whitespace())
-}
-
-/// check if the string starts with something like ": 15 warnings emitted"
-fn is_n_warnings_emitted(s: &str) -> bool {
-    regex_is_match!(r#"^: \d+ warnings? emitted"#, s)
-}
-fn is_generated_n_warnings(ts: &[TString]) -> bool {
-    ts.iter()
-        .any(|ts| regex_is_match!(r#"generated \d+ warnings?"#, &ts.raw))
-}
-fn is_build_failed(ts: Option<&TString>) -> bool {
-    ts.map_or(false, |ts| regex_is_match!(r#"^\s*build failed"#, &ts.raw))
-}
-
-/// similar to as_test_result but without the FAILED|ok part
-/// This is used in case of styled output (because the FAILED|ok
-/// part is in another TString)
-fn as_test_name(s: &str) -> Option<&str> {
-    regex_captures!(
-        r#"^(?:test\s+)?(.+?)(?: - should panic\s*)?(?: - compile\s*)?\s+...\s*$"#,
-        s
-    )
-    .map(|(_, key)| key)
-}
-
-/// return Some when the line is the non detailled
-/// result of a test, for example
-///
-///  "test str_fit::fitting_count_tests::test_count_fitting ... FAILED"
-/// or
-///  "test wrap::wrap_tests::check_space_removing ... ok"
-/// or
-///  "test src/lib.rs - (line 6) ... FAILED"
-/// or
-///  "test src/lib.rs - (line 10) ... ok"
-/// or
-///  "test src/mode.rs - mode::Mode::new (line 121) - compile ... FAILED"
-/// (in this case, the " - compile" part isn't in the key, see #64)
-/// or
-///  "test tests::another - should panic ... FAILED"
-/// (in this case, the " - should panic" part isn't in the key, see #95)
-fn as_test_result(s: &str) -> Option<(&str, bool)> {
-    regex_captures!(
-        r#"^(?:test\s+)?(.+?)(?: - should panic\s*)?(?: - compile\s*)?\s+...\s+(\w+)$"#,
-        s
-    )
-    .and_then(|(_, key, outcome)| match outcome {
-        "ok" => Some((key, true)),
-        "FAILED" => Some((key, false)),
-        other => {
-            warn!("unrecognized doctest outcome: {:?}", other);
-            None
+    pub fn title_key(
+        kind: Kind,
+        key: String,
+    ) -> Self {
+        Self {
+            line_type: LineType::Title(kind),
+            key: Some(key),
         }
-    })
-}
-
-/// return Some(key) when the line is like this:
-/// "---- key stdout ----"
-fn as_fail_result_title(s: &str) -> Option<&str> {
-    regex_captures!(r#"^---- (.+) stdout ----$"#, s).map(|(_, key)| key)
+    }
+    pub fn test_result(
+        key: String,
+        pass: bool,
+    ) -> Self {
+        Self {
+            line_type: LineType::TestResult(pass),
+            key: Some(key),
+        }
+    }
 }
diff --git a/src/line_type.rs b/src/line_type.rs
index 6444f74..34c54a4 100644
--- a/src/line_type.rs
+++ b/src/line_type.rs
@@ -28,6 +28,9 @@ pub enum LineType {
     /// the start of a section
     Title(Kind),
 
+    /// the end of a section (not part of the section)
+    SectionEnd,
+
     /// a line locating the problem
     Location,
 
@@ -37,6 +40,9 @@ pub enum LineType {
     /// a suggestion to try with backtrace
     BacktraceSuggestion,
 
+    /// a line we know is useless noise
+    Garbage,
+
     /// any other line
     Normal,
 }
diff --git a/src/mission.rs b/src/mission.rs
index 3fac275..b1a90d7 100644
--- a/src/mission.rs
+++ b/src/mission.rs
@@ -7,8 +7,8 @@ use {
         RecursiveMode,
         Watcher,
     },
+    rustc_hash::FxHashSet,
     std::{
-        collections::HashSet,
         path::PathBuf,
         process::Command,
     },
@@ -269,13 +269,17 @@ impl<'s> Mission<'s> {
     pub fn need_stdout(&self) -> bool {
         self.job.need_stdout
     }
+
+    pub fn analyzer(&self) -> Analyzer {
+        self.job.analyzer.unwrap_or_default()
+    }
 }
 
 fn merge_features(
     a: &str,
     b: &str,
 ) -> String {
-    let mut features = HashSet::new();
+    let mut features = FxHashSet::default();
     for feature in a.split(',') {
         features.insert(feature);
     }
diff --git a/src/report.rs b/src/report.rs
index 09d5ddb..f902fa6 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -7,7 +7,6 @@ use {
         Serialize,
     },
     std::{
-        collections::HashSet,
         io,
         path::PathBuf,
     },
@@ -41,136 +40,6 @@ impl Report {
             || (!allow_failures && self.stats.test_fails != 0)
             || (!allow_warnings && self.stats.warnings != 0))
     }
-    /// compute the report from the lines of stdout and/or stderr of the
-    /// `cargo` command.
-    ///
-    /// We assume errors and warnings come in the stderr stream while
-    ///  test failures come in stdout
-    pub fn from_lines(cmd_lines: &[CommandOutputLine]) -> Result<Report> {
-        // we first accumulate warnings, test fails and errors in separate vectors
-        let mut warnings = Vec::new();
-        let mut errors = Vec::new();
-        let mut fails = Vec::new();
-        let mut failure_names = HashSet::new();
-        let mut passed_tests = 0;
-        let mut cur_err_kind = None; // the current kind among stderr lines
-        let mut is_in_out_fail = false;
-        let mut suggest_backtrace = false;
-        for cmd_line in cmd_lines {
-            let line_analysis = LineAnalysis::from(cmd_line);
-            let line_type = line_analysis.line_type;
-            let mut line = Line {
-                item_idx: 0, // will be filled later
-                line_type,
-                content: cmd_line.content.clone(),
-            };
-            debug!(
-                "{:?}> [{line_type:?}][{:?}]",
-                cmd_line.origin, line_analysis.key
-            );
-            match (line_type, line_analysis.key) {
-                (LineType::TestResult(r), Some(key)) => {
-                    if r {
-                        passed_tests += 1;
-                    } else {
-                        // we should receive the test failure section later,
-                        // right now we just whitelist it
-                        failure_names.insert(key);
-                    }
-                }
-                (LineType::Title(Kind::TestFail), Some(key)) => {
-                    if failure_names.contains(&key) {
-                        failure_names.remove(&key);
-                        line.content = TLine::failed(&key);
-                        fails.push(line);
-                        is_in_out_fail = true;
-                        cur_err_kind = Some(Kind::TestFail);
-                    } else {
-                        warn!(
-                            "unexpected test result failure_names={:?}, key={:?}",
-                            &failure_names, &key,
-                        );
-                    }
-                }
-                (LineType::Normal, None) => {
-                    if line.content.is_blank() && cur_err_kind != Some(Kind::TestFail) {
-                        is_in_out_fail = false;
-                    }
-                    if is_in_out_fail {
-                        fails.push(line);
-                    } else {
-                        match cur_err_kind {
-                            Some(Kind::Warning) => warnings.push(line),
-                            Some(Kind::Error) => errors.push(line),
-                            _ => {}
-                        }
-                    }
-                }
-                (LineType::Title(Kind::Sum), None) => {
-                    // we're not interested in this section
-                    cur_err_kind = None;
-                    is_in_out_fail = false;
-                }
-                (LineType::Title(kind), _) => {
-                    cur_err_kind = Some(kind);
-                    match cur_err_kind {
-                        Some(Kind::Warning) => warnings.push(line),
-                        Some(Kind::Error) => errors.push(line),
-                        _ => {} // before warnings and errors, or in a sum
-                    }
-                }
-                (LineType::BacktraceSuggestion, _) => {
-                    suggest_backtrace = true;
-                }
-                (LineType::Location, _) => {
-                    match cur_err_kind {
-                        Some(Kind::Warning) => warnings.push(line),
-                        Some(Kind::Error) => errors.push(line),
-                        Some(Kind::TestFail) => fails.push(line),
-                        _ => {} // before warnings and errors, or in a sum
-                    }
-                }
-                _ => {}
-            }
-        }
-        // for now, we only added the test failures for which there was an output.
-        // We add the other ones
-        for key in failure_names.drain() {
-            fails.push(Line {
-                item_idx: 0, // will be filled later
-                line_type: LineType::Title(Kind::TestFail),
-                content: TLine::failed(&key),
-            });
-            fails.push(Line {
-                item_idx: 0,
-                line_type: LineType::Normal,
-                content: TLine::italic("no output".to_string()),
-            });
-        }
-        // we now build a common vector, with errors first
-        let mut lines = errors;
-        lines.append(&mut fails);
-        lines.append(&mut warnings);
-        // and we assign the indexes
-        let mut item_idx = 0;
-        for line in &mut lines {
-            if matches!(line.line_type, LineType::Title(_)) {
-                item_idx += 1;
-            }
-            line.item_idx = item_idx;
-        }
-        // we compute the stats at end because some lines may
-        // have been read but not added (at start or end)
-        let mut stats = Stats::from(&lines);
-        stats.passed_tests = passed_tests;
-        debug!("stats: {:#?}", &stats);
-        Ok(Report {
-            lines,
-            stats,
-            suggest_backtrace,
-            output: CommandOutput::default(),
-        })
-    }
 
     /// Extract all the diagnostic context, that is all the normal lines
     /// which have the same item index as the given line.
diff --git a/src/tty.rs b/src/tty.rs
index 9714165..667bf5f 100644
--- a/src/tty.rs
+++ b/src/tty.rs
@@ -60,6 +60,15 @@ pub struct TString {
     pub raw: String,
 }
 impl TString {
+    pub fn new<S: Into<String>>(
+        csi: S,
+        raw: S,
+    ) -> Self {
+        Self {
+            csi: csi.into(),
+            raw: raw.into(),
+        }
+    }
     /// colors are 8bits ansi values
     pub fn badge(
         con: &str,
@@ -203,18 +212,19 @@ impl TLine {
         }
     }
     pub fn failed(key: &str) -> Self {
-        Self {
-            strings: vec![
-                TString {
-                    csi: CSI_BOLD_ORANGE.to_string(),
-                    raw: "failed".to_string(),
-                },
-                TString {
-                    csi: CSI_BOLD.to_string(),
-                    raw: format!(": {}", key),
-                },
-            ],
+        let mut strings = Vec::with_capacity(4);
+        strings.push(TString::new(CSI_BOLD_ORANGE, "failed"));
+        strings.push(TString::new("", ": "));
+        if let Some((module, function)) = key.rsplit_once("::") {
+            strings.push(TString {
+                csi: "".to_string(),
+                raw: format!("{module}::"),
+            });
+            strings.push(TString::new(CSI_BOLD_ORANGE, function));
+        } else {
+            strings.push(TString::new(CSI_BOLD_ORANGE, key));
         }
+        TLine { strings }
     }
     pub fn add_badge(
         &mut self,


Test patch:
diff --git a/src/analysis/nextest_analyzer.rs b/src/analysis/nextest_analyzer.rs
new file mode 100644
index 0000000..3510aef
--- /dev/null
+++ b/src/analysis/nextest_analyzer.rs
@@ -0,0 +1,193 @@
+use {
+    super::*,
+    crate::*,
+    lazy_regex::*,
+};
+
+const CSI_TITLE: &str = "\u{1b}[35;1m";
+const CSI_PASS: &str = "\u{1b}[32;1m";
+const CSI_ERROR: &str = "\u{1b}[31;1m";
+
+pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
+    let content = &cmd_line.content;
+    if let Some(key) = title_key(content) {
+        return LineAnalysis::title_key(Kind::TestFail, key);
+    }
+    if let Some((key, pass)) = as_test_result(content) {
+        return LineAnalysis::test_result(key, pass);
+    }
+    if is_canceling(content) {
+        return LineAnalysis::of_type(LineType::SectionEnd);
+    }
+    if is_error_test_run_failed(content) {
+        return LineAnalysis::of_type(LineType::Garbage);
+    }
+    if let Some(content) = cmd_line.content.if_unstyled() {
+        if regex_is_match!(r"^running \d+ tests?$", content) {
+            return LineAnalysis::of_type(LineType::Garbage);
+        }
+        if content == "------------" {
+            return LineAnalysis::of_type(LineType::SectionEnd);
+        }
+    }
+    // compilation warnings and errors are still composed with the standard cargo tool
+    standard_analyzer::analyze_line(cmd_line)
+}
+
+/// Return the key when the line is like "--- STD(OUT|ERR): somekey ---"
+fn title_key(content: &TLine) -> Option<String> {
+    let mut strings = content.strings.iter();
+    let (Some(first), Some(second)) = (strings.next(), strings.next()) else {
+        return None;
+    };
+    //if first.csi != CSI_TITLE || first.raw != "--- " || second.csi != CSI_TITLE {
+    if first.raw != "--- " {
+        return None;
+    }
+    if !regex_is_match!(r"^STD(OUT|ERR):\s*$", &second.raw) {
+        return None;
+    }
+    extract_key_after_crate_name(strings)
+}
+
+fn extract_key_after_crate_name(mut strings: std::slice::Iter<'_, TString>) -> Option<String> {
+    let _ = strings.next(); // skip crate name
+    let _ = strings.next(); // skip blank
+    let mut key = String::new();
+    for s in &mut strings {
+        if s.csi.is_empty() {
+            continue;
+        }
+        if s.raw == " ---" || s.csi == CSI_TITLE {
+            break;
+        }
+        key.push_str(&s.raw);
+    }
+    if strings.next().is_some() {
+        return None;
+    }
+    if key.is_empty() { None } else { Some(key) }
+}
+
+fn is_error_test_run_failed(content: &TLine) -> bool {
+    let mut strings = content.strings.iter();
+    let (Some(first), Some(second), None) = (strings.next(), strings.next(), strings.next()) else {
+        return false;
+    };
+    first.csi == CSI_ERROR
+        && first.raw.trim() == "error"
+        && second.raw.trim() == ": test run failed"
+}
+
+fn is_canceling(content: &TLine) -> bool {
+    let Some(first) = content.strings.get(0) else {
+        return false;
+    };
+    first.csi == CSI_ERROR && first.raw.trim() == "Canceling"
+}
+
+/// 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(content: &TLine) -> Option<(String, bool)> {
+    let mut strings = content.strings.iter();
+    let Some(first) = strings.next() else {
+        return None;
+    };
+    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(strings)?;
+    Some((key, pass))
+}
+
+#[test]
+fn test_title_key() {
+    let content = TLine {
+        strings: vec![
+            TString::new("\u{1b}[35;1m", "--- "),
+            TString::new("\u{1b}[35;1m", "STDOUT:              "),
+            TString::new("\u{1b}[35;1m", "bacon-test"),
+            TString::new("", " "),
+            TString::new("\u{1b}[36m", "tests"),
+            TString::new("\u{1b}[36m", "::"),
+            TString::new("\u{1b}[34;1m", "failing_test3"),
+            TString::new("\u{1b}[35;1m", " ---"),
+        ],
+    };
+    assert_eq!(
+        title_key(&content),
+        Some("tests::failing_test3".to_string())
+    );
+    let content = TLine {
+        strings: vec![
+            TString::new("\u{1b}[31;1m", "--- "),
+            TString::new("\u{1b}[31;1m", "STDERR:              "),
+            TString::new("\u{1b}[35;1m", "bacon"),
+            TString::new("", " "),
+            TString::new("\u{1b}[36m", "analysis::nextest_analyzer"),
+            TString::new("\u{1b}[36m", "::"),
+            TString::new("\u{1b}[34;1m", "test_as_test_result"),
+            TString::new("\u{1b}[31;1m", " ---"),
+        ],
+    };
+    assert_eq!(
+        title_key(&content),
+        Some("analysis::nextest_analyzer::test_as_test_result".to_string())
+    );
+}
+
+#[test]
+fn test_canceling() {
+    let content = TLine {
+        strings: vec![
+            TString::new("\u{1b}[31;1m", "   Canceling"),
+            TString::new("", " due to "),
+            TString::new("\u{1b}[31;1m", "test failure"),
+            TString::new("", ": "),
+            TString::new("\u{1b}[1m", "1"),
+            TString::new("", " test still running"),
+        ],
+    };
+    assert_eq!(is_canceling(&content), true);
+}
+
+#[test]
+fn test_as_test_result() {
+    let content = TLine {
+        strings: vec![
+            TString::new("\u{1b}[32;1m", "        PASS"),
+            TString::new("", " [   0.003s] "),
+            TString::new("\u{1b}[35;1m", "bacon"),
+            TString::new("", " "),
+            TString::new("\u{1b}[36m", "analysis::nextest_analyzer"),
+            TString::new("\u{1b}[36m", "::"),
+            TString::new("\u{1b}[34;1m", "test_canceling"),
+        ],
+    };
+    assert_eq!(
+        as_test_result(&content),
+        Some((
+            "analysis::nextest_analyzer::test_canceling".to_string(),
+            true
+        ))
+    );
+}
+
+#[test]
+fn test_recognize_test_run_failed() {
+    let content = TLine {
+        strings: vec![
+            TString::new("\u{1b}[31;1m", "error"),
+            TString::new("", ": test run failed"),
+        ],
+    };
+    assert!(is_error_test_run_failed(&content));
+}
diff --git a/tests/standard_line_analysis.rs b/tests/line_analysis.rs
similarity index 84%
rename from tests/standard_line_analysis.rs
rename to tests/line_analysis.rs
index 3c1ba8d..d6a5bb2 100644
--- a/tests/standard_line_analysis.rs
+++ b/tests/line_analysis.rs
@@ -1,8 +1,8 @@
 use bacon::*;
 
-/// Check we reproduce all the line analyses from json files in standard_line_analysis directory
+/// Check we reproduce all the line analyses from json files in line_analysis directory
 #[test]
-fn test_standard_line_analysis() {
+fn line_analysis() {
     let dir = file!().strip_suffix(".rs").unwrap();
     for entry in std::fs::read_dir(dir).unwrap() {
         let entry = entry.unwrap();
@@ -16,9 +16,11 @@ fn test_standard_line_analysis() {
         let file = std::fs::File::open(&path).unwrap();
         let reader = std::io::BufReader::new(file);
         let export: AnalysisExport = serde_json::from_reader(reader).unwrap();
+        let analyzer = export.analyzer;
+        println!("Analyzer: {:?}", analyzer);
         for line_entry in export.lines {
             // checking that we reproduce the same analysis
-            let analysis = LineAnalysis::from(&line_entry.line);
+            let analysis = analyzer.analyze_line(&line_entry.line);
             if analysis != line_entry.analysis {
                 println!("Wrong analysis in {:?} for {:#?}", name, line_entry.line);
                 println!("Expected: {:?}", line_entry.analysis);
diff --git a/tests/standard_line_analysis/failure-location-issue-208.json b/tests/line_analysis/failure-location-issue-208.json
similarity index 98%
rename from tests/standard_line_analysis/failure-location-issue-208.json
rename to tests/line_analysis/failure-location-issue-208.json
index 563ae6f..58167af 100644
--- a/tests/standard_line_analysis/failure-location-issue-208.json
+++ b/tests/line_analysis/failure-location-issue-208.json
@@ -1,4 +1,5 @@
 {
+  "result": "report",
   "lines": [
     {
       "line": {
diff --git a/tests/line_analysis/nextest.json b/tests/line_analysis/nextest.json
new file mode 100644
index 0000000..6effa8d
--- /dev/null
+++ b/tests/line_analysis/nextest.json
@@ -0,0 +1,1449 @@
+{
+  "analyzer": "nextest",
+  "result": "report",
+  "lines": [
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[1m\u001b[33m",
+              "raw": "warning"
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": ": function `unused_function` is never used"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Warning"
+        }
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "  "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "--> "
+            },
+            {
+              "csi": "",
+              "raw": "src/lib.rs:25:4"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Location",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "|"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "25"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "|"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "",
+              "raw": "fn unused_function() {"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "|"
+            },
+            {
+              "csi": "",
+              "raw": "    "
+            },
+            {
+              "csi": "\u001b[1m\u001b[33m",
+              "raw": "^^^^^^^^^^^^^^^"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "|"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   "
+            },
+            {
+              "csi": "\u001b[1m\u001b[38;5;12m",
+              "raw": "= "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "note"
+            },
+            {
+              "csi": "",
+              "raw": ": `#[warn(dead_code)]` on by default"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[1m\u001b[33m",
+              "raw": "warning"
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": ":"
+            },
+            {
+              "csi": "",
+              "raw": " `bacon-test` (lib) generated 1 warning"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[1m\u001b[33m",
+              "raw": "warning"
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": ":"
+            },
+            {
+              "csi": "",
+              "raw": " `bacon-test` (lib test) generated 1 warning (1 duplicate)"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[1m\u001b[32m",
+              "raw": "    Finished"
+            },
+            {
+              "csi": "",
+              "raw": " `test` profile [unoptimized + debuginfo] target(s) in 0.00s"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[32;1m",
+              "raw": "    Starting"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "3"
+            },
+            {
+              "csi": "",
+              "raw": " tests across "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "1"
+            },
+            {
+              "csi": "",
+              "raw": " binary (run ID: "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "f2e61b6a-d9c0-40f2-8ca4-9b29cf4051a7"
+            },
+            {
+              "csi": "",
+              "raw": ", nextest profile: "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "default"
+            },
+            {
+              "csi": "",
+              "raw": ")"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "        FAIL"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test2"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+            "TestResult": false
+        },
+        "key": "failing_test2"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "--- "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "STDOUT:              "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test2"
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": " ---"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "TestFail"
+        },
+        "key": "failing_test2"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "running 1 test"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Garbage",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "test failing_test2 ... FAILED"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "TestResult": false
+        },
+        "key": "failing_test2"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "failures:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "failures:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "    failing_test2"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "--- "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "STDERR:              "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test2"
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": " ---"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "TestFail"
+        },
+        "key": "failing_test2"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "eprint in test 2 - hello"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "thread 'failing_test2' panicked at src/lib.rs:22:5:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Location",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "assertion `left == right` failed"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "  left: \"hello\""
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": " right: \"world\""
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "BacktraceSuggestion",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "   Canceling"
+            },
+            {
+              "csi": "",
+              "raw": " due to "
+            },
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "test failure"
+            },
+            {
+              "csi": "",
+              "raw": ": "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "2"
+            },
+            {
+              "csi": "",
+              "raw": " tests still running"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "SectionEnd",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[32;1m",
+              "raw": "        PASS"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "successful_test4"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+            "TestResult": true
+        },
+        "key": "successful_test4"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "        FAIL"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "tests"
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "::"
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test3"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+            "TestResult": false
+        },
+        "key": "tests::failing_test3"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "--- "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "STDOUT:              "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "tests"
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "::"
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test3"
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": " ---"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "TestFail"
+        },
+        "key": "tests::failing_test3"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "running 1 test"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Garbage",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "print in test 3"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "test tests::failing_test3 ... FAILED"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "TestResult": false
+        },
+        "key": "tests::failing_test3"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "failures:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "failures:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "Sum"
+        },
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "    tests::failing_test3"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.00s"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "--- "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "STDERR:              "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "tests"
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "::"
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test3"
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": " ---"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+          "Title": "TestFail"
+        },
+        "key": "tests::failing_test3"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "thread 'tests::failing_test3' panicked at src/lib.rs:7:9:"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Location",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "assertion `left == right` failed"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "  left: 3"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": " right: 5"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "BacktraceSuggestion",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "------------"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "SectionEnd",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "     Summary"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "3"
+            },
+            {
+              "csi": "",
+              "raw": " tests run: "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "1"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[32;1m",
+              "raw": "passed"
+            },
+            {
+              "csi": "",
+              "raw": ", "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "2"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "failed"
+            },
+            {
+              "csi": "",
+              "raw": ", "
+            },
+            {
+              "csi": "\u001b[1m",
+              "raw": "0"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[33;1m",
+              "raw": "skipped"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "        FAIL"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test2"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+            "TestResult": false
+        },
+        "key": "failing_test2"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "        FAIL"
+            },
+            {
+              "csi": "",
+              "raw": " [   0.003s] "
+            },
+            {
+              "csi": "\u001b[35;1m",
+              "raw": "bacon-test"
+            },
+            {
+              "csi": "",
+              "raw": " "
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "tests"
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "::"
+            },
+            {
+              "csi": "\u001b[34;1m",
+              "raw": "failing_test3"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": {
+            "TestResult": false
+        },
+        "key": "tests::failing_test3"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31;1m",
+              "raw": "error"
+            },
+            {
+              "csi": "",
+              "raw": ": test run failed"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Garbage",
+        "key": null
+      }
+    }
+  ]
+}
diff --git a/tests/standard_line_analysis/readme.md b/tests/line_analysis/readme.md
similarity index 100%
rename from tests/standard_line_analysis/readme.md
rename to tests/line_analysis/readme.md
diff --git a/tests/standard_line_analysis/simple-passing-test.json b/tests/line_analysis/simple-passing-test.json
similarity index 99%
rename from tests/standard_line_analysis/simple-passing-test.json
rename to tests/line_analysis/simple-passing-test.json
index 8f3e2dc..9878b38 100644
--- a/tests/standard_line_analysis/simple-passing-test.json
+++ b/tests/line_analysis/simple-passing-test.json
@@ -1,4 +1,5 @@
 {
+  "result": "report",
   "lines": [
     {
       "line": {