From 1a0b384a1d00a2dfcafa2a6795a4663372d0241e Mon Sep 17 00:00:00 2001 From: romancitodev <84428770+romancitodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:25:34 -0300 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20wrap=20failed-test=20?= =?UTF-8?q?stdout=20to=20terminal=20width=20instead=20of=20clipping=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit norimel clips text runs at the buffer edge rather than wrapping them, so a single long Debug line in a failed test's captured stdout got cut mid-token. Hard-wrap each line to the terminal width (minus a right-hand padding margin) before handing it to the existing scroll/pagination logic. --- src/main.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index b104b16..b10f5e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,9 @@ const SETTLE_STEP: f32 = 0.125; /// expanded. Not the actual display cap: the render step sizes that to what the real terminal /// can show, so this only has to be a reasonable approximation for input handling. const STDOUT_PAGE: usize = 20; +/// Right-hand breathing room for wrapped stdout lines, so captured output stops just short of +/// the terminal edge instead of spanning the full width. +const STDOUT_RIGHT_PADDING: usize = 4; /// What's being built: identity, disk footprint, per-crate versions. #[derive(Default)] @@ -91,6 +94,34 @@ fn terminal_rows() -> usize { terminal::size().map(|(_, rows)| rows).unwrap_or(24) as usize } +/// Terminal columns currently available, or a sane guess when the query fails (e.g. not a tty). +fn terminal_cols() -> usize { + terminal::size().map(|(cols, _)| cols).unwrap_or(80) as usize +} + +/// Hard-wraps `line` to `width` chars so nothing scrolls off past the terminal edge instead of +/// being silently clipped by ratatui's buffer (norimel cuts, it doesn't wrap — see its own doc +/// comment). ponytail: single-width char count, not display width; wide/CJK glyphs would wrap +/// early. Fine for the plain-ASCII Debug output this feeds today. +fn wrap_line(line: &str, width: usize) -> Vec<&str> { + if width == 0 { + return vec![line]; + } + let mut out = Vec::new(); + let mut start = 0; + let mut count = 0; + for (i, _) in line.char_indices() { + if count == width { + out.push(&line[start..i]); + start = i; + count = 0; + } + count += 1; + } + out.push(&line[start..]); + out +} + /// Everything the render loop accumulates across frames. #[derive(Default)] struct State { @@ -655,7 +686,14 @@ fn main() -> Result<()> { // ratatui's inline viewport then silently clips instead of scrolling. Sizing // the window to what's actually left on screen shows the whole thing // whenever it fits (the common case), only scrolling when it truly can't. - let stdout_lines: Vec<&str> = f.stdout.lines().collect(); + let indent_width = terminal_cols() + .saturating_sub(2 + STDOUT_RIGHT_PADDING) + .max(1); + let stdout_lines: Vec<&str> = f + .stdout + .lines() + .flat_map(|line| wrap_line(line, indent_width)) + .collect(); let budget = terminal_rows() .saturating_sub(lines.len()) .saturating_sub(2) @@ -722,3 +760,15 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::wrap_line; + + #[test] + fn wraps_long_lines_and_leaves_short_ones_alone() { + assert_eq!(wrap_line("short", 10), vec!["short"]); + assert_eq!(wrap_line("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); + assert_eq!(wrap_line("", 4), vec![""]); + } +} From 664c430313e04d33db267832759439ce8320d1b7 Mon Sep 17 00:00:00 2001 From: romancitodev <84428770+romancitodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:36:37 -0300 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat:=20highlight=20the=20panic?= =?UTF-8?q?=20message=20and=20colorize=20assert=5Feq!/assert=5Fne!=20diffs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failed-test stdout panel gave every line the same visual weight, so the one line that matters (the panic message) got lost in whatever the test printed before it. Tag the message line bold and, when libtest's own " left: "/" right: " pair follows it, color left red and right green, so the eye lands on the failure instead of scanning a wall of uniform text. Verified against real cargo test --format json output for an assert_eq! failure, not just synthetic strings. --- src/main.rs | 106 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index b10f5e7..1aa6498 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,10 +99,8 @@ fn terminal_cols() -> usize { terminal::size().map(|(cols, _)| cols).unwrap_or(80) as usize } -/// Hard-wraps `line` to `width` chars so nothing scrolls off past the terminal edge instead of -/// being silently clipped by ratatui's buffer (norimel cuts, it doesn't wrap — see its own doc -/// comment). ponytail: single-width char count, not display width; wide/CJK glyphs would wrap -/// early. Fine for the plain-ASCII Debug output this feeds today. +/// Hard-wraps `line` to `width` chars. norimel clips instead of wrapping, so this has to. +/// ponytail: char count, not display width. Fine for ASCII debug output. fn wrap_line(line: &str, width: usize) -> Vec<&str> { if width == 0 { return vec![line]; @@ -122,6 +120,44 @@ fn wrap_line(line: &str, width: usize) -> Vec<&str> { out } +/// How a stdout line should stand out. Panic message and assert diff get styled, the rest +/// stays `Plain`. +#[derive(Clone, Copy, PartialEq, Debug)] +enum StdoutStyle { + Plain, + Message, + Left, + Right, +} + +/// Tags the line after `panicked at ...:` as the message, and libtest's ` left: `/` right: ` +/// pair after it, when present, as the diff. +fn classify_stdout(stdout: &str) -> Vec<(StdoutStyle, &str)> { + let mut out = Vec::new(); + let mut lines = stdout.lines().peekable(); + while let Some(line) = lines.next() { + out.push((StdoutStyle::Plain, line)); + if !line.contains("panicked at ") { + continue; + } + let Some(message) = lines.next() else { continue }; + out.push((StdoutStyle::Message, message)); + if let Some(left) = lines.peek().copied() + && left.starts_with(" left: ") + { + out.push((StdoutStyle::Left, left)); + lines.next(); + if let Some(right) = lines.peek().copied() + && right.starts_with(" right: ") + { + out.push((StdoutStyle::Right, right)); + lines.next(); + } + } + } + out +} + /// Everything the render loop accumulates across frames. #[derive(Default)] struct State { @@ -689,10 +725,13 @@ fn main() -> Result<()> { let indent_width = terminal_cols() .saturating_sub(2 + STDOUT_RIGHT_PADDING) .max(1); - let stdout_lines: Vec<&str> = f - .stdout - .lines() - .flat_map(|line| wrap_line(line, indent_width)) + let stdout_lines: Vec<(StdoutStyle, &str)> = classify_stdout(&f.stdout) + .into_iter() + .flat_map(|(style, line)| { + wrap_line(line, indent_width) + .into_iter() + .map(move |chunk| (style, chunk)) + }) .collect(); let budget = terminal_rows() .saturating_sub(lines.len()) @@ -704,8 +743,14 @@ fn main() -> Result<()> { if scroll > 0 { lines.push(rimel::text(format!(" ↑ {scroll} more lines (PageUp)")).dim()); } - for line in &stdout_lines[scroll..end] { - lines.push(rimel::text(format!(" {line}"))); + for (style, line) in &stdout_lines[scroll..end] { + let text = rimel::text(format!(" {line}")); + lines.push(match style { + StdoutStyle::Plain => text, + StdoutStyle::Message => text.bold(), + StdoutStyle::Left => text.fg(rimel::palette::RED), + StdoutStyle::Right => text.fg(rimel::palette::GREEN), + }); } let below = stdout_lines.len() - end; if below > 0 { @@ -763,7 +808,7 @@ fn main() -> Result<()> { #[cfg(test)] mod tests { - use super::wrap_line; + use super::{StdoutStyle, classify_stdout, wrap_line}; #[test] fn wraps_long_lines_and_leaves_short_ones_alone() { @@ -771,4 +816,43 @@ mod tests { assert_eq!(wrap_line("abcdefghij", 4), vec!["abcd", "efgh", "ij"]); assert_eq!(wrap_line("", 4), vec![""]); } + + #[test] + fn classifies_the_panic_message_and_assert_eq_diff() { + let stdout = "thread 'x' panicked at src/lib.rs:1:1:\n\ + assertion `left == right` failed\n left: `1`\n right: `2`\n\ + note: run with `RUST_BACKTRACE=1`"; + assert_eq!( + classify_stdout(stdout), + vec![ + ( + StdoutStyle::Plain, + "thread 'x' panicked at src/lib.rs:1:1:" + ), + (StdoutStyle::Message, "assertion `left == right` failed"), + (StdoutStyle::Left, " left: `1`"), + (StdoutStyle::Right, " right: `2`"), + (StdoutStyle::Plain, "note: run with `RUST_BACKTRACE=1`"), + ] + ); + } + + #[test] + fn leaves_a_plain_panic_message_uncolored_without_a_diff() { + let stdout = "thread 'x' panicked at src/lib.rs:1:1:\n\ + called `Option::unwrap()` on a `None` value"; + assert_eq!( + classify_stdout(stdout), + vec![ + ( + StdoutStyle::Plain, + "thread 'x' panicked at src/lib.rs:1:1:" + ), + ( + StdoutStyle::Message, + "called `Option::unwrap()` on a `None` value" + ), + ] + ); + } } From 2140b04af4c4dc6aaf29b55f3ceac183fe25924f Mon Sep 17 00:00:00 2001 From: romancitodev <84428770+romancitodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:19:52 -0300 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20fix:=20clamp=20stdout=20scro?= =?UTF-8?q?ll=20in=20place=20and=20let=20'q'=20quit=20like=20Esc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdout_scroll only got clamped at render time for display, so it kept counting past the true max while held at the bottom, and Up needed the same number of presses to unwind that overshoot before the view actually moved. Write the clamped value back to state instead of just reading a clamped local. Also add Char('q') next to every existing Esc quit/dismiss arm, and mention it in the two hint lines. --- src/main.rs | 12 ++++++++---- src/ui.rs | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1aa6498..230dfa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,7 +462,7 @@ fn main() -> Result<()> { s.warnings.inspecting = false; } KeyCode::Enter => s.warnings.inspecting = !s.warnings.inspecting, - KeyCode::Esc => { + KeyCode::Esc | KeyCode::Char('q') => { dismissing.set(true); cx.render(rimel::text("")); return; @@ -534,7 +534,7 @@ fn main() -> Result<()> { KeyCode::PageDown => { s.failed.stdout_scroll = s.failed.stdout_scroll.saturating_add(STDOUT_PAGE); } - KeyCode::Esc => quit(), + KeyCode::Esc | KeyCode::Char('q') => quit(), _ => {} } } @@ -679,7 +679,7 @@ fn main() -> Result<()> { lines.push(rimel::text("")); lines.push( rimel::text( - "↑↓ select / scroll when expanded Enter expand/collapse PgUp/PgDn page", + "↑↓ select / scroll when expanded Enter expand/collapse PgUp/PgDn page Esc/q quit", ) .dim(), ); @@ -737,8 +737,12 @@ fn main() -> Result<()> { .saturating_sub(lines.len()) .saturating_sub(2) .max(3); + // Written back, not just read: without this, holding Down past the bottom + // keeps incrementing stdout_scroll past what's ever shown, and Up then has + // to burn through that overshoot before the view visibly moves again. let max_scroll = stdout_lines.len().saturating_sub(budget); - let scroll = s.failed.stdout_scroll.min(max_scroll); + s.failed.stdout_scroll = s.failed.stdout_scroll.min(max_scroll); + let scroll = s.failed.stdout_scroll; let end = (scroll + budget).min(stdout_lines.len()); if scroll > 0 { lines.push(rimel::text(format!(" ↑ {scroll} more lines (PageUp)")).dim()); diff --git a/src/ui.rs b/src/ui.rs index f1ea389..b2a76d9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -62,7 +62,7 @@ pub fn warning_panel(warnings: &[Warning], selected: usize, inspecting: bool) -> rimel::text(""), boxed, rimel::text(""), - rimel::text("↑↓ navigate Enter inspect Esc dismiss").dim(), + rimel::text("↑↓ navigate Enter inspect Esc/q dismiss").dim(), ]) } From ebe72c0888538687c8775132e919052ae8acb68f Mon Sep 17 00:00:00 2001 From: romancitodev <84428770+romancitodev@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:35:39 -0300 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20feat:=20retry=20a=20single=20fa?= =?UTF-8?q?iled=20test=20with=20'r'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing a failing test used to mean rerunning the whole suite to check it. `r` on the selected row now rebuilds quietly (no live progress, so the build/progress block above the accordion doesn't reanimate) and reruns just that test with --exact, trying each binary in turn since a rebuilt binary's hash-suffixed filename can change. While it runs the row shows a yellow triangle instead of its marker/time. On pass it drops out of the list and selection moves on; on failure it updates in place with the fresh location/stdout, still expanded. build() now takes tx as Option<&Emitter> so the quiet rebuild can reuse it without sending progress events, and returns the compiler's error text so a failed retry rebuild has something to show instead of nothing. Verified against a real two-binary scratch crate: a binary with no matching --exact test emits zero `test` events (confirming the try-next-binary logic) and the one that does have it reports the real failure. --- src/build.rs | 93 ++++++++++++++++++++++++++++++----------------- src/main.rs | 90 +++++++++++++++++++++++++++++++++++++--------- src/test.rs | 100 ++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 221 insertions(+), 62 deletions(-) diff --git a/src/build.rs b/src/build.rs index 860fb54..7ad17a7 100644 --- a/src/build.rs +++ b/src/build.rs @@ -44,6 +44,9 @@ pub enum Event { target_dir: std::path::PathBuf, total: usize, versions: HashMap, + /// Package id to crate name, kept around so a later retry can rebuild without paying + /// for another `cargo metadata` call just to resolve artifact names again. + names: HashMap, }, /// A more exact unit count than `Ready`'s, from cargo's own unit graph. Arrives later /// because it costs its own `cargo` invocation, run only after the real build is under way. @@ -76,6 +79,12 @@ pub enum Event { secs: f32, outcome: Outcome, }, + /// A single failed test's `r`-triggered rerun came back. + RetryFinished { + name: String, + secs: f32, + outcome: Outcome, + }, Done(bool), } @@ -114,16 +123,18 @@ fn to_warning(d: &Diagnostic) -> Warning { } } -/// Compiles with `cargo --message-format=json ` and streams progress -/// as [`Event`]s. Returns whether it succeeded and every runnable artifact it produced (bins, -/// examples, test binaries), in the order cargo built them. The caller decides what to do with -/// those (`run` executes the one it found, `test` runs every one of them itself). +/// Compiles with `cargo --message-format=json ` and, when `tx` is +/// `Some`, streams progress as [`Event`]s. `None` is the quiet path a retry rebuilds with: no +/// progress events, since that would reanimate the build/progress block above an accordion the +/// user is mid-browse in. Either way, returns whether it succeeded, every runnable artifact it +/// produced (bins, examples, test binaries) in build order, and the compiler's error text (empty +/// on success). pub fn build( - tx: &Emitter, + tx: Option<&Emitter>, names: &HashMap, cargo_args: &[&str], extra_args: &[String], -) -> (bool, Vec) { +) -> (bool, Vec, String) { let mut child = Command::new("cargo") .args(cargo_args) .args(["--message-format=json", "--color=always"]) @@ -136,7 +147,7 @@ pub fn build( // cargo writes "Compiling"/"Finished" straight to stderr regardless of --message-format; // it's the only place we learn a crate *started*, so a second thread parses it for that. let stderr = child.stderr.take().unwrap(); - let stderr_tx = tx.clone(); + let stderr_tx = tx.cloned(); let stderr_thread = std::thread::spawn(move || { let mut full = String::new(); for line in BufReader::new(stderr) @@ -146,6 +157,7 @@ pub fn build( let clean = strip_ansi(&line); if let Some(rest) = clean.trim_start().strip_prefix("Compiling ") && let Some(name) = rest.split_whitespace().next() + && let Some(stderr_tx) = &stderr_tx { stderr_tx.send(Event::Started(name.to_string())); } @@ -158,6 +170,7 @@ pub fn build( let reader = BufReader::new(child.stdout.take().unwrap()); let mut ok = true; let mut executables = Vec::new(); + let mut errors = String::new(); for message in Message::parse_stream(reader).flatten() { match message { @@ -166,39 +179,50 @@ pub fn build( if let Some(path) = artifact.executable { executables.push(path.into_std_path_buf()); } - let is_build_script = artifact - .target - .is_kind(cargo_metadata::TargetKind::CustomBuild); - let real = !is_build_script; - let id = artifact.package_id.repr.clone(); - let name = names - .get(id.as_str()) - .cloned() - .unwrap_or(artifact.target.name); - if is_build_script && !fresh { - tx.send(Event::ScriptRunning(name.clone())); + if let Some(tx) = tx { + let is_build_script = artifact + .target + .is_kind(cargo_metadata::TargetKind::CustomBuild); + let real = !is_build_script; + let id = artifact.package_id.repr.clone(); + let name = names + .get(id.as_str()) + .cloned() + .unwrap_or(artifact.target.name); + if is_build_script && !fresh { + tx.send(Event::ScriptRunning(name.clone())); + } + tx.send(Event::Artifact { + id, + name, + fresh, + real, + }); } - tx.send(Event::Artifact { - id, - name, - fresh, - real, - }); } Message::BuildScriptExecuted(script) => { - let name = names - .get(script.package_id.repr.as_str()) - .cloned() - .unwrap_or(script.package_id.repr); - tx.send(Event::ScriptExecuted(name)); + if let Some(tx) = tx { + let name = names + .get(script.package_id.repr.as_str()) + .cloned() + .unwrap_or(script.package_id.repr); + tx.send(Event::ScriptExecuted(name)); + } } Message::CompilerMessage(msg) => match msg.message.level { DiagnosticLevel::Error => { if let Some(rendered) = msg.message.rendered { - tx.send(Event::Error(rendered)); + if let Some(tx) = tx { + tx.send(Event::Error(rendered.clone())); + } + errors.push_str(&rendered); + } + } + DiagnosticLevel::Warning => { + if let Some(tx) = tx { + tx.send(Event::Warning(to_warning(&msg.message))); } } - DiagnosticLevel::Warning => tx.send(Event::Warning(to_warning(&msg.message))), _ => {} }, Message::BuildFinished(finished) => ok = finished.success, @@ -209,7 +233,10 @@ pub fn build( let _ = child.wait(); let stderr_text = stderr_thread.join().unwrap_or_default(); if !ok && !stderr_text.trim().is_empty() { - tx.send(Event::Error(stderr_text)); + if let Some(tx) = tx { + tx.send(Event::Error(stderr_text.clone())); + } + errors.push_str(&stderr_text); } - (ok, executables) + (ok, executables, errors) } diff --git a/src/main.rs b/src/main.rs index 230dfa8..24fd1aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,9 @@ struct Project { target_dir: Option, before_size: u64, versions: HashMap, + // Package id to crate name, from `cargo metadata`. Kept around so a retry can rebuild + // without re-running `cargo metadata` just to resolve artifact names again. + names: HashMap, compiled: HashSet, grew_by: u64, } @@ -81,6 +84,9 @@ struct FailedTests { // How many lines of the selected test's stdout are scrolled past, so a panic's full // output stays reachable via PageUp/PageDown instead of being clipped by the terminal. stdout_scroll: usize, + // Name of the test currently being retried, if any. One at a time: `r` is a no-op while + // this is set. + retrying: Option, } /// Approximate scroll position that lands near the tail (usually where the panic message is). @@ -140,7 +146,9 @@ fn classify_stdout(stdout: &str) -> Vec<(StdoutStyle, &str)> { if !line.contains("panicked at ") { continue; } - let Some(message) = lines.next() else { continue }; + let Some(message) = lines.next() else { + continue; + }; out.push((StdoutStyle::Message, message)); if let Some(left) = lines.peek().copied() && left.starts_with(" left: ") @@ -282,6 +290,7 @@ fn main() -> Result<()> { target_dir, total, versions, + names: names.clone(), }); // A more exact count needs its own `cargo` invocation, so it runs alongside the real @@ -295,7 +304,8 @@ fn main() -> Result<()> { } }); - let (ok, executables) = build(&tx, &names, setup_verb.compile_args(), &setup_args); + let (ok, executables, _errors) = + build(Some(&tx), &names, setup_verb.compile_args(), &setup_args); match setup_verb { Verb::Run { .. } => { if ok { @@ -323,12 +333,14 @@ fn main() -> Result<()> { target_dir: dir, total: t, versions: v, + names: n, } => { s.project.name = p; s.project.before_size = dir_size(&dir); s.project.target_dir = Some(dir); s.progress.total = t; s.project.versions = v; + s.project.names = n; } Event::Total(t) => s.progress.total = t, Event::Started(name) => { @@ -407,6 +419,36 @@ fn main() -> Result<()> { } } } + Event::RetryFinished { + name, + secs, + outcome, + } => { + s.failed.retrying = None; + if let Some(idx) = s.failed.items.iter().position(|f| f.name == name) { + match outcome { + Outcome::Passed | Outcome::Ignored => { + s.failed.items.remove(idx); + s.failed.expanded.remove(idx); + s.failed.selected = s + .failed + .selected + .min(s.failed.items.len().saturating_sub(1)); + } + Outcome::Failed { location, stdout } => { + s.failed.items[idx].secs = secs; + s.failed.items[idx].location = location; + s.failed.items[idx].stdout = stdout; + if let Some(expanded) = s.failed.expanded.get_mut(idx) { + *expanded = true; + } + if idx == s.failed.selected { + s.failed.stdout_scroll = tail_scroll(&s.failed.items[idx].stdout); + } + } + } + } + } Event::Done(ok) => { s.build_ok = Some(ok); s.final_elapsed = Some(started.elapsed().as_secs_f32()); @@ -534,6 +576,20 @@ fn main() -> Result<()> { KeyCode::PageDown => { s.failed.stdout_scroll = s.failed.stdout_scroll.saturating_add(STDOUT_PAGE); } + KeyCode::Char('r') if s.failed.retrying.is_none() => { + let name = s.failed.items[s.failed.selected].name.clone(); + let names = s.project.names.clone(); + let extra_args = extra_args.clone(); + s.failed.retrying = Some(name.clone()); + inbox.spawn(move |tx| { + let (secs, outcome) = test::retry(&names, &extra_args, &name); + tx.send(Event::RetryFinished { + name, + secs, + outcome, + }); + }); + } KeyCode::Esc | KeyCode::Char('q') => quit(), _ => {} } @@ -679,7 +735,7 @@ fn main() -> Result<()> { lines.push(rimel::text("")); lines.push( rimel::text( - "↑↓ select / scroll when expanded Enter expand/collapse PgUp/PgDn page Esc/q quit", + "↑↓ select / scroll when expanded Enter expand/collapse PgUp/PgDn page r retry Esc/q quit", ) .dim(), ); @@ -701,7 +757,12 @@ fn main() -> Result<()> { .map(|(j, f)| (window_start + j, f)); for (i, f) in window { let expanded = s.failed.expanded.get(i).copied().unwrap_or(false); - let marker = if expanded { "▾ " } else { "▸ " }; + let retrying = s.failed.retrying.as_deref() == Some(f.name.as_str()); + let marker = if retrying { + rimel::text("▲ ").fg(rimel::palette::YELLOW) + } else { + rimel::text(if expanded { "▾ " } else { "▸ " }).fg(rimel::palette::RED) + }; let name = rimel::text(format!("{: Result<()> { } else { name }; - lines.push(rimel::row([ - rimel::text(marker).fg(rimel::palette::RED), - name, - rimel::text(format!("{:05.2}s", f.secs)).fg(rimel::palette::SUBTEXT0), - ])); + let secs = if retrying { + rimel::text("retrying...").fg(rimel::palette::YELLOW) + } else { + rimel::text(format!("{:05.2}s", f.secs)).fg(rimel::palette::SUBTEXT0) + }; + lines.push(rimel::row([marker, name, secs])); if expanded { if let Some(loc) = &f.location { lines.push(rimel::text(format!(" at {loc}")).dim()); @@ -829,10 +891,7 @@ mod tests { assert_eq!( classify_stdout(stdout), vec![ - ( - StdoutStyle::Plain, - "thread 'x' panicked at src/lib.rs:1:1:" - ), + (StdoutStyle::Plain, "thread 'x' panicked at src/lib.rs:1:1:"), (StdoutStyle::Message, "assertion `left == right` failed"), (StdoutStyle::Left, " left: `1`"), (StdoutStyle::Right, " right: `2`"), @@ -848,10 +907,7 @@ mod tests { assert_eq!( classify_stdout(stdout), vec![ - ( - StdoutStyle::Plain, - "thread 'x' panicked at src/lib.rs:1:1:" - ), + (StdoutStyle::Plain, "thread 'x' panicked at src/lib.rs:1:1:"), ( StdoutStyle::Message, "called `Option::unwrap()` on a `None` value" diff --git a/src/test.rs b/src/test.rs index 8cbf613..2546ad4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -5,7 +6,7 @@ use std::process::{Command, Stdio}; use nobubbles::effects::Emitter; use serde_json::Value; -use crate::build::{Event, Outcome}; +use crate::build::{self, Event, Outcome}; /// Runs every test binary in turn, exactly like cargo's own test runner: sequential, one binary /// at a time, stopping at the first failure unless `harness_args` carries `--no-fail-fast`. @@ -66,20 +67,13 @@ fn run_one(tx: &Emitter, path: &Path, harness_args: &[String]) -> bool { (Some("test"), Some(event @ ("ok" | "failed" | "ignored"))) => { let name = msg["name"].as_str().unwrap_or_default().to_string(); let secs = msg["exec_time"].as_f64().unwrap_or(0.0) as f32; - let outcome = match event { - "ok" => Outcome::Passed, - "ignored" => Outcome::Ignored, - _ => { - ok = false; - let stdout = msg["stdout"].as_str().unwrap_or_default().to_string(); - let location = panic_location(&stdout); - Outcome::Failed { location, stdout } - } - }; + if event == "failed" { + ok = false; + } tx.send(Event::TestFinished { name, secs, - outcome, + outcome: outcome_from(event, &msg), }); } _ => {} @@ -101,3 +95,85 @@ fn panic_location(stdout: &str) -> Option { let file = parts.next()?; Some(format!("{file}:{line}")) } + +/// Builds the `Outcome` for one libtest JSON `test` event, shared by the full suite run and +/// the single-test retry below. +fn outcome_from(event: &str, msg: &Value) -> Outcome { + match event { + "ok" => Outcome::Passed, + "ignored" => Outcome::Ignored, + _ => { + let stdout = msg["stdout"].as_str().unwrap_or_default().to_string(); + let location = panic_location(&stdout); + Outcome::Failed { location, stdout } + } + } +} + +/// Runs one binary filtered to exactly one test. `None` means this binary has no test by that +/// name, so [`retry`] should try the next one. +fn retry_one(path: &Path, name: &str) -> Option<(f32, Outcome)> { + let mut child = Command::new(path) + .args([ + "--format", + "json", + "-Z", + "unstable-options", + "--report-time", + "--exact", + name, + ]) + .env("RUSTC_BOOTSTRAP", "1") + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn test binary"); + + let reader = BufReader::new(child.stdout.take().unwrap()); + let mut result = None; + + for line in reader.lines().map_while(Result::ok) { + let Ok(msg) = serde_json::from_str::(&line) else { + continue; + }; + let (Some("test"), Some(event @ ("ok" | "failed" | "ignored"))) = + (msg["type"].as_str(), msg["event"].as_str()) + else { + continue; + }; + if msg["name"].as_str() != Some(name) { + continue; + } + let secs = msg["exec_time"].as_f64().unwrap_or(0.0) as f32; + result = Some((secs, outcome_from(event, &msg))); + } + + let _ = child.wait(); + result +} + +/// Rebuilds quietly (no live progress, so the accordion the user is browsing doesn't get a +/// build/progress block reanimated above it) and reruns exactly one test by name, trying +/// binaries in turn until one has it. A rebuilt binary's filename gets a fresh hash, so this +/// doesn't try to guess which one it used to be. +pub fn retry(names: &HashMap, extra_args: &[String], name: &str) -> (f32, Outcome) { + let (ok, executables, errors) = build::build(None, names, &["test", "--no-run"], extra_args); + if !ok { + return ( + 0.0, + Outcome::Failed { + location: None, + stdout: errors, + }, + ); + } + executables + .iter() + .find_map(|path| retry_one(path, name)) + .unwrap_or(( + 0.0, + Outcome::Failed { + location: None, + stdout: format!("test `{name}` did not run in any binary after the rebuild"), + }, + )) +}