Repository: Canop/bacon (language: rust)

Issue:
`RUST_BACKTRACE` is silently ignored
I tried to activate backtraces by setting `env.RUST_BACKTRACE = "1"` and was confused that no backtraces were shown. It didn’t occur to me to check the docs because (for me) it was very obvious that setting `RUST_BACKTRACE=1` should enable backtraces, so I thought that something was wrong with my program that prevented backtraces from being shown.

It would have helped me if bacon had warned that setting `RUST_BACKTRACE` has no effect.

Also, from skimming the code, apparently it is supposed to show `press b to toggle backtraces` in the status line when there is a panic message in the output? That doesn’t happen for me with the default `run` profile (no `bacon.toml` file) (pressing `b` works though, it just is a bit undiscoverable):

```rust
fn main() {
    panic!()
}
```
=>
```console
$ bacon run
 t   run   Command error code: 101

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/t`
thread 'main' panicked at src/main.rs:2:5:
explicit panic
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Hit q to quit, w to not wrap lines, p to pause, ? for help
```

Am I missing something?

Also, another small thing: When using crates like [`color_eyre`](https://docs.rs/color-eyre/latest/color_eyre/), it would be nice to be able to set `RUST_BACKTRACE=full` because then the backtraces show the source for each stack frame (there’s an example of that [here](https://docs.rs/color-eyre/latest/color_eyre/#multiple-report-format-verbosity-levels)).


Discussion / hints:
Different points here.

Regarding the lack of automatic suggestion of hitting b to have the backtrace, it's because bacon think, in this precise case (command returning a non zero code, and no test failures found in the output) that there was no report to build, and didn't exploit the "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace" line. I'll see what I can do for that.

> it would be nice to be able to set RUST_BACKTRACE=full

Right. I'll probably allow this.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d9336b..fc67320 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 - 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
+- `toggle-backtrace` accepts an optional level: `toggle-backtrace(1)` or `toggle-backtrace(full)` - Experimental - Fix #210
 
 <a name="v2.21.0"></a>
 ### v2.21.0 - 2024/09/14
diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml
index ae23ef9..84d8387 100644
--- a/defaults/default-prefs.toml
+++ b/defaults/default-prefs.toml
@@ -78,6 +78,7 @@ path = "bacon-analysis.json"
 # s = "toggle-summary"
 # w = "toggle-wrap"
 # b = "toggle-backtrace"
+# f = "toggle-backtrace(full)"
 # Home = "scroll-to-top"
 # End = "scroll-to-bottom"
 # Up = "scroll-lines(-1)"
diff --git a/src/analysis/standard_analyzer.rs b/src/analysis/standard_analyzer.rs
index 3a97c8d..c689a73 100644
--- a/src/analysis/standard_analyzer.rs
+++ b/src/analysis/standard_analyzer.rs
@@ -24,7 +24,7 @@ pub fn analyze_line(cmd_line: &CommandOutputLine) -> LineAnalysis {
         } 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) {
+        } else if regex_is_match!("[Rr]un 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
diff --git a/src/app.rs b/src/app.rs
index 8355d08..eba329e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -201,8 +201,8 @@ pub fn run(
                     Internal::ToggleWrap => {
                         state.toggle_wrap_mode();
                     }
-                    Internal::ToggleBacktrace => {
-                        state.toggle_backtrace();
+                    Internal::ToggleBacktrace(level) => {
+                        state.toggle_backtrace(level);
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
                     }
diff --git a/src/command_result.rs b/src/command_result.rs
index 40c1079..21d7fae 100644
--- a/src/command_result.rs
+++ b/src/command_result.rs
@@ -33,7 +33,12 @@ impl CommandResult {
             if report.stats.errors + report.stats.test_fails == 0 {
                 // report shows no error while the command exe reported
                 // an error, so the report can't be trusted
-                return Ok(Self::Failure(Failure { error_code, output }));
+                let suggest_backtrace = report.suggest_backtrace;
+                return Ok(Self::Failure(Failure {
+                    error_code,
+                    output,
+                    suggest_backtrace,
+                }));
             }
         }
         report.output = output;
@@ -56,6 +61,14 @@ impl CommandResult {
         }
     }
 
+    pub fn suggest_backtrace(&self) -> bool {
+        match self {
+            Self::Report(report) => report.suggest_backtrace,
+            Self::Failure(failure) => failure.suggest_backtrace,
+            _ => false,
+        }
+    }
+
     /// return true when the report has been computed and there's been no
     /// error, warning, or test failures
     pub fn is_success(&self) -> bool {
diff --git a/src/executor.rs b/src/executor.rs
index e251f94..ac5257d 100644
--- a/src/executor.rs
+++ b/src/executor.rs
@@ -54,7 +54,7 @@ enum StopMessage {
 /// Settings for one execution of job's command
 #[derive(Debug, Clone, Copy, PartialEq, Default)]
 pub struct Task {
-    pub backtrace: bool,
+    pub backtrace: Option<&'static str>,
 }
 
 impl TaskExecutor {
@@ -105,7 +105,7 @@ impl MissionExecutor {
         info!("start task {task:?}");
         let mut child = self
             .command
-            .env("RUST_BACKTRACE", if task.backtrace { "1" } else { "0" })
+            .env("RUST_BACKTRACE", task.backtrace.unwrap_or("0"))
             .spawn()
             .context("failed to launch command")?;
         let kill_command = self.kill_command.clone();
diff --git a/src/failure.rs b/src/failure.rs
index fdb1ded..34b451d 100644
--- a/src/failure.rs
+++ b/src/failure.rs
@@ -11,4 +11,5 @@ use {
 pub struct Failure {
     pub error_code: i32,
     pub output: CommandOutput,
+    pub suggest_backtrace: bool,
 }
diff --git a/src/help_line.rs b/src/help_line.rs
index b8efa84..4924677 100644
--- a/src/help_line.rs
+++ b/src/help_line.rs
@@ -29,7 +29,9 @@ impl HelpLine {
             .shortest_internal_key(Internal::ToggleWrap)
             .map(|k| format!("*{k}* to not wrap lines"));
         let toggle_backtrace = kb
-            .shortest_internal_key(Internal::ToggleBacktrace)
+            .shortest_action_key(|action| {
+                matches!(action, Action::Internal(Internal::ToggleBacktrace(_)))
+            })
             .map(|k| format!("*{k}* to toggle backtraces"));
         let help = kb
             .shortest_internal_key(Internal::Help)
@@ -73,12 +75,12 @@ impl HelpLine {
                     parts.push(s);
                 }
             }
-            if let CommandResult::Report(report) = &state.cmd_result {
-                if report.suggest_backtrace {
-                    if let Some(s) = &self.toggle_backtrace {
-                        parts.push(s);
-                    }
-                } else if !state.mission.is_success(report) {
+            if state.cmd_result.suggest_backtrace() {
+                if let Some(s) = &self.toggle_backtrace {
+                    parts.push(s);
+                }
+            } else if let CommandResult::Report(report) = &state.cmd_result {
+                if !state.mission.is_success(report) {
                     if let Some(s) = &self.toggle_summary {
                         parts.push(s);
                     }
diff --git a/src/internal.rs b/src/internal.rs
index fa1d82a..e818acf 100644
--- a/src/internal.rs
+++ b/src/internal.rs
@@ -13,7 +13,7 @@ pub enum Internal {
     Refresh, // clear and rerun
     ReRun,
     Scroll(ScrollCommand),
-    ToggleBacktrace,
+    ToggleBacktrace(&'static str),
     ToggleRawOutput,
     ToggleSummary,
     ToggleWrap,
@@ -34,7 +34,7 @@ impl fmt::Display for Internal {
             Self::Refresh => write!(f, "clear then run current job again"),
             Self::ReRun => write!(f, "run current job again"),
             Self::Scroll(scroll_command) => scroll_command.fmt(f),
-            Self::ToggleBacktrace => write!(f, "toggle backtrace"),
+            Self::ToggleBacktrace(level) => write!(f, "toggle backtrace ({level})"),
             Self::ToggleRawOutput => write!(f, "toggle raw output"),
             Self::ToggleSummary => write!(f, "toggle summary"),
             Self::ToggleWrap => write!(f, "toggle wrap"),
@@ -58,7 +58,10 @@ impl std::str::FromStr for Internal {
             "refresh" => Ok(Self::Refresh),
             "rerun" => Ok(Self::ReRun),
             "toggle-raw-output" => Ok(Self::ToggleRawOutput),
-            "toggle-backtrace" => Ok(Self::ToggleBacktrace),
+            "toggle-backtrace" => Ok(Self::ToggleBacktrace("1")),
+            "toggle-backtrace(1)" => Ok(Self::ToggleBacktrace("1")),
+            "toggle-backtrace(2)" => Ok(Self::ToggleBacktrace("2")),
+            "toggle-backtrace(full)" => Ok(Self::ToggleBacktrace("full")),
             "toggle-summary" => Ok(Self::ToggleSummary),
             "toggle-wrap" => Ok(Self::ToggleWrap),
             "pause" => Ok(Self::Pause),
diff --git a/src/keybindings.rs b/src/keybindings.rs
index fc02397..8db8ce9 100644
--- a/src/keybindings.rs
+++ b/src/keybindings.rs
@@ -30,7 +30,7 @@ impl Default for KeyBindings {
         bindings.set(key!(F5), Internal::Refresh);
         bindings.set(key!(s), Internal::ToggleSummary);
         bindings.set(key!(w), Internal::ToggleWrap);
-        bindings.set(key!(b), Internal::ToggleBacktrace);
+        bindings.set(key!(b), Internal::ToggleBacktrace("1"));
         bindings.set(key!(Home), Internal::Scroll(ScrollCommand::Top));
         bindings.set(key!(End), Internal::Scroll(ScrollCommand::Bottom));
         bindings.set(key!(Up), Internal::Scroll(ScrollCommand::Lines(-1)));
@@ -83,14 +83,16 @@ impl KeyBindings {
         self.map.get(&key)
     }
     /// return the shortest key.to_string for the internal, if any
-    pub fn shortest_internal_key(
+    pub fn shortest_action_key<F>(
         &self,
-        internal: Internal,
-    ) -> Option<String> {
+        filter: F,
+    ) -> Option<String>
+    where
+        F: Fn(&Action) -> bool,
+    {
         let mut shortest: Option<String> = None;
-        let searched_action = Action::Internal(internal);
         for (ck, action) in &self.map {
-            if action == &searched_action {
+            if filter(action) {
                 let s = ck.to_string();
                 match &shortest {
                     Some(previous) if previous.len() < s.len() => {}
@@ -102,6 +104,14 @@ impl KeyBindings {
         }
         shortest
     }
+    /// return the shortest key.to_string for the internal, if any
+    pub fn shortest_internal_key(
+        &self,
+        internal: Internal,
+    ) -> Option<String> {
+        let internal_action = Action::Internal(internal);
+        self.shortest_action_key(|action| action == &internal_action)
+    }
     /// build and return a map from actions to all the possible shortcuts
     pub fn build_reverse_map(&self) -> HashMap<&Action, Vec<KeyCombination>> {
         let mut reverse_map = HashMap::new();
diff --git a/src/state.rs b/src/state.rs
index dc4674a..9c3cb3e 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -43,8 +43,8 @@ pub struct AppState<'s> {
     computing: bool,
     /// whether the user wants wrapped lines
     pub wrap: bool,
-    /// whether the user wants backtraces
-    pub backtrace: bool,
+    /// the optional RUST_BACKTRACE env var to set
+    pub backtrace: Option<&'static str>,
     /// whether we should display only titles and locations
     summary: bool,
     /// whether we display the gui bottom-to-top
@@ -92,7 +92,7 @@ impl<'s> AppState<'s> {
             computing: true,
             summary: mission.settings.summary,
             wrap: mission.settings.wrap,
-            backtrace: false,
+            backtrace: None,
             reverse: mission.settings.reverse,
             show_changes_count: mission.settings.show_changes_count,
             status_skin,
@@ -309,8 +309,15 @@ impl<'s> AppState<'s> {
         self.summary ^= true;
         self.try_scroll_to_last_top_item();
     }
-    pub fn toggle_backtrace(&mut self) {
-        self.backtrace ^= true;
+    pub fn toggle_backtrace(
+        &mut self,
+        level: &'static str,
+    ) {
+        self.backtrace = if self.backtrace == Some(level) {
+            None
+        } else {
+            Some(level)
+        };
     }
     pub fn toggle_wrap_mode(&mut self) {
         self.wrap ^= true;
diff --git a/website/docs/config.md b/website/docs/config.md
index f2e8b78..503a5a7 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -64,7 +64,7 @@ For example:
 ```TOML
 [keybindings]
 h = "job:clippy"
-shift-F9 = "toggle-backtrace"
+shift-F9 = "toggle-backtrace(1)"
 ctrl-r = "toggle-raw-output"
 ```
 
@@ -172,7 +172,7 @@ quit | <kbd>q</kbd> or <kbd>ctrl</kbd><kbd>q</kbd> or <kbd>ctrl</kbd><kbd>c</kbd
 refresh | <kbd>F5</kbd> | clear output then run current job again
 rerun |  | run current job again
 toggle-raw-output |  | display the untransformed command output
-toggle-backtrace | <kbd>b</kbd> | enable rust backtrace (for example on test failing)
+toggle-backtrace(level) | <kbd>b</kbd> | enable rust backtrace, level is either `1` or `full`
 toggle-summary | <kbd>s</kbd> | display results as abstracts
 toggle-wrap | <kbd>w</kbd> | toggle line wrapping
 scroll-to-top | <kbd>Home</kbd> | scroll to top


Test patch:
diff --git a/tests/line_analysis/color-eyre-suggest-backtrace-issue-219.json b/tests/line_analysis/color-eyre-suggest-backtrace-issue-219.json
new file mode 100644
index 0000000..8633b85
--- /dev/null
+++ b/tests/line_analysis/color-eyre-suggest-backtrace-issue-219.json
@@ -0,0 +1,349 @@
+{
+  "result": "report",
+  "lines": [
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "warning: unreachable statement"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "  --> src/main.rs:17:5"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Location",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   |"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "16 |     panic!();"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   |     -------- any code following this expression is unreachable"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "17 |     println!(r#\"{{\"test\":\"truc\"}}\"#);"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unreachable statement"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   |"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   = note: `#[warn(unreachable_code)]` on by default"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "   = note: this warning originates in the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "warning: `bacon-test` (bin \"bacon-test\") generated 1 warning"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "    Finished dev [unoptimized + debuginfo] target(s) in 0.01s"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "     Running `target/debug/bacon-test`"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "\u001b[31m",
+              "raw": "The application panicked (crashed)."
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "Message:  "
+            },
+            {
+              "csi": "\u001b[36m",
+              "raw": "explicit panic"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "Location: "
+            },
+            {
+              "csi": "\u001b[35m",
+              "raw": "src/main.rs"
+            },
+            {
+              "csi": "",
+              "raw": ":"
+            },
+            {
+              "csi": "\u001b[35m",
+              "raw": "16"
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": []
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "Normal",
+        "key": null
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it."
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "BacktraceSuggestion"
+      }
+    },
+    {
+      "line": {
+        "content": {
+          "strings": [
+            {
+              "csi": "",
+              "raw": "Run with RUST_BACKTRACE=full to include source snippets."
+            }
+          ]
+        },
+        "origin": "StdErr"
+      },
+      "analysis": {
+        "line_type": "BacktraceSuggestion"
+      }
+    }
+  ]
+}