use std::ffi::{OsStr, OsString};
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use test_better::StructuredError;
pub use test_better::{RUNNER_ENV, STRUCTURED_MARKER};
const SUBCOMMAND: &str = "test-better";
const NO_CONTEXT: &str = "(no context)";
#[must_use]
pub fn cargo_test_command<I, S>(args: I) -> Command
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut forwarded: Vec<OsString> = args
.into_iter()
.map(|arg| arg.as_ref().to_os_string())
.collect();
if forwarded.first().is_some_and(|arg| arg == SUBCOMMAND) {
forwarded.remove(0);
}
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
let mut command = Command::new(cargo);
command.arg("test").args(forwarded).env(RUNNER_ENV, "1");
command
}
pub fn run<I, S>(args: I) -> std::io::Result<i32>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut command = cargo_test_command(args);
command.stdout(Stdio::piped());
let started = Instant::now();
let mut child = command.spawn()?;
let report = match child.stdout.take() {
Some(stdout) => {
let lines = BufReader::new(stdout).lines().map_while(Result::ok);
let mut progress = Progress::new(std::io::stderr().is_terminal());
let report = scan_output(lines, |line| {
let event = progress_event(line);
if !(progress.enabled && matches!(event, Some(ProgressEvent::Completed))) {
println!("{line}");
}
if let Some(event) = event {
progress.observe(event);
}
});
progress.clear();
report
}
None => GroupedReport::default(),
};
let status = child.wait()?;
print_report(&report);
print_summary(&report.summary, started.elapsed());
Ok(status.code().unwrap_or(101))
}
#[derive(Debug, Clone)]
pub struct StructuredFailure {
pub test: String,
pub error: StructuredError,
}
#[derive(Debug, Clone)]
pub struct ContextGroup {
pub context: String,
pub failures: Vec<StructuredFailure>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunSummary {
pub passed: usize,
pub failed: usize,
pub ignored: usize,
pub measured: usize,
pub filtered_out: usize,
}
#[derive(Debug, Clone, Default)]
pub struct GroupedReport {
pub groups: Vec<ContextGroup>,
pub unstructured: Vec<String>,
pub summary: RunSummary,
}
#[must_use]
pub fn scan_output<L, E>(lines: L, mut echo: E) -> GroupedReport
where
L: IntoIterator<Item = String>,
E: FnMut(&str),
{
let mut current_test: Option<String> = None;
let mut structured: Vec<StructuredFailure> = Vec::new();
let mut sectioned: Vec<String> = Vec::new();
let mut with_marker: Vec<String> = Vec::new();
let mut summary = RunSummary::default();
for line in lines {
if let Some(payload) = marker_payload(&line) {
if let (Some(test), Ok(error)) = (
current_test.as_ref(),
serde_json::from_str::<StructuredError>(payload),
) {
structured.push(StructuredFailure {
test: test.clone(),
error,
});
with_marker.push(test.clone());
}
continue;
}
if let Some(name) = test_section_header(&line) {
current_test = Some(name.to_string());
if !sectioned.iter().any(|seen| seen == name) {
sectioned.push(name.to_string());
}
}
if let Some(line_summary) = parse_result_line(&line) {
summary.passed += line_summary.passed;
summary.failed += line_summary.failed;
summary.ignored += line_summary.ignored;
summary.measured += line_summary.measured;
summary.filtered_out += line_summary.filtered_out;
}
echo(&line);
}
let unstructured = sectioned
.into_iter()
.filter(|test| !with_marker.contains(test))
.collect();
GroupedReport {
groups: group(structured),
unstructured,
summary,
}
}
fn group(failures: Vec<StructuredFailure>) -> Vec<ContextGroup> {
let mut groups: Vec<ContextGroup> = Vec::new();
for failure in failures {
let context = failure
.error
.context
.first()
.map_or_else(|| NO_CONTEXT.to_string(), |frame| frame.message.clone());
match groups
.iter_mut()
.find(|existing| existing.context == context)
{
Some(existing) => existing.failures.push(failure),
None => groups.push(ContextGroup {
context,
failures: vec![failure],
}),
}
}
groups
}
fn marker_payload(line: &str) -> Option<&str> {
line.trim()
.strip_prefix(STRUCTURED_MARKER)?
.strip_suffix(STRUCTURED_MARKER)
}
fn test_section_header(line: &str) -> Option<&str> {
line.strip_prefix("---- ")?.strip_suffix(" stdout ----")
}
fn segment_count(segment: &str, label: &str) -> Option<usize> {
segment
.trim()
.strip_suffix(label)?
.trim_end()
.rsplit(' ')
.next()
.and_then(|count| count.parse().ok())
}
fn parse_result_line(line: &str) -> Option<RunSummary> {
let line = line.trim();
if !line.starts_with("test result:") {
return None;
}
let mut summary = RunSummary::default();
let mut matched = false;
for segment in line.split(';') {
if let Some(count) = segment_count(segment, "passed") {
summary.passed = count;
matched = true;
} else if let Some(count) = segment_count(segment, "failed") {
summary.failed = count;
matched = true;
} else if let Some(count) = segment_count(segment, "ignored") {
summary.ignored = count;
matched = true;
} else if let Some(count) = segment_count(segment, "measured") {
summary.measured = count;
matched = true;
} else if let Some(count) = segment_count(segment, "filtered out") {
summary.filtered_out = count;
matched = true;
}
}
matched.then_some(summary)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressEvent {
Discovered(usize),
Completed,
}
#[must_use]
pub fn progress_event(line: &str) -> Option<ProgressEvent> {
let line = line.trim();
if let Some(rest) = line.strip_prefix("running ") {
let count = rest.split(' ').next()?.parse().ok()?;
return Some(ProgressEvent::Discovered(count));
}
if line.starts_with("test ") && !line.starts_with("test result:") && line.contains(" ... ") {
return Some(ProgressEvent::Completed);
}
None
}
struct Progress {
enabled: bool,
total: usize,
done: usize,
}
impl Progress {
fn new(enabled: bool) -> Self {
Self {
enabled,
total: 0,
done: 0,
}
}
fn observe(&mut self, event: ProgressEvent) {
match event {
ProgressEvent::Discovered(count) => self.total += count,
ProgressEvent::Completed => self.done += 1,
}
if self.enabled {
let mut stderr = std::io::stderr();
let _ = write!(stderr, "\r running: {}/{} tests ", self.done, self.total);
let _ = stderr.flush();
}
}
fn clear(&self) {
if self.enabled {
let mut stderr = std::io::stderr();
let _ = write!(stderr, "\r\u{1b}[K");
let _ = stderr.flush();
}
}
}
fn print_report(report: &GroupedReport) {
if report.groups.is_empty() && report.unstructured.is_empty() {
return;
}
println!();
println!("test-better: grouped failures");
for group in &report.groups {
println!();
println!(" {}", group.context);
for failure in &group.failures {
let summary = failure
.error
.message
.as_deref()
.unwrap_or_else(|| failure.error.kind.headline());
println!(" {}: {summary}", failure.test);
println!(
" at {}:{}:{}",
failure.error.location.file,
failure.error.location.line,
failure.error.location.column,
);
}
}
if !report.unstructured.is_empty() {
println!();
println!(" unstructured (no test-better failure data)");
for test in &report.unstructured {
println!(" {test}");
}
}
}
fn print_summary(summary: &RunSummary, duration: Duration) {
println!();
println!("test-better: summary");
println!(
" {} passed, {} failed, {} ignored",
summary.passed, summary.failed, summary.ignored,
);
println!(" finished in {:.2}s", duration.as_secs_f64());
}
#[cfg(test)]
mod tests {
use super::*;
use test_better::prelude::*;
use test_better::{ErrorKind, SourceLocation, StructuredContextFrame};
fn structured_error(kind: ErrorKind, message: &str, context: &[&str]) -> StructuredError {
StructuredError {
kind,
message: Some(message.to_string()),
location: SourceLocation {
file: "src/lib.rs".to_string(),
line: 7,
column: 5,
},
context: context
.iter()
.map(|frame| StructuredContextFrame {
message: (*frame).to_string(),
location: None,
})
.collect(),
trace: Vec::new(),
payload: None,
}
}
fn header(test: &str) -> String {
format!("---- {test} stdout ----")
}
fn marker_line(error: &StructuredError) -> TestResult<String> {
let json = serde_json::to_string(error).or_fail_with("serialize structured error")?;
Ok(format!("{STRUCTURED_MARKER}{json}{STRUCTURED_MARKER}"))
}
#[test]
fn forwards_args_after_dropping_the_subcommand() -> TestResult {
let command = cargo_test_command(["test-better", "--release", "-p", "mycrate"]);
let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
check!(args).satisfies(eq(vec![
OsString::from("test"),
OsString::from("--release"),
OsString::from("-p"),
OsString::from("mycrate"),
]))
}
#[test]
fn keeps_args_when_there_is_no_subcommand_prefix() -> TestResult {
let command = cargo_test_command(["--lib"]);
let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
check!(args).satisfies(eq(vec![OsString::from("test"), OsString::from("--lib")]))
}
#[test]
fn opens_the_structured_output_channel() -> TestResult {
let command = cargo_test_command(["test-better"]);
let opened = command
.get_envs()
.any(|(key, value)| key == OsStr::new(RUNNER_ENV) && value == Some(OsStr::new("1")));
check!(opened).satisfies(is_true())
}
#[test]
fn groups_structured_failures_by_top_context_frame() -> TestResult {
let db_one = structured_error(
ErrorKind::Assertion,
"row count differs",
&["the user store"],
);
let db_two = structured_error(ErrorKind::Setup, "no connection", &["the user store"]);
let http = structured_error(ErrorKind::Assertion, "status was 500", &["the http layer"]);
let lines = vec![
header("store::counts_match"),
marker_line(&db_one)?,
header("store::connects"),
marker_line(&db_two)?,
header("http::returns_ok"),
marker_line(&http)?,
];
let report = scan_output(lines, |_| {});
check!(report.groups.len()).satisfies(eq(2))?;
check!(report.groups[0].context.as_str()).satisfies(eq("the user store"))?;
check!(report.groups[0].failures.len()).satisfies(eq(2))?;
check!(report.groups[0].failures[0].test.as_str()).satisfies(eq("store::counts_match"))?;
check!(report.groups[1].context.as_str()).satisfies(eq("the http layer"))?;
check!(report.groups[1].failures.len()).satisfies(eq(1))?;
check!(report.unstructured.is_empty()).satisfies(is_true())
}
#[test]
fn keeps_failures_without_a_marker_as_unstructured() -> TestResult {
let lines = vec![
header("math::adds"),
"thread 'math::adds' panicked at src/lib.rs:3:5:".to_string(),
"assertion `left == right` failed".to_string(),
];
let report = scan_output(lines, |_| {});
check!(report.groups.is_empty()).satisfies(is_true())?;
check!(report.unstructured).satisfies(eq(vec!["math::adds".to_string()]))
}
#[test]
fn echoes_every_non_marker_line() -> TestResult {
let error = structured_error(ErrorKind::Assertion, "boom", &["an area"]);
let lines = vec![
"running 1 test".to_string(),
header("suite::case"),
marker_line(&error)?,
"test result: FAILED. 0 passed; 1 failed".to_string(),
];
let mut echoed: Vec<String> = Vec::new();
let _ = scan_output(lines, |line| echoed.push(line.to_string()));
check!(echoed).satisfies(eq(vec![
"running 1 test".to_string(),
header("suite::case"),
"test result: FAILED. 0 passed; 1 failed".to_string(),
]))
}
#[test]
fn an_unparseable_marker_leaves_the_test_unstructured() -> TestResult {
let lines = vec![
header("suite::case"),
format!("{STRUCTURED_MARKER}not json{STRUCTURED_MARKER}"),
];
let report = scan_output(lines, |_| {});
check!(report.groups.is_empty()).satisfies(is_true())?;
check!(report.unstructured).satisfies(eq(vec!["suite::case".to_string()]))
}
#[test]
fn errors_without_context_land_in_the_no_context_bucket() -> TestResult {
let bare = structured_error(ErrorKind::Custom, "something off", &[]);
let lines = vec![header("suite::case"), marker_line(&bare)?];
let report = scan_output(lines, |_| {});
check!(report.groups.len()).satisfies(eq(1))?;
check!(report.groups[0].context.as_str()).satisfies(eq(NO_CONTEXT))
}
#[test]
fn parses_a_test_result_line_into_a_summary() -> TestResult {
let summary = parse_result_line(
"test result: FAILED. 5 passed; 2 failed; 1 ignored; 0 measured; 3 filtered out; \
finished in 0.42s",
)
.or_fail_with("the line is a test result line")?;
check!(summary).satisfies(eq(RunSummary {
passed: 5,
failed: 2,
ignored: 1,
measured: 0,
filtered_out: 3,
}))
}
#[test]
fn a_non_result_line_is_not_a_summary() -> TestResult {
check!(parse_result_line("running 3 tests").is_none()).satisfies(is_true())?;
check!(parse_result_line("test math::adds ... ok").is_none()).satisfies(is_true())
}
#[test]
fn scan_output_sums_the_summary_across_test_binaries() -> TestResult {
let lines = vec![
"test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; \
finished in 0.01s"
.to_string(),
"test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; \
finished in 0.02s"
.to_string(),
];
let report = scan_output(lines, |_| {});
check!(report.summary).satisfies(eq(RunSummary {
passed: 4,
failed: 2,
ignored: 1,
measured: 0,
filtered_out: 0,
}))
}
#[test]
fn classifies_progress_events() -> TestResult {
check!(progress_event("running 5 tests")).satisfies(eq(Some(ProgressEvent::Discovered(5))))?;
check!(progress_event("running 1 test")).satisfies(eq(Some(ProgressEvent::Discovered(1))))?;
check!(progress_event("test math::adds ... ok")).satisfies(eq(Some(ProgressEvent::Completed)))?;
check!(progress_event("test math::divides ... FAILED"))
.satisfies(eq(Some(ProgressEvent::Completed)))?;
check!(progress_event("test math::slow ... ignored"))
.satisfies(eq(Some(ProgressEvent::Completed)))?;
check!(progress_event("test result: ok. 1 passed; 0 failed")).satisfies(eq(None))?;
check!(progress_event("some other line")).satisfies(eq(None))
}
}