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 b104b16..24fd1aa 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)] @@ -44,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, } @@ -78,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). @@ -91,6 +100,72 @@ 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. 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]; + } + 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 +} + +/// 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 { @@ -215,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 @@ -228,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 { @@ -256,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) => { @@ -340,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()); @@ -395,7 +504,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; @@ -467,7 +576,21 @@ fn main() -> Result<()> { KeyCode::PageDown => { s.failed.stdout_scroll = s.failed.stdout_scroll.saturating_add(STDOUT_PAGE); } - KeyCode::Esc => quit(), + 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(), _ => {} } } @@ -612,7 +735,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 r retry Esc/q quit", ) .dim(), ); @@ -634,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()); @@ -655,19 +784,39 @@ 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<(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()) .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()); } - 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 { @@ -722,3 +871,48 @@ fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::{StdoutStyle, classify_stdout, 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![""]); + } + + #[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" + ), + ] + ); + } +} 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"), + }, + )) +} 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(), ]) }