Skip to main content

agentstatedeveloper_core/
test_summary.rs

1//! Compact, failures-only summaries of test-runner output (Plan
2//! competitive-harvest t-005). Reads a runner's verbose output and keeps only
3//! what an agent needs to act: the pass/fail counts and each failure with a
4//! little context — typically a ~90% token cut versus the raw log.
5//!
6//! Runners: Cargo and pytest are parsed precisely (the two relevant to an ASD
7//! codebase); everything else falls back to a generic failure-line scan.
8
9/// One failed test plus a few lines of captured detail.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Failure {
12    pub name: String,
13    pub detail: Vec<String>,
14}
15
16/// Compact result of a test run.
17#[derive(Debug, Clone, PartialEq)]
18pub struct TestSummary {
19    pub runner: String,
20    pub passed: usize,
21    pub failed: usize,
22    pub failures: Vec<Failure>,
23}
24
25/// Parse test-runner output into a compact summary, auto-detecting the runner.
26pub fn summarize(text: &str) -> TestSummary {
27    match detect_runner(text) {
28        "cargo" => parse_cargo(text),
29        "pytest" => parse_pytest(text),
30        other => parse_generic(text, other),
31    }
32}
33
34fn detect_runner(text: &str) -> &'static str {
35    if text.contains("test result:") && text.contains("running ") {
36        "cargo"
37    } else if text.contains("=== ")
38        && (text.contains(" passed") || text.contains(" failed"))
39        && (text.contains("pytest") || text.contains("PASSED") || text.contains("FAILED "))
40    {
41        "pytest"
42    } else {
43        "generic"
44    }
45}
46
47// --- Cargo -----------------------------------------------------------------
48
49fn parse_cargo(text: &str) -> TestSummary {
50    let mut passed = 0;
51    let mut failed = 0;
52    let mut failed_names: Vec<String> = Vec::new();
53    // `---- name stdout ----` … blocks give detail per failure.
54    let mut details: std::collections::HashMap<String, Vec<String>> =
55        std::collections::HashMap::new();
56
57    let lines: Vec<&str> = text.lines().collect();
58    let mut i = 0;
59    while i < lines.len() {
60        let line = lines[i];
61        let t = line.trim();
62        // `test result: ok. 12 passed; 0 failed; ...` — sum across binaries.
63        if let Some(rest) = t.strip_prefix("test result:") {
64            passed += count_before(rest, "passed");
65            failed += count_before(rest, "failed");
66        }
67        // `test some::name ... FAILED`
68        if let Some(name) = t.strip_prefix("test ") {
69            if let Some(name) = name.strip_suffix(" ... FAILED") {
70                let name = name.trim();
71                if !name.is_empty() && !failed_names.iter().any(|n| n == name) {
72                    failed_names.push(name.to_string());
73                }
74            }
75        }
76        // `---- name stdout ----` detail block until the next `----`/blank.
77        if t.starts_with("---- ") && t.ends_with(" ----") {
78            let name = t
79                .trim_start_matches("---- ")
80                .trim_end_matches(" ----")
81                .trim_end_matches(" stdout")
82                .trim()
83                .to_string();
84            let mut detail = Vec::new();
85            i += 1;
86            while i < lines.len() {
87                let d = lines[i].trim();
88                if d.is_empty() || d.starts_with("----") {
89                    break;
90                }
91                if detail.len() < 6 {
92                    detail.push(lines[i].trim_end().to_string());
93                }
94                i += 1;
95            }
96            details.entry(name).or_insert(detail);
97            continue;
98        }
99        i += 1;
100    }
101
102    let failures = failed_names
103        .into_iter()
104        .map(|name| {
105            let detail = details.get(&name).cloned().unwrap_or_default();
106            Failure { name, detail }
107        })
108        .collect();
109
110    TestSummary {
111        runner: "cargo".into(),
112        passed,
113        failed,
114        failures,
115    }
116}
117
118// --- pytest ----------------------------------------------------------------
119
120fn parse_pytest(text: &str) -> TestSummary {
121    let mut passed = 0;
122    let mut failed = 0;
123    let mut failures: Vec<Failure> = Vec::new();
124
125    for raw in text.lines() {
126        let t = raw.trim();
127        // Short summary line: `FAILED path::test - AssertionError: ...`
128        if let Some(rest) = t.strip_prefix("FAILED ") {
129            let mut parts = rest.splitn(2, " - ");
130            let name = parts.next().unwrap_or("").trim().to_string();
131            let detail = parts
132                .next()
133                .map(|d| vec![d.trim().to_string()])
134                .unwrap_or_default();
135            if !name.is_empty() && !failures.iter().any(|f| f.name == name) {
136                failures.push(Failure { name, detail });
137            }
138        }
139        // The `=== 2 failed, 10 passed in 1.2s ===` summary line.
140        if t.starts_with("===") && (t.contains(" passed") || t.contains(" failed")) {
141            passed = passed.max(count_before(t, "passed"));
142            failed = failed.max(count_before(t, "failed"));
143        }
144    }
145    if failed == 0 {
146        failed = failures.len();
147    }
148    TestSummary {
149        runner: "pytest".into(),
150        passed,
151        failed,
152        failures,
153    }
154}
155
156// --- Generic ---------------------------------------------------------------
157
158fn parse_generic(text: &str, runner: &str) -> TestSummary {
159    let mut failures: Vec<Failure> = Vec::new();
160    for raw in text.lines() {
161        let t = raw.trim();
162        let looks_failed = t.contains("FAIL")
163            || t.contains("panicked")
164            || t.starts_with("Error")
165            || t.contains("✗")
166            || t.contains("✘");
167        if looks_failed && !t.is_empty() && failures.len() < 200 {
168            failures.push(Failure {
169                name: t.to_string(),
170                detail: Vec::new(),
171            });
172        }
173    }
174    let failed = failures.len();
175    TestSummary {
176        runner: runner.into(),
177        passed: 0,
178        failed,
179        failures,
180    }
181}
182
183// --- helpers ---------------------------------------------------------------
184
185/// Extract the integer immediately preceding `word` in `s`, e.g.
186/// `count_before("ok. 12 passed; 0 failed", "passed")` → 12. Returns 0 if absent.
187fn count_before(s: &str, word: &str) -> usize {
188    let Some(pos) = s.find(word) else {
189        return 0;
190    };
191    s[..pos]
192        .split(|c: char| !c.is_ascii_digit())
193        .filter(|t| !t.is_empty())
194        .next_back()
195        .and_then(|n| n.parse().ok())
196        .unwrap_or(0)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn count_before_extracts_preceding_int() {
205        assert_eq!(count_before("ok. 12 passed; 3 failed", "passed"), 12);
206        assert_eq!(count_before("ok. 12 passed; 3 failed", "failed"), 3);
207        assert_eq!(count_before("no number here", "passed"), 0);
208    }
209
210    #[test]
211    fn cargo_failures_and_counts() {
212        let out = "\
213running 3 tests
214test alpha ... ok
215test beta::works ... FAILED
216test gamma ... ok
217
218failures:
219
220---- beta::works stdout ----
221thread 'beta::works' panicked at src/lib.rs:42:9:
222assertion `left == right` failed
223  left: 1
224  right: 2
225
226failures:
227    beta::works
228
229test result: FAILED. 2 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
230";
231        let s = summarize(out);
232        assert_eq!(s.runner, "cargo");
233        assert_eq!((s.passed, s.failed), (2, 1));
234        assert_eq!(s.failures.len(), 1);
235        assert_eq!(s.failures[0].name, "beta::works");
236        assert!(s.failures[0].detail.iter().any(|d| d.contains("panicked")));
237    }
238
239    #[test]
240    fn cargo_sums_across_binaries() {
241        let out = "\
242running 1 test
243test a ... ok
244test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
245running 2 tests
246test b ... ok
247test c ... ok
248test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
249";
250        let s = summarize(out);
251        assert_eq!((s.passed, s.failed), (3, 0));
252        assert!(s.failures.is_empty());
253    }
254
255    #[test]
256    fn pytest_short_summary() {
257        let out = "\
258=========================== short test summary info ============================
259FAILED tests/test_api.py::test_charge - assert 500 == 200
260FAILED tests/test_api.py::test_refund - KeyError: 'amount'
261========================= 2 failed, 10 passed in 1.23s =========================
262";
263        let s = summarize(out);
264        assert_eq!(s.runner, "pytest");
265        assert_eq!((s.passed, s.failed), (10, 2));
266        assert_eq!(s.failures.len(), 2);
267        assert_eq!(s.failures[0].name, "tests/test_api.py::test_charge");
268        assert_eq!(s.failures[0].detail, vec!["assert 500 == 200".to_string()]);
269    }
270
271    #[test]
272    fn generic_fallback_collects_failure_lines() {
273        let out = "ok pkg/a\n--- FAIL: TestThing (0.00s)\n    thing_test.go:10: boom\nFAIL\n";
274        let s = summarize(out);
275        assert_eq!(s.runner, "generic");
276        assert!(s.failed >= 1);
277        assert!(s.failures.iter().any(|f| f.name.contains("FAIL")));
278    }
279}