1use std::ffi::{OsStr, OsString};
47use std::io::{BufRead, BufReader, IsTerminal, Write};
48use std::process::{Command, Stdio};
49use std::time::{Duration, Instant};
50
51use test_better::StructuredError;
52pub use test_better::{RUNNER_ENV, STRUCTURED_MARKER};
53
54const SUBCOMMAND: &str = "test-better";
57
58const NO_CONTEXT: &str = "(no context)";
61
62#[must_use]
70pub fn cargo_test_command<I, S>(args: I) -> Command
71where
72 I: IntoIterator<Item = S>,
73 S: AsRef<OsStr>,
74{
75 let mut forwarded: Vec<OsString> = args
76 .into_iter()
77 .map(|arg| arg.as_ref().to_os_string())
78 .collect();
79 if forwarded.first().is_some_and(|arg| arg == SUBCOMMAND) {
80 forwarded.remove(0);
81 }
82 let cargo = std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
85 let mut command = Command::new(cargo);
86 command.arg("test").args(forwarded).env(RUNNER_ENV, "1");
87 command
88}
89
90pub fn run<I, S>(args: I) -> std::io::Result<i32>
110where
111 I: IntoIterator<Item = S>,
112 S: AsRef<OsStr>,
113{
114 let mut command = cargo_test_command(args);
115 command.stdout(Stdio::piped());
116
117 let started = Instant::now();
118 let mut child = command.spawn()?;
119
120 let report = match child.stdout.take() {
124 Some(stdout) => {
125 let lines = BufReader::new(stdout).lines().map_while(Result::ok);
126 let mut progress = Progress::new(std::io::stderr().is_terminal());
127 let report = scan_output(lines, |line| {
128 let event = progress_event(line);
129 if !(progress.enabled && matches!(event, Some(ProgressEvent::Completed))) {
132 println!("{line}");
133 }
134 if let Some(event) = event {
135 progress.observe(event);
136 }
137 });
138 progress.clear();
139 report
140 }
141 None => GroupedReport::default(),
142 };
143
144 let status = child.wait()?;
145 print_report(&report);
146 print_summary(&report.summary, started.elapsed());
147 Ok(status.code().unwrap_or(101))
148}
149
150#[derive(Debug, Clone)]
152pub struct StructuredFailure {
153 pub test: String,
155 pub error: StructuredError,
157}
158
159#[derive(Debug, Clone)]
161pub struct ContextGroup {
162 pub context: String,
165 pub failures: Vec<StructuredFailure>,
167}
168
169#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
173pub struct RunSummary {
174 pub passed: usize,
176 pub failed: usize,
178 pub ignored: usize,
180 pub measured: usize,
182 pub filtered_out: usize,
184}
185
186#[derive(Debug, Clone, Default)]
190pub struct GroupedReport {
191 pub groups: Vec<ContextGroup>,
193 pub unstructured: Vec<String>,
196 pub summary: RunSummary,
198}
199
200#[must_use]
213pub fn scan_output<L, E>(lines: L, mut echo: E) -> GroupedReport
214where
215 L: IntoIterator<Item = String>,
216 E: FnMut(&str),
217{
218 let mut current_test: Option<String> = None;
219 let mut structured: Vec<StructuredFailure> = Vec::new();
220 let mut sectioned: Vec<String> = Vec::new();
221 let mut with_marker: Vec<String> = Vec::new();
222 let mut summary = RunSummary::default();
223
224 for line in lines {
225 if let Some(payload) = marker_payload(&line) {
226 if let (Some(test), Ok(error)) = (
227 current_test.as_ref(),
228 serde_json::from_str::<StructuredError>(payload),
229 ) {
230 structured.push(StructuredFailure {
231 test: test.clone(),
232 error,
233 });
234 with_marker.push(test.clone());
235 }
236 continue;
238 }
239 if let Some(name) = test_section_header(&line) {
240 current_test = Some(name.to_string());
241 if !sectioned.iter().any(|seen| seen == name) {
242 sectioned.push(name.to_string());
243 }
244 }
245 if let Some(line_summary) = parse_result_line(&line) {
246 summary.passed += line_summary.passed;
247 summary.failed += line_summary.failed;
248 summary.ignored += line_summary.ignored;
249 summary.measured += line_summary.measured;
250 summary.filtered_out += line_summary.filtered_out;
251 }
252 echo(&line);
253 }
254
255 let unstructured = sectioned
256 .into_iter()
257 .filter(|test| !with_marker.contains(test))
258 .collect();
259 GroupedReport {
260 groups: group(structured),
261 unstructured,
262 summary,
263 }
264}
265
266fn group(failures: Vec<StructuredFailure>) -> Vec<ContextGroup> {
269 let mut groups: Vec<ContextGroup> = Vec::new();
270 for failure in failures {
271 let context = failure
272 .error
273 .context
274 .first()
275 .map_or_else(|| NO_CONTEXT.to_string(), |frame| frame.message.clone());
276 match groups
277 .iter_mut()
278 .find(|existing| existing.context == context)
279 {
280 Some(existing) => existing.failures.push(failure),
281 None => groups.push(ContextGroup {
282 context,
283 failures: vec![failure],
284 }),
285 }
286 }
287 groups
288}
289
290fn marker_payload(line: &str) -> Option<&str> {
293 line.trim()
294 .strip_prefix(STRUCTURED_MARKER)?
295 .strip_suffix(STRUCTURED_MARKER)
296}
297
298fn test_section_header(line: &str) -> Option<&str> {
301 line.strip_prefix("---- ")?.strip_suffix(" stdout ----")
302}
303
304fn segment_count(segment: &str, label: &str) -> Option<usize> {
310 segment
311 .trim()
312 .strip_suffix(label)?
313 .trim_end()
314 .rsplit(' ')
315 .next()
316 .and_then(|count| count.parse().ok())
317}
318
319fn parse_result_line(line: &str) -> Option<RunSummary> {
324 let line = line.trim();
325 if !line.starts_with("test result:") {
326 return None;
327 }
328 let mut summary = RunSummary::default();
329 let mut matched = false;
330 for segment in line.split(';') {
331 if let Some(count) = segment_count(segment, "passed") {
332 summary.passed = count;
333 matched = true;
334 } else if let Some(count) = segment_count(segment, "failed") {
335 summary.failed = count;
336 matched = true;
337 } else if let Some(count) = segment_count(segment, "ignored") {
338 summary.ignored = count;
339 matched = true;
340 } else if let Some(count) = segment_count(segment, "measured") {
341 summary.measured = count;
342 matched = true;
343 } else if let Some(count) = segment_count(segment, "filtered out") {
344 summary.filtered_out = count;
345 matched = true;
346 }
347 }
348 matched.then_some(summary)
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum ProgressEvent {
355 Discovered(usize),
357 Completed,
359}
360
361#[must_use]
364pub fn progress_event(line: &str) -> Option<ProgressEvent> {
365 let line = line.trim();
366 if let Some(rest) = line.strip_prefix("running ") {
367 let count = rest.split(' ').next()?.parse().ok()?;
369 return Some(ProgressEvent::Discovered(count));
370 }
371 if line.starts_with("test ") && !line.starts_with("test result:") && line.contains(" ... ") {
374 return Some(ProgressEvent::Completed);
375 }
376 None
377}
378
379struct Progress {
382 enabled: bool,
384 total: usize,
386 done: usize,
388}
389
390impl Progress {
391 fn new(enabled: bool) -> Self {
393 Self {
394 enabled,
395 total: 0,
396 done: 0,
397 }
398 }
399
400 fn observe(&mut self, event: ProgressEvent) {
402 match event {
403 ProgressEvent::Discovered(count) => self.total += count,
404 ProgressEvent::Completed => self.done += 1,
405 }
406 if self.enabled {
407 let mut stderr = std::io::stderr();
411 let _ = write!(stderr, "\r running: {}/{} tests ", self.done, self.total);
412 let _ = stderr.flush();
413 }
414 }
415
416 fn clear(&self) {
418 if self.enabled {
419 let mut stderr = std::io::stderr();
420 let _ = write!(stderr, "\r\u{1b}[K");
421 let _ = stderr.flush();
422 }
423 }
424}
425
426fn print_report(report: &GroupedReport) {
429 if report.groups.is_empty() && report.unstructured.is_empty() {
430 return;
431 }
432 println!();
433 println!("test-better: grouped failures");
434 for group in &report.groups {
435 println!();
436 println!(" {}", group.context);
437 for failure in &group.failures {
438 let summary = failure
439 .error
440 .message
441 .as_deref()
442 .unwrap_or_else(|| failure.error.kind.headline());
443 println!(" {}: {summary}", failure.test);
444 println!(
445 " at {}:{}:{}",
446 failure.error.location.file,
447 failure.error.location.line,
448 failure.error.location.column,
449 );
450 }
451 }
452 if !report.unstructured.is_empty() {
453 println!();
454 println!(" unstructured (no test-better failure data)");
455 for test in &report.unstructured {
456 println!(" {test}");
457 }
458 }
459}
460
461fn print_summary(summary: &RunSummary, duration: Duration) {
465 println!();
466 println!("test-better: summary");
467 println!(
468 " {} passed, {} failed, {} ignored",
469 summary.passed, summary.failed, summary.ignored,
470 );
471 println!(" finished in {:.2}s", duration.as_secs_f64());
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use test_better::prelude::*;
478 use test_better::{ErrorKind, SourceLocation, StructuredContextFrame};
479
480 fn structured_error(kind: ErrorKind, message: &str, context: &[&str]) -> StructuredError {
483 StructuredError {
484 kind,
485 message: Some(message.to_string()),
486 location: SourceLocation {
487 file: "src/lib.rs".to_string(),
488 line: 7,
489 column: 5,
490 },
491 context: context
492 .iter()
493 .map(|frame| StructuredContextFrame {
494 message: (*frame).to_string(),
495 location: None,
496 })
497 .collect(),
498 trace: Vec::new(),
499 payload: None,
500 }
501 }
502
503 fn header(test: &str) -> String {
505 format!("---- {test} stdout ----")
506 }
507
508 fn marker_line(error: &StructuredError) -> TestResult<String> {
510 let json = serde_json::to_string(error).or_fail_with("serialize structured error")?;
511 Ok(format!("{STRUCTURED_MARKER}{json}{STRUCTURED_MARKER}"))
512 }
513
514 #[test]
515 fn forwards_args_after_dropping_the_subcommand() -> TestResult {
516 let command = cargo_test_command(["test-better", "--release", "-p", "mycrate"]);
517 let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
518 expect!(args).to(eq(vec![
519 OsString::from("test"),
520 OsString::from("--release"),
521 OsString::from("-p"),
522 OsString::from("mycrate"),
523 ]))
524 }
525
526 #[test]
527 fn keeps_args_when_there_is_no_subcommand_prefix() -> TestResult {
528 let command = cargo_test_command(["--lib"]);
529 let args: Vec<OsString> = command.get_args().map(OsStr::to_os_string).collect();
530 expect!(args).to(eq(vec![OsString::from("test"), OsString::from("--lib")]))
531 }
532
533 #[test]
534 fn opens_the_structured_output_channel() -> TestResult {
535 let command = cargo_test_command(["test-better"]);
536 let opened = command
537 .get_envs()
538 .any(|(key, value)| key == OsStr::new(RUNNER_ENV) && value == Some(OsStr::new("1")));
539 expect!(opened).to(is_true())
540 }
541
542 #[test]
543 fn groups_structured_failures_by_top_context_frame() -> TestResult {
544 let db_one = structured_error(
545 ErrorKind::Assertion,
546 "row count differs",
547 &["the user store"],
548 );
549 let db_two = structured_error(ErrorKind::Setup, "no connection", &["the user store"]);
550 let http = structured_error(ErrorKind::Assertion, "status was 500", &["the http layer"]);
551 let lines = vec![
552 header("store::counts_match"),
553 marker_line(&db_one)?,
554 header("store::connects"),
555 marker_line(&db_two)?,
556 header("http::returns_ok"),
557 marker_line(&http)?,
558 ];
559
560 let report = scan_output(lines, |_| {});
561
562 expect!(report.groups.len()).to(eq(2))?;
563 expect!(report.groups[0].context.as_str()).to(eq("the user store"))?;
564 expect!(report.groups[0].failures.len()).to(eq(2))?;
565 expect!(report.groups[0].failures[0].test.as_str()).to(eq("store::counts_match"))?;
566 expect!(report.groups[1].context.as_str()).to(eq("the http layer"))?;
567 expect!(report.groups[1].failures.len()).to(eq(1))?;
568 expect!(report.unstructured.is_empty()).to(is_true())
569 }
570
571 #[test]
572 fn keeps_failures_without_a_marker_as_unstructured() -> TestResult {
573 let lines = vec![
574 header("math::adds"),
575 "thread 'math::adds' panicked at src/lib.rs:3:5:".to_string(),
576 "assertion `left == right` failed".to_string(),
577 ];
578
579 let report = scan_output(lines, |_| {});
580
581 expect!(report.groups.is_empty()).to(is_true())?;
582 expect!(report.unstructured).to(eq(vec!["math::adds".to_string()]))
583 }
584
585 #[test]
586 fn echoes_every_non_marker_line() -> TestResult {
587 let error = structured_error(ErrorKind::Assertion, "boom", &["an area"]);
588 let lines = vec![
589 "running 1 test".to_string(),
590 header("suite::case"),
591 marker_line(&error)?,
592 "test result: FAILED. 0 passed; 1 failed".to_string(),
593 ];
594
595 let mut echoed: Vec<String> = Vec::new();
596 let _ = scan_output(lines, |line| echoed.push(line.to_string()));
597
598 expect!(echoed).to(eq(vec![
600 "running 1 test".to_string(),
601 header("suite::case"),
602 "test result: FAILED. 0 passed; 1 failed".to_string(),
603 ]))
604 }
605
606 #[test]
607 fn an_unparseable_marker_leaves_the_test_unstructured() -> TestResult {
608 let lines = vec![
609 header("suite::case"),
610 format!("{STRUCTURED_MARKER}not json{STRUCTURED_MARKER}"),
611 ];
612
613 let report = scan_output(lines, |_| {});
614
615 expect!(report.groups.is_empty()).to(is_true())?;
616 expect!(report.unstructured).to(eq(vec!["suite::case".to_string()]))
617 }
618
619 #[test]
620 fn errors_without_context_land_in_the_no_context_bucket() -> TestResult {
621 let bare = structured_error(ErrorKind::Custom, "something off", &[]);
622 let lines = vec![header("suite::case"), marker_line(&bare)?];
623
624 let report = scan_output(lines, |_| {});
625
626 expect!(report.groups.len()).to(eq(1))?;
627 expect!(report.groups[0].context.as_str()).to(eq(NO_CONTEXT))
628 }
629
630 #[test]
631 fn parses_a_test_result_line_into_a_summary() -> TestResult {
632 let summary = parse_result_line(
633 "test result: FAILED. 5 passed; 2 failed; 1 ignored; 0 measured; 3 filtered out; \
634 finished in 0.42s",
635 )
636 .or_fail_with("the line is a test result line")?;
637 expect!(summary).to(eq(RunSummary {
638 passed: 5,
639 failed: 2,
640 ignored: 1,
641 measured: 0,
642 filtered_out: 3,
643 }))
644 }
645
646 #[test]
647 fn a_non_result_line_is_not_a_summary() -> TestResult {
648 expect!(parse_result_line("running 3 tests").is_none()).to(is_true())?;
649 expect!(parse_result_line("test math::adds ... ok").is_none()).to(is_true())
650 }
651
652 #[test]
653 fn scan_output_sums_the_summary_across_test_binaries() -> TestResult {
654 let lines = vec![
655 "test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; \
656 finished in 0.01s"
657 .to_string(),
658 "test result: FAILED. 1 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; \
659 finished in 0.02s"
660 .to_string(),
661 ];
662
663 let report = scan_output(lines, |_| {});
664
665 expect!(report.summary).to(eq(RunSummary {
666 passed: 4,
667 failed: 2,
668 ignored: 1,
669 measured: 0,
670 filtered_out: 0,
671 }))
672 }
673
674 #[test]
675 fn classifies_progress_events() -> TestResult {
676 expect!(progress_event("running 5 tests")).to(eq(Some(ProgressEvent::Discovered(5))))?;
677 expect!(progress_event("running 1 test")).to(eq(Some(ProgressEvent::Discovered(1))))?;
678 expect!(progress_event("test math::adds ... ok")).to(eq(Some(ProgressEvent::Completed)))?;
679 expect!(progress_event("test math::divides ... FAILED"))
680 .to(eq(Some(ProgressEvent::Completed)))?;
681 expect!(progress_event("test math::slow ... ignored"))
682 .to(eq(Some(ProgressEvent::Completed)))?;
683 expect!(progress_event("test result: ok. 1 passed; 0 failed")).to(eq(None))?;
685 expect!(progress_event("some other line")).to(eq(None))
686 }
687}