agentstatedeveloper_core/
test_summary.rs1#[derive(Debug, Clone, PartialEq)]
11pub struct Failure {
12 pub name: String,
13 pub detail: Vec<String>,
14}
15
16#[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
25pub 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
47fn 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 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 if let Some(rest) = t.strip_prefix("test result:") {
64 passed += count_before(rest, "passed");
65 failed += count_before(rest, "failed");
66 }
67 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 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
118fn 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 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 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
156fn 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
183fn 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}