async fn handle_discovery_run(
project_path: &Path,
output_path: &Path,
use_nextest: bool,
timeout: u64,
) -> Result<()> {
println!("🔍 Discovering test failures in {}", project_path.display());
println!(
" Using: {}",
if use_nextest {
"cargo nextest"
} else {
"cargo test"
}
);
println!(" Timeout: {}s", timeout);
println!();
let mut cmd = if use_nextest {
let mut c = Command::new("cargo");
c.arg("nextest")
.arg("run")
.arg("--workspace")
.arg("--no-fail-fast")
.current_dir(project_path);
c
} else {
let mut c = Command::new("cargo");
c.arg("test")
.arg("--workspace")
.arg("--no-fail-fast")
.current_dir(project_path);
c
};
println!("📊 Running tests (this may take a while)...");
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.context("Failed to run test command")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
println!("\n📈 Parsing test results...");
let failures = parse_test_output(&stdout, &stderr)?;
let combined_for_count = format!("{}\n{}", stdout, stderr);
let report = DiscoveryReport {
total_tests: count_total_tests(&combined_for_count)?,
failures: failures.len(),
test_failures: failures.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
command: format!("{:?}", cmd),
};
let json = serde_json::to_string_pretty(&report)?;
std::fs::write(output_path, json)?;
println!("\n✅ Discovery complete:");
println!(" Total tests: {}", report.total_tests);
println!(" Failures: {}", report.failures);
println!(" Output: {}", output_path.display());
println!();
print_category_summary(&failures);
Ok(())
}
fn parse_test_output(stdout: &str, stderr: &str) -> Result<Vec<TestFailure>> {
let mut failures = Vec::new();
let combined = format!("{}\n{}", stdout, stderr);
for line in combined.lines() {
let trimmed = line.trim();
if trimmed.starts_with("test ") && trimmed.ends_with("FAILED") {
let name = trimmed
.strip_prefix("test ")
.unwrap_or(trimmed)
.split(" ... ")
.next()
.unwrap_or("unknown")
.trim()
.to_string();
failures.push(TestFailure {
name,
file: PathBuf::from("unknown"),
line: None,
reason: "FAILED".to_string(),
category: FailureCategory::Unknown,
duration_ms: None,
});
continue;
}
let nextest_line = trimmed
.strip_prefix('(')
.and_then(|s| s.split(')').nth(1))
.map(str::trim)
.unwrap_or(trimmed);
if nextest_line.starts_with("FAIL") {
if let Some(after_bracket) = nextest_line.split(']').nth(1) {
let name = after_bracket.trim().to_string();
if !failures.iter().any(|f| f.name == name) {
failures.push(TestFailure {
name,
file: PathBuf::from("unknown"),
line: None,
reason: "FAILED".to_string(),
category: FailureCategory::Unknown,
duration_ms: None,
});
}
}
}
}
refine_failure_reasons(&combined, &mut failures);
Ok(failures)
}
fn refine_failure_reasons(output: &str, failures: &mut [TestFailure]) {
let mut current_test: Option<String> = None;
let mut current_reason = String::new();
for trimmed in output
.lines()
.map(str::trim)
.skip_while(|l| *l != "failures:")
.skip(1)
{
if trimmed.starts_with("test result:") || trimmed == "failures:" {
flush_failure_reason(¤t_test, ¤t_reason, failures);
break;
}
if trimmed.starts_with("---- ") && trimmed.ends_with(" ----") {
flush_failure_reason(¤t_test, ¤t_reason, failures);
let inner = trimmed
.strip_prefix("---- ")
.and_then(|s| s.strip_suffix(" ----"))
.unwrap_or("")
.replace(" stdout", "");
current_test = Some(inner);
current_reason.clear();
} else if current_test.is_some() {
current_reason.push_str(trimmed);
current_reason.push('\n');
}
}
}
fn flush_failure_reason(
current_test: &Option<String>,
current_reason: &str,
failures: &mut [TestFailure],
) {
let name = match current_test {
Some(n) => n,
None => return,
};
let reason = current_reason.trim();
if reason.is_empty() {
return;
}
if let Some(f) = failures.iter_mut().find(|f| f.name == *name) {
f.reason = reason.to_string();
f.category = categorize_failure(&f.reason);
}
}
fn categorize_failure(reason: &str) -> FailureCategory {
if reason.contains("timed out") || reason.contains("Timeout") {
FailureCategory::Timeout
} else if reason.contains("failed to compile") || reason.contains("unresolved import") {
FailureCategory::CompileError
} else if reason.contains("panicked at") || reason.contains("thread panicked") {
FailureCategory::RuntimeError
} else if reason.contains("assert") || reason.contains("expected") {
FailureCategory::AssertionFailure
} else {
FailureCategory::Unknown
}
}
fn count_total_tests(stdout: &str) -> Result<usize> {
let mut total = 0usize;
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.starts_with("test result:") {
let passed = extract_number_before(trimmed, " passed").unwrap_or(0);
let failed = extract_number_before(trimmed, " failed").unwrap_or(0);
let ignored = extract_number_before(trimmed, " ignored").unwrap_or(0);
total += passed + failed + ignored;
} else if trimmed.starts_with("Summary") && trimmed.contains("tests run") {
if let Some(count) = extract_number_before(trimmed, " tests run") {
total += count;
}
}
}
Ok(total)
}
fn extract_number_before(s: &str, suffix: &str) -> Option<usize> {
let idx = s.find(suffix)?;
let before = &s[..idx];
let num_str: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
let num_str: String = num_str.chars().rev().collect();
num_str.parse().ok()
}
fn print_category_summary(failures: &[TestFailure]) {
use std::collections::HashMap;
let mut by_category: HashMap<String, usize> = HashMap::new();
for failure in failures {
let cat = format!("{:?}", failure.category);
*by_category.entry(cat).or_insert(0) += 1;
}
println!("📊 Failures by category:");
for (category, count) in by_category {
println!(" {}: {}", category, count);
}
}