Repository: Canop/bacon (language: rust)

Issue:
Add a command to relaunch tests but limited to the failing one(s)
(follows issue #213 and discussion with @GeeWee)

The idea would  be to hit a key and have the same test/nextest/whatever job relaunched but scoped to the failing test(s).

Hitting escape would take you back to all tests. 


Discussion / hints:
That would also be incredible - just like Jest's option to only rerun failing tests!
A serious problem is that cargo test doesn't support specifying several tests to run. Either you run them all or you run all the ones which contain a specific string.

So if I want an easy scoping feature, it would run only when there's only one failure. Or when using nextest.


Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc67320..970d76a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ### next
 - support for nextest output & default nextest job, bound by default to the 'n' key - Fix #196
+- hit 'f' to have tests "scoped" to the failures - Fix #214
 - 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
diff --git a/README.md b/README.md
index 690ab59..ac6d3c8 100644
--- a/README.md
+++ b/README.md
@@ -60,8 +60,14 @@ This will run against all targets like `check-all` does.
 
     bacon test
 
+or `bacon nextest` if you're a nextest user.
+
 ![bacon test](doc/test.png)
 
+
+When there's a failure, hit <kbd>f</kbd> to restrict the job to the failing test.
+Hit <kbd>esc</kbd> to get back to all tests.
+
 ## define your own jobs
 
 First create a `bacon.toml` file by running
diff --git a/defaults/default-prefs.toml b/defaults/default-prefs.toml
index 84d8387..c8d5cdd 100644
--- a/defaults/default-prefs.toml
+++ b/defaults/default-prefs.toml
@@ -75,10 +75,9 @@ path = "bacon-analysis.json"
 # ctrl-q = "quit"
 # q = "quit"
 # F5 = "rerun"
-# s = "toggle-summary"
-# w = "toggle-wrap"
-# b = "toggle-backtrace"
-# f = "toggle-backtrace(full)"
+# alt-s = "toggle-summary"
+# alt-w = "toggle-wrap"
+# alt-b = "toggle-backtrace"
 # Home = "scroll-to-top"
 # End = "scroll-to-bottom"
 # Up = "scroll-lines(-1)"
diff --git a/src/analysis/analyzer.rs b/src/analysis/analyzer.rs
index 782f4d4..7c343c0 100644
--- a/src/analysis/analyzer.rs
+++ b/src/analysis/analyzer.rs
@@ -134,7 +134,7 @@ impl Analyzer {
                 _ => {}
             }
         }
-        for (key, failure) in failures.drain() {
+        for (key, failure) in &failures {
             // if we know of a failure but there was no content, we add some
             if failure.has_title {
                 continue;
@@ -142,7 +142,7 @@ impl Analyzer {
             fails.push(Line {
                 item_idx: 0, // will be filled later
                 line_type: LineType::Title(Kind::TestFail),
-                content: TLine::failed(&key),
+                content: TLine::failed(key),
             });
             fails.push(Line {
                 item_idx: 0,
@@ -167,11 +167,13 @@ impl Analyzer {
         let mut stats = Stats::from(&lines);
         stats.passed_tests = passed_tests;
         debug!("stats: {:#?}", &stats);
+        let failure_keys = failures.keys().cloned().collect();
         let report = Report {
             lines,
             stats,
             suggest_backtrace,
             output: Default::default(),
+            failure_keys,
         };
         Ok(report)
     }
diff --git a/src/app.rs b/src/app.rs
index eba329e..289e103 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -159,7 +159,6 @@ pub fn run(
                 event_source.unblock(false);
             }
         }
-        info!("action: {action:?}");
         if let Some(action) = action.take() {
             debug!("requested action: {action:?}");
             match action {
@@ -192,6 +191,15 @@ pub fn run(
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
                     }
+                    Internal::ScopeToFailures => {
+                        if let Some(scope) = state.failures_scope() {
+                            info!("scoping to failures: {scope:#?}");
+                            next_job = Some(JobRef::Scope(scope));
+                            break;
+                        } else {
+                            warn!("no available failures scope");
+                        }
+                    }
                     Internal::ToggleRawOutput => {
                         state.toggle_raw_output();
                     }
diff --git a/src/cli.rs b/src/cli.rs
index 353834c..e2b5f4d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -129,7 +129,7 @@ pub fn run() -> anyhow::Result<()> {
     let mut result = Ok(());
     #[allow(clippy::while_let_loop)]
     loop {
-        let (job_name, job) = match job_stack.pick_job(&next_job) {
+        let (concrete_job_ref, job) = match job_stack.pick_job(&next_job) {
             Err(e) => {
                 result = Err(e);
                 break;
@@ -139,7 +139,7 @@ pub fn run() -> anyhow::Result<()> {
                 break;
             }
         };
-        let r = Mission::new(&location, job_name.to_string(), job, &settings)
+        let r = Mission::new(&location, concrete_job_ref, job, &settings)
             .and_then(|mission| app::run(&mut w, mission, &event_source));
         match r {
             Ok(Some(job_ref)) => {
diff --git a/src/help_line.rs b/src/help_line.rs
index 4924677..f17bc73 100644
--- a/src/help_line.rs
+++ b/src/help_line.rs
@@ -10,6 +10,7 @@ pub struct HelpLine {
     close_help: Option<String>,
     pause: Option<String>,
     unpause: Option<String>,
+    scope: Option<String>,
 }
 
 impl HelpLine {
@@ -48,6 +49,9 @@ impl HelpLine {
             .shortest_internal_key(Internal::Unpause)
             .or(kb.shortest_internal_key(Internal::TogglePause))
             .map(|k| format!("*{k}* to unpause"));
+        let scope = kb
+            .shortest_internal_key(Internal::ScopeToFailures)
+            .map(|k| format!("*{k}* to scope to failures"));
         Self {
             quit,
             toggle_summary,
@@ -58,6 +62,7 @@ impl HelpLine {
             close_help,
             pause,
             unpause,
+            scope,
         }
     }
     pub fn markdown(
@@ -70,6 +75,11 @@ impl HelpLine {
                 parts.push(s);
             }
         } else {
+            if state.can_be_scoped() {
+                if let Some(s) = &self.scope {
+                    parts.push(s);
+                }
+            }
             if state.auto_refresh.is_paused() {
                 if let Some(s) = &self.unpause {
                     parts.push(s);
diff --git a/src/internal.rs b/src/internal.rs
index e818acf..cc1b9b0 100644
--- a/src/internal.rs
+++ b/src/internal.rs
@@ -12,6 +12,7 @@ pub enum Internal {
     Quit,
     Refresh, // clear and rerun
     ReRun,
+    ScopeToFailures,
     Scroll(ScrollCommand),
     ToggleBacktrace(&'static str),
     ToggleRawOutput,
@@ -33,6 +34,7 @@ impl fmt::Display for Internal {
             Self::Quit => write!(f, "quit"),
             Self::Refresh => write!(f, "clear then run current job again"),
             Self::ReRun => write!(f, "run current job again"),
+            Self::ScopeToFailures => write!(f, "scope to failures"),
             Self::Scroll(scroll_command) => scroll_command.fmt(f),
             Self::ToggleBacktrace(level) => write!(f, "toggle backtrace ({level})"),
             Self::ToggleRawOutput => write!(f, "toggle raw output"),
@@ -57,6 +59,7 @@ impl std::str::FromStr for Internal {
             "quit" => Ok(Self::Quit),
             "refresh" => Ok(Self::Refresh),
             "rerun" => Ok(Self::ReRun),
+            "scope-to-failures" => Ok(Self::ScopeToFailures),
             "toggle-raw-output" => Ok(Self::ToggleRawOutput),
             "toggle-backtrace" => Ok(Self::ToggleBacktrace("1")),
             "toggle-backtrace(1)" => Ok(Self::ToggleBacktrace("1")),
diff --git a/src/job_ref.rs b/src/job_ref.rs
index d6621c2..5d54094 100644
--- a/src/job_ref.rs
+++ b/src/job_ref.rs
@@ -1,4 +1,6 @@
 use {
+    crate::*,
+    lazy_regex::*,
     serde::{
         Deserialize,
         Deserializer,
@@ -16,25 +18,66 @@ pub enum JobRef {
     Initial,
     Previous,
     Concrete(ConcreteJobRef),
+    Scope(Scope),
 }
 
 impl JobRef {
     pub fn from_job_name<S: Into<String>>(s: S) -> Self {
-        Self::Concrete(ConcreteJobRef::Name(s.into()))
+        Self::Concrete(ConcreteJobRef::from_job_name(s))
     }
 }
 
 /// A "concrete" job ref is one which can be used from the start, without
 /// referring to the job stack
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub enum ConcreteJobRef {
+pub struct ConcreteJobRef {
+    pub name_or_alias: NameOrAlias,
+    pub scope: Scope,
+}
+
+impl ConcreteJobRef {
+    pub fn from_job_name<S: Into<String>>(s: S) -> Self {
+        Self {
+            name_or_alias: NameOrAlias::Name(s.into()),
+            scope: Default::default(),
+        }
+    }
+    pub fn badge_label(&self) -> String {
+        let mut s = String::new();
+        match &self.name_or_alias {
+            NameOrAlias::Name(name) => {
+                s.push_str(name);
+            }
+            NameOrAlias::Alias(alias) => {
+                s.push_str(alias);
+            }
+        }
+        if self.scope.has_tests() {
+            s.push_str(" (scoped)");
+        }
+        s
+    }
+    pub fn with_scope(
+        mut self,
+        scope: Scope,
+    ) -> Self {
+        self.scope = scope;
+        self
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum NameOrAlias {
     Name(String),
     Alias(String),
 }
 
 impl Default for ConcreteJobRef {
     fn default() -> Self {
-        Self::Name("check".to_string())
+        Self {
+            name_or_alias: NameOrAlias::Name("check".to_string()),
+            scope: Default::default(),
+        }
     }
 }
 
@@ -43,10 +86,14 @@ impl fmt::Display for ConcreteJobRef {
         &self,
         f: &mut fmt::Formatter,
     ) -> fmt::Result {
-        match self {
-            Self::Alias(alias) => write!(f, "alias:{alias}"),
-            Self::Name(name) => write!(f, "{name}"),
+        match &self.name_or_alias {
+            NameOrAlias::Alias(alias) => write!(f, "alias:{alias}")?,
+            NameOrAlias::Name(name) => write!(f, "{name}")?,
+        }
+        if self.scope.has_tests() {
+            write!(f, "({})", self.scope.tests.join(","))?;
         }
+        Ok(())
     }
 }
 
@@ -72,10 +119,27 @@ impl<'de> Deserialize<'de> for ConcreteJobRef {
 
 impl From<&str> for ConcreteJobRef {
     fn from(str_entry: &str) -> Self {
-        if let Some(alias) = str_entry.strip_prefix("alias:") {
-            ConcreteJobRef::Alias(alias.to_string())
+        let Some((_, alias_prefix, name_or_alias, scope)) =
+            regex_captures!(r"^(alias:)?([^\(\)]+)(?:\(([^\)]+)\))?$", str_entry,)
+        else {
+            warn!("unexpected job ref: {:?}", str_entry);
+            return Self::from_job_name(str_entry.to_string());
+        };
+        let name_or_alias = if alias_prefix.is_empty() {
+            NameOrAlias::Name(name_or_alias.to_string())
         } else {
-            ConcreteJobRef::Name(str_entry.to_string())
+            NameOrAlias::Alias(name_or_alias.to_string())
+        };
+        let scope = Scope {
+            tests: scope
+                .split(',')
+                .filter(|t| !t.trim().is_empty())
+                .map(|s| s.to_string())
+                .collect(),
+        };
+        Self {
+            name_or_alias,
+            scope,
         }
     }
 }
@@ -89,18 +153,70 @@ impl fmt::Display for JobRef {
             Self::Default => write!(f, "default"),
             Self::Initial => write!(f, "initial"),
             Self::Previous => write!(f, "previous"),
-            Self::Concrete(concrete) => concrete.fmt(f),
+            Self::Scope(Scope { tests }) => write!(f, "scope:{}", tests.join(",")),
+            Self::Concrete(concrete) => write!(f, "{}", concrete),
         }
     }
 }
 
 impl From<&str> for JobRef {
-    fn from(name: &str) -> Self {
-        match name {
-            "default" => Self::Default,
-            "initial" => Self::Initial,
-            "previous" => Self::Previous,
-            _ => Self::Concrete(ConcreteJobRef::from(name)),
-        }
+    fn from(s: &str) -> Self {
+        regex_switch!(s,
+            "^default$"i => Self::Default,
+            "^default$"i => Self::Default,
+            "^initial$"i => Self::Initial,
+            "^previous$"i => Self::Previous,
+            "^scope:(?<tests>.+)$" => Self::Scope(Scope {
+                tests: tests
+                    .split(',')
+                    .filter(|t| !t.trim().is_empty())
+                    .map(|s| s.to_string())
+                    .collect(),
+            }),
+        )
+        .unwrap_or_else(|| Self::Concrete(ConcreteJobRef::from(s)))
+    }
+}
+
+#[test]
+fn test_job_ref_string_round_trip() {
+    let job_refs = vec![
+        JobRef::Default,
+        JobRef::Initial,
+        JobRef::Previous,
+        JobRef::Concrete(ConcreteJobRef {
+            name_or_alias: NameOrAlias::Name("run".to_string()),
+            scope: Scope::default(),
+        }),
+        JobRef::Concrete(ConcreteJobRef {
+            name_or_alias: NameOrAlias::Name("nextest".to_string()),
+            scope: Scope {
+                tests: vec!["first::test".to_string(), "second_test".to_string()],
+            },
+        }),
+        JobRef::Concrete(ConcreteJobRef {
+            name_or_alias: NameOrAlias::Alias("my-check".to_string()),
+            scope: Scope::default(),
+        }),
+        JobRef::Concrete(ConcreteJobRef {
+            name_or_alias: NameOrAlias::Alias("my-test".to_string()),
+            scope: Scope {
+                tests: vec!["abc".to_string()],
+            },
+        }),
+        JobRef::Concrete(ConcreteJobRef {
+            name_or_alias: NameOrAlias::Name("nextest".to_string()),
+            scope: Scope {
+                tests: vec!["abc".to_string()],
+            },
+        }),
+        JobRef::Scope(Scope {
+            tests: vec!["first::test".to_string(), "second_test".to_string()],
+        }),
+    ];
+    for job_ref in job_refs {
+        let s = job_ref.to_string();
+        let job_ref2 = JobRef::from(s.as_str());
+        assert_eq!(job_ref, job_ref2);
     }
 }
diff --git a/src/job_stack.rs b/src/job_stack.rs
index 9173d4f..b6e6fd1 100644
--- a/src/job_stack.rs
+++ b/src/job_stack.rs
@@ -6,8 +6,8 @@ use {
     },
 };
 
-/// The stack of jobs that bacon ran, allowing
-/// to get back to the previous one
+/// The stack of jobs that bacon ran, allowing to get back to the previous one,
+/// or to scope the current one
 pub struct JobStack<'c> {
     settings: &'c Settings,
     entries: Vec<ConcreteJobRef>,
@@ -28,6 +28,9 @@ impl<'c> JobStack<'c> {
             .unwrap_or(&self.settings.default_job)
     }
 
+    /// Apply the job ref instruction to determine the job to run, updating the stack.
+    ///
+    /// When no job is returned, the application is supposed to quit.
     pub fn pick_job(
         &mut self,
         job_ref: &JobRef,
@@ -37,19 +40,38 @@ impl<'c> JobStack<'c> {
             JobRef::Default => self.settings.default_job.clone(),
             JobRef::Initial => self.initial_job().clone(),
             JobRef::Previous => {
-                self.entries.pop();
+                let current = self.entries.pop();
                 match self.entries.pop() {
                     Some(concrete) => concrete,
+                    None if current
+                        .as_ref()
+                        .map_or(false, |current| current.scope.has_tests()) =>
+                    {
+                        // rather than quitting, we assume the user wants to "unscope"
+                        ConcreteJobRef {
+                            name_or_alias: current.unwrap().name_or_alias,
+                            scope: Scope::default(),
+                        }
+                    }
                     None => {
                         return Ok(None);
                     }
                 }
             }
             JobRef::Concrete(concrete) => concrete.clone(),
+            JobRef::Scope(scope) => match self.entries.last() {
+                Some(concrete) => ConcreteJobRef {
+                    name_or_alias: concrete.name_or_alias.clone(),
+                    scope: scope.clone(),
+                },
+                None => {
+                    return Ok(None);
+                }
+            },
         };
-        let job = match &concrete {
-            ConcreteJobRef::Alias(alias) => Job::from_alias(alias, self.settings),
-            ConcreteJobRef::Name(name) => self
+        let job = match &concrete.name_or_alias {
+            NameOrAlias::Alias(alias) => Job::from_alias(alias, self.settings),
+            NameOrAlias::Name(name) => self
                 .settings
                 .jobs
                 .get(name)
diff --git a/src/keybindings.rs b/src/keybindings.rs
index 8db8ce9..281be70 100644
--- a/src/keybindings.rs
+++ b/src/keybindings.rs
@@ -38,6 +38,7 @@ impl Default for KeyBindings {
         bindings.set(key!(PageUp), Internal::Scroll(ScrollCommand::Pages(-1)));
         bindings.set(key!(PageDown), Internal::Scroll(ScrollCommand::Pages(1)));
         bindings.set(key!(Space), Internal::Scroll(ScrollCommand::Pages(1)));
+        bindings.set(key!(f), Internal::ScopeToFailures);
         bindings.set(key!(esc), Internal::Back);
         bindings.set(key!(ctrl - d), JobRef::Default);
         bindings.set(key!(i), JobRef::Initial);
diff --git a/src/lib.rs b/src/lib.rs
index 233e75b..2b44f0d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -29,6 +29,7 @@ mod mission;
 mod mission_location;
 mod on_change_strategy;
 mod report;
+mod scope;
 mod scroll;
 mod settings;
 mod state;
@@ -69,6 +70,7 @@ pub use {
     mission_location::*,
     on_change_strategy::*,
     report::*,
+    scope::*,
     scroll::*,
     settings::*,
     state::*,
diff --git a/src/mission.rs b/src/mission.rs
index b1a90d7..89b49b9 100644
--- a/src/mission.rs
+++ b/src/mission.rs
@@ -21,7 +21,7 @@ static DEFAULT_WATCHES: &[&str] = &["src", "tests", "benches", "examples", "buil
 #[derive(Debug)]
 pub struct Mission<'s> {
     pub location_name: String,
-    pub job_name: String,
+    pub concrete_job_ref: ConcreteJobRef,
     pub cargo_execution_directory: PathBuf,
     pub workspace_root: PathBuf,
     pub job: Job,
@@ -33,7 +33,7 @@ pub struct Mission<'s> {
 impl<'s> Mission<'s> {
     pub fn new(
         location: &MissionLocation,
-        job_name: String,
+        concrete_job_ref: ConcreteJobRef,
         job: Job,
         settings: &'s Settings,
     ) -> Result<Self> {
@@ -69,7 +69,7 @@ impl<'s> Mission<'s> {
                                 files_to_watch.push(full_path.into());
                             }
                         } else {
-                            info!("missing {} : {:?}", dir, full_path);
+                            debug!("missing {} : {:?}", dir, full_path);
                         }
                     }
                 }
@@ -84,7 +84,7 @@ impl<'s> Mission<'s> {
         let cargo_execution_directory = location.package_directory.clone();
         Ok(Mission {
             location_name,
-            job_name,
+            concrete_job_ref,
             cargo_execution_directory,
             workspace_root: location.workspace_root.clone(),
             job,
@@ -146,10 +146,8 @@ impl<'s> Mission<'s> {
 
     /// build (and doesn't call) the external cargo command
     pub fn get_command(&self) -> Command {
-        let expanded;
-        let command = if self.job.expand_env_vars {
-            expanded = self
-                .job
+        let mut command = if self.job.expand_env_vars {
+            self.job
                 .command
                 .iter()
                 .map(|token| {
@@ -164,11 +162,26 @@ impl<'s> Mission<'s> {
                     })
                     .to_string()
                 })
-                .collect();
-            &expanded
+                .collect()
         } else {
-            &self.job.command
+            self.job.command.clone()
         };
+
+        let scope = &self.concrete_job_ref.scope;
+        if scope.has_tests() && command.len() > 2 {
+            let tests = if command[0] == "cargo" && command[1] == "test" {
+                // Here we're going around a limitation of the vanilla cargo test:
+                // it can only be scoped to one test
+                &scope.tests[..1]
+            } else {
+                &scope.tests
+            };
+            for test in tests {
+                command.push(test.to_string());
+            }
+        }
+
+        info!("command: {command:#?}");
         let mut tokens = command.iter();
         let mut command = Command::new(
             tokens.next().unwrap(), // implies a check in the job
diff --git a/src/report.rs b/src/report.rs
index f902fa6..d27a2ff 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -20,6 +20,7 @@ pub struct Report {
     pub stats: Stats,
     pub suggest_backtrace: bool,
     pub output: CommandOutput,
+    pub failure_keys: Vec<String>,
 }
 
 impl Report {
diff --git a/src/scope.rs b/src/scope.rs
new file mode 100644
index 0000000..360ef8a
--- /dev/null
+++ b/src/scope.rs
@@ -0,0 +1,11 @@
+/// A dynamic reduction of a job execution
+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
+pub struct Scope {
+    pub tests: Vec<String>,
+}
+
+impl Scope {
+    pub fn has_tests(&self) -> bool {
+        !self.tests.is_empty()
+    }
+}
diff --git a/src/settings.rs b/src/settings.rs
index ce74286..87ac9d4 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -152,7 +152,7 @@ impl Settings {
         if self.jobs.is_empty() {
             bail!("Invalid configuration : no job found");
         }
-        if let ConcreteJobRef::Name(name) = &self.default_job {
+        if let NameOrAlias::Name(name) = &self.default_job.name_or_alias {
             if !self.jobs.contains_key(name) {
                 bail!("Invalid configuration : default job ({name:?}) not found in jobs");
             }
diff --git a/src/state.rs b/src/state.rs
index 9c3cb3e..0a37968 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -144,6 +144,19 @@ impl<'s> AppState<'s> {
     pub fn has_report(&self) -> bool {
         matches!(self.cmd_result, CommandResult::Report(_))
     }
+    pub fn can_be_scoped(&self) -> bool {
+        self.cmd_result
+            .report()
+            .map_or(false, |report| report.stats.can_scope_tests())
+    }
+    pub fn failures_scope(&self) -> Option<Scope> {
+        if !self.can_be_scoped() {
+            return None;
+        }
+        self.cmd_result.report().map(|report| Scope {
+            tests: report.failure_keys.clone(),
+        })
+    }
     pub fn toggle_raw_output(&mut self) {
         self.raw_output ^= true;
     }
@@ -155,11 +168,12 @@ impl<'s> AppState<'s> {
             cmd_result.reverse();
         }
         match &cmd_result {
-            CommandResult::Report(_) => {
-                debug!("GOT REPORT");
+            CommandResult::Report(report) => {
+                debug!("Got report");
+                info!("Stats: {:#?}", report.stats);
             }
             CommandResult::Failure(_) => {
-                debug!("GOT FAILURE");
+                debug!("Got failure");
             }
             CommandResult::None => {
                 debug!("GOT NONE ???");
@@ -412,7 +426,8 @@ impl<'s> AppState<'s> {
         let project_name = &self.mission.location_name;
         t_line.add_badge(TString::badge(project_name, 255, 240));
         // black over pink
-        t_line.add_badge(TString::badge(&self.mission.job_name, 235, 204));
+        let job_label = self.mission.concrete_job_ref.badge_label();
+        t_line.add_badge(TString::badge(&job_label, 235, 204));
         if let CommandResult::Report(report) = &self.cmd_result {
             let stats = &report.stats;
             if stats.errors > 0 {
diff --git a/src/stats.rs b/src/stats.rs
index c9b62a6..26b318e 100644
--- a/src/stats.rs
+++ b/src/stats.rs
@@ -44,4 +44,7 @@ impl Stats {
     pub fn items(&self) -> usize {
         self.warnings + self.errors + self.test_fails
     }
+    pub fn can_scope_tests(&self) -> bool {
+        self.passed_tests > 0 && self.test_fails > 0
+    }
 }
diff --git a/src/tty/mod.rs b/src/tty/mod.rs
new file mode 100644
index 0000000..c7b877e
--- /dev/null
+++ b/src/tty/mod.rs
@@ -0,0 +1,38 @@
+mod tline;
+mod tstring;
+
+pub const CSI_RESET: &str = "\u{1b}[0m\u{1b}[0m";
+pub const CSI_BOLD: &str = "\u{1b}[1m";
+pub const CSI_ITALIC: &str = "\u{1b}[3m";
+
+pub const CSI_GREEN: &str = "\u{1b}[32m";
+
+pub const CSI_RED: &str = "\u{1b}[31m";
+pub const CSI_BOLD_RED: &str = "\u{1b}[1m\u{1b}[38;5;9m";
+pub const CSI_BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m";
+
+/// Used for "Blocking"
+pub const CSI_BLUE: &str = "\u{1b}[1m\u{1b}[36m";
+
+#[cfg(windows)]
+pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[38;5;11m";
+#[cfg(not(windows))]
+pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[33m";
+
+#[cfg(windows)]
+pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;14m";
+#[cfg(not(windows))]
+pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;12m";
+
+#[cfg(windows)]
+pub const CSI_BOLD_4BIT_YELLOW: &str = "\u{1b}[1m\u{1b}[33m";
+
+#[cfg(windows)]
+pub const CSI_BOLD_WHITE: &str = "\u{1b}[1m\u{1b}[38;5;15m";
+
+static TAB_REPLACEMENT: &str = "    ";
+
+pub use {
+    tline::*,
+    tstring::*,
+};
diff --git a/src/tty.rs b/src/tty/tline.rs
similarity index 58%
rename from src/tty.rs
rename to src/tty/tline.rs
index 667bf5f..37d5a89 100644
--- a/src/tty.rs
+++ b/src/tty/tline.rs
@@ -1,157 +1,13 @@
 use {
+    super::*,
     crate::*,
     anyhow::*,
     serde::{
         Deserialize,
         Serialize,
     },
-    std::{
-        fmt::Write as _,
-        io::Write,
-    },
-    termimad::StrFit,
 };
 
-pub const CSI_RESET: &str = "\u{1b}[0m\u{1b}[0m";
-pub const CSI_BOLD: &str = "\u{1b}[1m";
-pub const CSI_ITALIC: &str = "\u{1b}[3m";
-
-pub const CSI_GREEN: &str = "\u{1b}[32m";
-
-pub const CSI_RED: &str = "\u{1b}[31m";
-pub const CSI_BOLD_RED: &str = "\u{1b}[1m\u{1b}[38;5;9m";
-pub const CSI_BOLD_ORANGE: &str = "\u{1b}[1m\u{1b}[38;5;208m";
-
-/// Used for "Blocking"
-pub const CSI_BLUE: &str = "\u{1b}[1m\u{1b}[36m";
-
-#[cfg(windows)]
-pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[38;5;11m";
-#[cfg(not(windows))]
-pub const CSI_BOLD_YELLOW: &str = "\u{1b}[1m\u{1b}[33m";
-
-#[cfg(windows)]
-pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;14m";
-#[cfg(not(windows))]
-pub const CSI_BOLD_BLUE: &str = "\u{1b}[1m\u{1b}[38;5;12m";
-
-#[cfg(windows)]
-pub const CSI_BOLD_4BIT_YELLOW: &str = "\u{1b}[1m\u{1b}[33m";
-
-#[cfg(windows)]
-pub const CSI_BOLD_WHITE: &str = "\u{1b}[1m\u{1b}[38;5;15m";
-
-static TAB_REPLACEMENT: &str = "    ";
-
-/// a simple representation of a colored and styled string.
-///
-/// Note that this works because of a few properties of
-/// cargo's output:
-/// - styles and colors are always reset on changes
-/// - they're always in the same order (bold then fg color)
-///
-/// A more generic parsing would have to:
-/// - parse the csi params (it's simple enough to map but takes code)
-/// - use a simple state machine to keep style (bold, italic, etc.),
-///    foreground color, and background color across tstrings
-#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct TString {
-    pub csi: String,
-    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,
-        fg: u8,
-        bg: u8,
-    ) -> Self {
-        Self {
-            csi: format!("\u{1b}[1m\u{1b}[38;5;{}m\u{1b}[48;5;{}m", fg, bg),
-            raw: format!(" {} ", con),
-        }
-    }
-    pub fn num_badge(
-        num: usize,
-        cat: &str,
-        fg: u8,
-        bg: u8,
-    ) -> Self {
-        let raw = if num < 2 {
-            format!(" {} {} ", num, cat)
-        } else {
-            format!(" {} {}s ", num, cat)
-        };
-        Self::badge(&raw, fg, bg)
-    }
-    pub fn push_csi(
-        &mut self,
-        params: &[i64],
-        action: char,
-    ) {
-        self.csi.push('\u{1b}');
-        self.csi.push('[');
-        for (idx, p) in params.iter().enumerate() {
-            let _ = write!(self.csi, "{}", p);
-            if idx < params.len() - 1 {
-                self.csi.push(';');
-            }
-        }
-        self.csi.push(action);
-    }
-    pub fn draw(
-        &self,
-        w: &mut W,
-    ) -> Result<()> {
-        if self.csi.is_empty() {
-            write!(w, "{}", &self.raw)?;
-        } else {
-            write!(w, "{}{}{}", &self.csi, &self.raw, CSI_RESET,)?;
-        }
-        Ok(())
-    }
-    /// draw the string but without taking more than cols_max cols.
-    /// Return the number of cols written
-    pub fn draw_in(
-        &self,
-        w: &mut W,
-        cols_max: usize,
-    ) -> Result<usize> {
-        let fit = StrFit::make_cow(&self.raw, cols_max);
-        if self.csi.is_empty() {
-            write!(w, "{}", &fit.0)?;
-        } else {
-            write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?;
-        }
-        Ok(fit.1)
-    }
-    pub fn starts_with(
-        &self,
-        csi: &str,
-        raw: &str,
-    ) -> bool {
-        self.csi == csi && self.raw.starts_with(raw)
-    }
-    pub fn split_off(
-        &mut self,
-        at: usize,
-    ) -> Self {
-        Self {
-            csi: self.csi.clone(),
-            raw: self.raw.split_off(at),
-        }
-    }
-}
-
 /// a simple representation of a line made of homogeneous parts.
 ///
 /// Note that this only manages CSI and SGR components
diff --git a/src/tty/tstring.rs b/src/tty/tstring.rs
new file mode 100644
index 0000000..7686181
--- /dev/null
+++ b/src/tty/tstring.rs
@@ -0,0 +1,123 @@
+use {
+    super::*,
+    crate::*,
+    anyhow::*,
+    serde::{
+        Deserialize,
+        Serialize,
+    },
+    std::{
+        fmt::Write as _,
+        io::Write,
+    },
+    termimad::StrFit,
+};
+
+/// a simple representation of a colored and styled string.
+///
+/// Note that this works because of a few properties of
+/// cargo's output:
+/// - styles and colors are always reset on changes
+/// - they're always in the same order (bold then fg color)
+///
+/// A more generic parsing would have to:
+/// - parse the csi params (it's simple enough to map but takes code)
+/// - use a simple state machine to keep style (bold, italic, etc.),
+///    foreground color, and background color across tstrings
+#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct TString {
+    pub csi: String,
+    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,
+        fg: u8,
+        bg: u8,
+    ) -> Self {
+        Self {
+            csi: format!("\u{1b}[1m\u{1b}[38;5;{}m\u{1b}[48;5;{}m", fg, bg),
+            raw: format!(" {} ", con),
+        }
+    }
+    pub fn num_badge(
+        num: usize,
+        cat: &str,
+        fg: u8,
+        bg: u8,
+    ) -> Self {
+        let raw = if num < 2 {
+            format!(" {} {} ", num, cat)
+        } else {
+            format!(" {} {}s ", num, cat)
+        };
+        Self::badge(&raw, fg, bg)
+    }
+    pub fn push_csi(
+        &mut self,
+        params: &[i64],
+        action: char,
+    ) {
+        self.csi.push('\u{1b}');
+        self.csi.push('[');
+        for (idx, p) in params.iter().enumerate() {
+            let _ = write!(self.csi, "{}", p);
+            if idx < params.len() - 1 {
+                self.csi.push(';');
+            }
+        }
+        self.csi.push(action);
+    }
+    pub fn draw(
+        &self,
+        w: &mut W,
+    ) -> Result<()> {
+        if self.csi.is_empty() {
+            write!(w, "{}", &self.raw)?;
+        } else {
+            write!(w, "{}{}{}", &self.csi, &self.raw, CSI_RESET,)?;
+        }
+        Ok(())
+    }
+    /// draw the string but without taking more than cols_max cols.
+    /// Return the number of cols written
+    pub fn draw_in(
+        &self,
+        w: &mut W,
+        cols_max: usize,
+    ) -> Result<usize> {
+        let fit = StrFit::make_cow(&self.raw, cols_max);
+        if self.csi.is_empty() {
+            write!(w, "{}", &fit.0)?;
+        } else {
+            write!(w, "{}{}{}", &self.csi, &fit.0, CSI_RESET)?;
+        }
+        Ok(fit.1)
+    }
+    pub fn starts_with(
+        &self,
+        csi: &str,
+        raw: &str,
+    ) -> bool {
+        self.csi == csi && self.raw.starts_with(raw)
+    }
+    pub fn split_off(
+        &mut self,
+        at: usize,
+    ) -> Self {
+        Self {
+            csi: self.csi.clone(),
+            raw: self.raw.split_off(at),
+        }
+    }
+}
diff --git a/website/docs/config.md b/website/docs/config.md
index 503a5a7..5a596a8 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -175,6 +175,7 @@ toggle-raw-output |  | display the untransformed command output
 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
+scope-to-failures | <kbd>f</kbd> | restrict job to test failure(s)
 scroll-to-top | <kbd>Home</kbd> | scroll to top
 scroll-to-bottom | <kbd>End</kbd> | scroll to bottom
 scroll-lines(-1) | <kbd>↑</kbd> | move one line up
diff --git a/website/docs/index.md b/website/docs/index.md
index 15e7290..1ea87be 100644
--- a/website/docs/index.md
+++ b/website/docs/index.md
@@ -42,6 +42,9 @@ bacon test
 
 ![test](img/test.png)
 
+When there's a failure, hit <kbd>f</kbd> to restrict the job to the failing test.
+Hit <kbd>esc</kbd> to get back to all tests.
+
 While in bacon, you can see Clippy warnings by hitting the <kbd>c</kbd> key. And you get back to your previous job with <kbd>esc</kbd>
 
 You may also open the `cargo doc` in your browser with the <kbd>d</kbd> key.


Test patch: