use std::collections::HashSet;
use std::io::IsTerminal;
use std::process::ExitCode;
use std::sync::LazyLock;
use regex::Regex;
use crate::cmd::{
inject_flag_before_separator, run_parsed_command_with_mode, user_has_flag, OutputFormat,
ParsedCommandConfig,
};
use crate::output::canonical::{TestEntry, TestOutcome, TestResult, TestSummary};
use crate::output::ParseResult;
use crate::runner::CommandOutput;
static RE_PASSED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+passed").unwrap());
static RE_FAILED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+failed").unwrap());
static RE_SKIPPED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\d+)\s+skipped").unwrap());
static RE_CARGO_SUMMARY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"test result: \w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored").unwrap()
});
pub(crate) fn run(args: &[String], show_stats: bool) -> anyhow::Result<ExitCode> {
let is_nextest = args.iter().any(|a| a == "nextest");
let mut cmd_args: Vec<String> = vec!["test".to_string()];
cmd_args.extend(args.iter().cloned());
if !is_nextest && !user_has_flag(&cmd_args, &["--message-format"]) {
inject_flag_before_separator(&mut cmd_args, "--message-format=json");
}
let use_stdin = !std::io::stdin().is_terminal() && args.is_empty();
run_parsed_command_with_mode(
ParsedCommandConfig {
program: "cargo",
args: &cmd_args,
env_overrides: &[("CARGO_TERM_COLOR", "never")],
install_hint: "Install Rust via https://rustup.rs",
use_stdin,
show_stats,
command_type: crate::analytics::CommandType::Test,
output_format: OutputFormat::default(),
},
move |output, _args| parse_impl(output, is_nextest),
)
}
fn parse_impl(output: &CommandOutput, is_nextest: bool) -> ParseResult<TestResult> {
if is_nextest {
if let Some(result) = try_parse_nextest(&output.stdout) {
return ParseResult::Full(result);
}
} else {
if let Some(result) = try_parse_json(&output.stdout) {
return ParseResult::Full(result);
}
}
let combined = if output.stderr.is_empty() {
output.stdout.clone()
} else {
format!("{}\n{}", output.stdout, output.stderr)
};
if let Some(result) = try_parse_regex(&combined) {
return ParseResult::Degraded(
result,
vec!["cargo test: no libtest JSON events found, using regex".to_string()],
);
}
ParseResult::Passthrough(combined)
}
fn try_parse_json(stdout: &str) -> Option<TestResult> {
let mut entries: Vec<TestEntry> = Vec::new();
let mut suite_found = false;
let mut passed: usize = 0;
let mut failed: usize = 0;
let mut ignored: usize = 0;
let mut exec_time_ms: Option<u64> = None;
for line in stdout.lines() {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(type_field) = value.get("type").and_then(|v| v.as_str()) else {
continue;
};
match type_field {
"test" => {
let Some(event) = value.get("event").and_then(|v| v.as_str()) else {
continue;
};
let name = value
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>")
.to_string();
let outcome = match event {
"ok" => TestOutcome::Pass,
"failed" => TestOutcome::Fail,
"ignored" => TestOutcome::Skip,
_ => continue,
};
let detail = if outcome == TestOutcome::Fail {
value
.get("stdout")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
} else {
None
};
entries.push(TestEntry {
name,
outcome,
detail,
});
}
"suite" => {
let Some(event) = value.get("event").and_then(|v| v.as_str()) else {
continue;
};
if event != "ok" && event != "failed" {
continue;
}
suite_found = true;
passed = value.get("passed").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
failed = value.get("failed").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
ignored = value.get("ignored").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
exec_time_ms = value
.get("exec_time")
.and_then(|v| v.as_f64())
.map(|s| (s * 1000.0) as u64);
}
_ => {}
}
}
if !suite_found {
return None;
}
let summary = TestSummary {
pass: passed,
fail: failed,
skip: ignored,
duration_ms: exec_time_ms,
};
Some(TestResult::new(summary, entries))
}
fn try_parse_nextest(stdout: &str) -> Option<TestResult> {
let mut entries: Vec<TestEntry> = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut summary_found = false;
let mut total_passed: usize = 0;
let mut total_failed: usize = 0;
let mut total_skipped: usize = 0;
let mut duration_ms: Option<u64> = None;
let mut current_stdout_capture: Option<(String, String)> = None; let mut in_stdout_block = false;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("--- STDOUT:") && trimmed.ends_with("---") {
let inner = trimmed
.strip_prefix("--- STDOUT:")
.unwrap_or("")
.strip_suffix("---")
.unwrap_or("")
.trim();
if !inner.is_empty() {
current_stdout_capture = Some((inner.to_string(), String::new()));
in_stdout_block = true;
continue;
}
}
if trimmed.starts_with("--- STDERR:") && trimmed.ends_with("---") {
if let Some((ref test_name, ref captured)) = current_stdout_capture {
let detail = captured.trim().to_string();
if !detail.is_empty() {
for entry in &mut entries {
if entry.name == *test_name && entry.detail.is_none() {
entry.detail = Some(detail.clone());
break;
}
}
}
}
current_stdout_capture = None;
in_stdout_block = false;
continue;
}
if in_stdout_block {
if let Some((_, ref mut captured)) = current_stdout_capture {
if !captured.is_empty() {
captured.push('\n');
}
captured.push_str(line.trim_end());
continue;
}
}
if !trimmed.is_empty()
&& !trimmed.starts_with("PASS")
&& !trimmed.starts_with("FAIL")
&& !trimmed.starts_with("SKIP")
&& !trimmed.starts_with("Starting")
&& !trimmed.starts_with("Summary")
&& !trimmed.starts_with("Finished")
&& !trimmed.starts_with("---")
{
if let Some((ref test_name, ref captured)) = current_stdout_capture {
let detail = captured.trim().to_string();
if !detail.is_empty() {
for entry in &mut entries {
if entry.name == *test_name && entry.detail.is_none() {
entry.detail = Some(detail.clone());
break;
}
}
}
}
current_stdout_capture = None;
in_stdout_block = false;
}
if let Some(rest) = trimmed.strip_prefix("PASS") {
if let Some(name) = extract_nextest_name(rest) {
let key = (name.clone(), "Pass".to_string());
if seen.insert(key) {
entries.push(TestEntry {
name,
outcome: TestOutcome::Pass,
detail: None,
});
}
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("FAIL") {
if let Some(name) = extract_nextest_name(rest) {
let key = (name.clone(), "Fail".to_string());
if seen.insert(key) {
entries.push(TestEntry {
name,
outcome: TestOutcome::Fail,
detail: None,
});
}
}
continue;
}
if let Some(rest) = trimmed.strip_prefix("SKIP") {
if let Some(name) = extract_nextest_name(rest) {
let key = (name.clone(), "Skip".to_string());
if seen.insert(key) {
entries.push(TestEntry {
name,
outcome: TestOutcome::Skip,
detail: None,
});
}
}
continue;
}
if trimmed.starts_with("Summary") {
if let Some(summary) = parse_nextest_summary(trimmed) {
summary_found = true;
total_passed = summary.0;
total_failed = summary.1;
total_skipped = summary.2;
duration_ms = summary.3;
}
}
}
if let Some((ref test_name, ref captured)) = current_stdout_capture {
let detail = captured.trim().to_string();
if !detail.is_empty() {
for entry in &mut entries {
if entry.name == *test_name && entry.detail.is_none() {
entry.detail = Some(detail.clone());
break;
}
}
}
}
if !summary_found {
return None;
}
let summary = TestSummary {
pass: total_passed,
fail: total_failed,
skip: total_skipped,
duration_ms,
};
Some(TestResult::new(summary, entries))
}
fn extract_nextest_name(rest: &str) -> Option<String> {
let rest = rest.trim();
if let Some(after_bracket) = rest.strip_prefix('[') {
if let Some(pos) = after_bracket.find(']') {
let name = after_bracket[pos + 1..].trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
None
}
fn parse_nextest_summary(line: &str) -> Option<(usize, usize, usize, Option<u64>)> {
let mut passed: usize = 0;
let mut failed: usize = 0;
let mut skipped: usize = 0;
let mut duration_ms: Option<u64> = None;
if let Some(start) = line.find('[') {
if let Some(end) = line.find(']') {
let dur_str = line[start + 1..end].trim();
if let Some(secs_str) = dur_str.strip_suffix('s') {
if let Ok(secs) = secs_str.trim().parse::<f64>() {
duration_ms = Some((secs * 1000.0) as u64);
}
}
}
}
let mut any_matched = false;
if let Some(caps) = RE_PASSED.captures(line) {
any_matched = true;
passed = caps[1].parse().unwrap_or(0);
}
if let Some(caps) = RE_FAILED.captures(line) {
any_matched = true;
failed = caps[1].parse().unwrap_or(0);
}
if let Some(caps) = RE_SKIPPED.captures(line) {
any_matched = true;
skipped = caps[1].parse().unwrap_or(0);
}
if any_matched {
Some((passed, failed, skipped, duration_ms))
} else {
None
}
}
fn try_parse_regex(text: &str) -> Option<TestResult> {
let mut total_passed: usize = 0;
let mut total_failed: usize = 0;
let mut total_ignored: usize = 0;
let mut found = false;
for caps in RE_CARGO_SUMMARY.captures_iter(text) {
found = true;
total_passed += caps[1].parse::<usize>().unwrap_or(0);
total_failed += caps[2].parse::<usize>().unwrap_or(0);
total_ignored += caps[3].parse::<usize>().unwrap_or(0);
}
if !found {
return None;
}
let summary = TestSummary {
pass: total_passed,
fail: total_failed,
skip: total_ignored,
duration_ms: None,
};
Some(TestResult::new(summary, vec![]))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_path(name: &str) -> std::path::PathBuf {
let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("tests");
path.push("fixtures");
path.push("cmd");
path.push("test");
path.push(name);
path
}
fn load_fixture(name: &str) -> String {
std::fs::read_to_string(fixture_path(name))
.unwrap_or_else(|e| panic!("Failed to load fixture '{name}': {e}"))
}
#[test]
fn test_tier1_all_pass() {
let input = load_fixture("cargo_pass.json");
let result = try_parse_json(&input);
assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed");
let result = result.unwrap();
assert!(result.summary.pass > 0, "Expected at least one pass");
assert_eq!(result.summary.fail, 0, "Expected zero failures");
}
#[test]
fn test_tier1_with_failures() {
let input = load_fixture("cargo_fail.json");
let result = try_parse_json(&input);
assert!(result.is_some(), "Expected Tier 1 JSON parse to succeed");
let result = result.unwrap();
assert!(result.summary.fail > 0, "Expected at least one failure");
let fail_entries: Vec<&TestEntry> = result
.entries
.iter()
.filter(|e| e.outcome == TestOutcome::Fail)
.collect();
assert!(!fail_entries.is_empty(), "Expected failure entries");
assert!(
fail_entries[0].detail.is_some(),
"Expected failure detail (stdout capture)"
);
}
#[test]
fn test_tier1_nextest_pass() {
let input = load_fixture("cargo_nextest_pass.txt");
let result = try_parse_nextest(&input);
assert!(result.is_some(), "Expected Tier 1 nextest parse to succeed");
let result = result.unwrap();
assert_eq!(result.summary.pass, 3);
assert_eq!(result.summary.fail, 0);
}
#[test]
fn test_tier1_nextest_dedup() {
let input = load_fixture("cargo_nextest_fail.txt");
let result = try_parse_nextest(&input);
assert!(result.is_some(), "Expected Tier 1 nextest parse to succeed");
let result = result.unwrap();
let fail_entries: Vec<&TestEntry> = result
.entries
.iter()
.filter(|e| e.outcome == TestOutcome::Fail)
.collect();
assert_eq!(
fail_entries.len(),
1,
"Expected exactly 1 failure entry (deduped), got {}",
fail_entries.len()
);
}
#[test]
fn test_tier2_regex_fallback() {
let text = "running 10 tests\ntest result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out";
let result = try_parse_regex(text);
assert!(result.is_some(), "Expected Tier 2 regex parse to succeed");
let result = result.unwrap();
assert_eq!(result.summary.pass, 10);
assert_eq!(result.summary.fail, 0);
assert_eq!(result.summary.skip, 0);
}
#[test]
fn test_tier2_regex_with_failures() {
let text = "test result: FAILED. 8 passed; 2 failed; 1 ignored; 0 measured";
let result = try_parse_regex(text);
assert!(result.is_some(), "Expected Tier 2 regex parse to succeed");
let result = result.unwrap();
assert_eq!(result.summary.pass, 8);
assert_eq!(result.summary.fail, 2);
assert_eq!(result.summary.skip, 1);
}
#[test]
fn test_parse_json_produces_full() {
let input = load_fixture("cargo_pass.json");
let output = CommandOutput {
stdout: input,
stderr: String::new(),
exit_code: Some(0),
duration: std::time::Duration::ZERO,
};
let result = parse_impl(&output, false);
assert!(
result.is_full(),
"Expected Full parse result, got {}",
result.tier_name()
);
}
#[test]
fn test_parse_plain_text_produces_degraded() {
let output = CommandOutput {
stdout: "test result: ok. 5 passed; 0 failed; 0 ignored".to_string(),
stderr: String::new(),
exit_code: Some(0),
duration: std::time::Duration::ZERO,
};
let result = parse_impl(&output, false);
assert!(
result.is_degraded(),
"Expected Degraded parse result, got {}",
result.tier_name()
);
}
#[test]
fn test_parse_garbage_produces_passthrough() {
let output = CommandOutput {
stdout: "completely unparseable output\nno json, no regex match".to_string(),
stderr: String::new(),
exit_code: Some(1),
duration: std::time::Duration::ZERO,
};
let result = parse_impl(&output, false);
assert!(
result.is_passthrough(),
"Expected Passthrough, got {}",
result.tier_name()
);
}
#[test]
fn test_flag_injection_skipped() {
let args = vec![
"test".to_string(),
"--message-format=json2".to_string(),
"--".to_string(),
"--nocapture".to_string(),
];
assert!(
user_has_flag(&args, &["--message-format"]),
"Should detect existing --message-format flag"
);
}
#[test]
fn test_flag_injection_not_triggered_for_different_flag() {
let args = vec!["test".to_string(), "--release".to_string()];
assert!(
!user_has_flag(&args, &["--message-format"]),
"Should not detect --message-format when only --release is present"
);
}
#[test]
fn test_separator_args_preserved() {
let mut args = vec![
"test".to_string(),
"--".to_string(),
"--nocapture".to_string(),
];
inject_flag_before_separator(&mut args, "--message-format=json");
assert_eq!(args[1], "--message-format=json");
assert_eq!(args[2], "--");
assert_eq!(args[3], "--nocapture");
}
#[test]
fn test_inject_flag_no_separator() {
let mut args = vec!["test".to_string(), "--release".to_string()];
inject_flag_before_separator(&mut args, "--message-format=json");
assert_eq!(args.last().unwrap(), "--message-format=json");
}
}