use crate::types::{StepResult, TestDetail, TestSummary}; /// Parse run-ci.sh output into a structured TestSummary. /// /// Looks for: /// - `PASS ` / `FAIL ` lines from the CI summary /// - `test result: ok. N passed; M failed` lines from cargo test /// - `test ... ok` / `test ... FAILED` individual test lines pub fn parse_ci_output(output: &str) -> TestSummary { let mut steps = Vec::new(); let mut total_passed: i64 = 0; let mut total_failed: i64 = 0; let mut found_test_results = false; for line in output.lines() { let trimmed = line.trim(); // Parse PASS/FAIL lines from run-ci.sh summary if let Some(name) = trimmed.strip_prefix("PASS ") { steps.push(StepResult { name: name.trim().to_string(), passed: true, }); } else if let Some(name) = trimmed.strip_prefix("FAIL ") { steps.push(StepResult { name: name.trim().to_string(), passed: false, }); } // Parse cargo test result lines if trimmed.starts_with("test result:") { found_test_results = true; // "test result: ok. 42 passed; 0 failed; 0 ignored; ..." if let Some(counts) = parse_test_result_line(trimmed) { total_passed += counts.0; total_failed += counts.1; } } } let details = parse_individual_tests(output); TestSummary { steps, total_passed: if found_test_results { Some(total_passed) } else { None }, total_failed: if found_test_results { Some(total_failed) } else { None }, details, } } /// Parse individual test result lines from cargo test output. /// /// Matches lines like: /// - `test workflows::endorsements::toggle ... ok` /// - `test markdown::tests::bold_and_italic ... FAILED` pub fn parse_individual_tests(output: &str) -> Vec { let mut details = Vec::new(); for line in output.lines() { let trimmed = line.trim(); // Match: "test ... ok" or "test ... FAILED" if let Some(rest) = trimmed.strip_prefix("test ") { if let Some(name) = rest.strip_suffix(" ... ok") { details.push(TestDetail { test_name: name.to_string(), passed: true, }); } else if let Some(name) = rest.strip_suffix(" ... FAILED") { details.push(TestDetail { test_name: name.to_string(), passed: false, }); } } } details } fn parse_test_result_line(line: &str) -> Option<(i64, i64)> { // "test result: ok. 42 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out" let mut passed = 0i64; let mut failed = 0i64; for part in line.split(';') { let part = part.trim(); if part.ends_with("passed") { // "42 passed" or "ok. 42 passed" let num_str = part .rsplit_once(". ") .map(|(_, r)| r) .unwrap_or(part) .trim() .strip_suffix(" passed")? .trim(); passed = num_str.parse().ok()?; } else if part.ends_with("failed") { let num_str = part.trim().strip_suffix(" failed")?.trim(); failed = num_str.parse().ok()?; } } Some((passed, failed)) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_full_ci_output() { let output = r#" ======================================== cargo check ======================================== Finished `dev` profile ======================================== cargo test --lib ======================================== running 45 tests test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s ======================================== cargo test --test integration ======================================== running 714 tests test result: ok. 714 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 89.2s ======================================== CI Summary ======================================== PASS cargo check PASS cargo test --lib PASS cargo test --test integration PASS cargo clippy PASS cargo audit All steps passed. "#; let summary = parse_ci_output(output); assert_eq!(summary.steps.len(), 5); assert!(summary.steps.iter().all(|s| s.passed)); assert_eq!(summary.total_passed, Some(759)); assert_eq!(summary.total_failed, Some(0)); } #[test] fn parse_failed_ci_output() { let output = r#" ======================================== CI Summary ======================================== PASS cargo check FAIL cargo test --lib PASS cargo clippy 1 step(s) failed. "#; let summary = parse_ci_output(output); assert_eq!(summary.steps.len(), 3); assert!(!summary.steps[1].passed); assert_eq!(summary.steps[1].name, "cargo test --lib"); } #[test] fn parse_individual_tests_ok_and_failed() { let output = r#" running 4 tests test workflows::endorsements::toggle_endorsement_removes ... ok test workflows::endorsements::self_endorse_rejected ... ok test markdown::tests::bold_and_italic ... ok test workflows::endorsements::endorse_post_happy_path ... FAILED test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out "#; let details = parse_individual_tests(output); assert_eq!(details.len(), 4); assert!(details[0].passed); assert_eq!(details[0].test_name, "workflows::endorsements::toggle_endorsement_removes"); assert!(details[1].passed); assert!(!details[3].passed); assert_eq!(details[3].test_name, "workflows::endorsements::endorse_post_happy_path"); } #[test] fn parse_individual_tests_empty_when_no_individual_lines() { let output = r#" ======================================== CI Summary ======================================== PASS cargo check PASS cargo test --lib "#; let details = parse_individual_tests(output); assert!(details.is_empty()); } #[test] fn parse_ci_output_includes_details() { let output = r#" ======================================== cargo test --lib ======================================== running 2 tests test foo::bar ... ok test foo::baz ... FAILED test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out ======================================== CI Summary ======================================== FAIL cargo test --lib "#; let summary = parse_ci_output(output); assert_eq!(summary.details.len(), 2); assert!(summary.details[0].passed); assert!(!summary.details[1].passed); assert_eq!(summary.total_passed, Some(1)); assert_eq!(summary.total_failed, Some(1)); } #[test] fn parse_test_result_line_ok() { let line = "test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out"; let (p, f) = parse_test_result_line(line).unwrap(); assert_eq!(p, 42); assert_eq!(f, 0); } #[test] fn parse_test_result_line_failed() { let line = "test result: FAILED. 40 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out"; let (p, f) = parse_test_result_line(line).unwrap(); assert_eq!(p, 40); assert_eq!(f, 2); } }