Skip to main content

batty_cli/team/
test_results.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct TestFailure {
6    pub test_name: String,
7    #[serde(default, skip_serializing_if = "Option::is_none")]
8    pub message: Option<String>,
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub location: Option<String>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct TestResults {
15    pub framework: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub total: Option<u32>,
18    pub passed: u32,
19    pub failed: u32,
20    pub ignored: u32,
21    #[serde(default)]
22    pub failures: Vec<TestFailure>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub summary: Option<String>,
25}
26
27impl TestResults {
28    pub fn failure_summary(&self) -> String {
29        if self.failed == 0 || self.failures.is_empty() {
30            return "tests failed".to_string();
31        }
32
33        let details = self
34            .failures
35            .iter()
36            .take(3)
37            .map(TestFailure::summary)
38            .collect::<Vec<_>>()
39            .join("; ");
40        let more = self.failures.len().saturating_sub(3);
41        if more > 0 {
42            format!("{} tests failed: {}; +{} more", self.failed, details, more)
43        } else {
44            format!("{} tests failed: {}", self.failed, details)
45        }
46    }
47}
48
49impl TestFailure {
50    fn summary(&self) -> String {
51        let mut out = self.test_name.clone();
52        if let Some(message) = self
53            .message
54            .as_deref()
55            .filter(|message| !message.is_empty())
56        {
57            out.push_str(" (");
58            out.push_str(message);
59            if let Some(location) = self
60                .location
61                .as_deref()
62                .filter(|location| !location.is_empty())
63            {
64                out.push_str(" at ");
65                out.push_str(location);
66            }
67            out.push(')');
68            return out;
69        }
70        if let Some(location) = self
71            .location
72            .as_deref()
73            .filter(|location| !location.is_empty())
74        {
75            out.push_str(" (at ");
76            out.push_str(location);
77            out.push(')');
78        }
79        out
80    }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TestRunOutput {
85    pub passed: bool,
86    pub output: String,
87    pub results: TestResults,
88}
89
90pub fn parse(command_text: &str, output: &str, passed: bool) -> TestResults {
91    let command = command_text.to_ascii_lowercase();
92    if command.contains("pytest") {
93        parse_pytest(output, passed)
94    } else if command.contains("jest") {
95        parse_jest(output, passed)
96    } else {
97        parse_cargo_test(output, passed)
98    }
99}
100
101pub fn parse_cargo_test(output: &str, passed: bool) -> TestResults {
102    let summary_re = Regex::new(
103        r"test result:\s+(?:ok|FAILED)\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored;",
104    )
105    .expect("valid regex");
106    let block_header_re = Regex::new(r"^----\s+(.+?)\s+stdout\s+----$").expect("valid regex");
107
108    let mut passed_count = 0;
109    let mut failed_count = if passed { 0 } else { 1 };
110    let mut ignored_count = 0;
111    let mut total = None;
112    let mut summary = None;
113
114    if let Some(captures) = summary_re.captures(output) {
115        passed_count = captures[1].parse().unwrap_or(0);
116        failed_count = captures[2].parse().unwrap_or(0);
117        ignored_count = captures[3].parse().unwrap_or(0);
118        total = Some(passed_count + failed_count + ignored_count);
119        summary = captures.get(0).map(|match_| match_.as_str().to_string());
120    }
121
122    let mut failures = Vec::new();
123    let mut current_name: Option<String> = None;
124    let mut current_message: Option<String> = None;
125    let mut current_location: Option<String> = None;
126
127    for line in output.lines() {
128        let trimmed = line.trim();
129        if let Some(captures) = block_header_re.captures(trimmed) {
130            if let Some(name) = current_name.take() {
131                failures.push(TestFailure {
132                    test_name: name,
133                    message: current_message.take(),
134                    location: current_location.take(),
135                });
136            }
137            current_name = Some(captures[1].trim().to_string());
138            continue;
139        }
140
141        if current_name.is_none() {
142            continue;
143        }
144
145        if let Some((location, message)) = parse_cargo_panic_line(trimmed) {
146            current_location = Some(location);
147            if current_message.is_none() {
148                current_message = message;
149            }
150            continue;
151        }
152
153        if current_message.is_none()
154            && !trimmed.is_empty()
155            && !trimmed.starts_with("stack backtrace:")
156            && !trimmed.starts_with("note:")
157            && !trimmed.starts_with("failures:")
158        {
159            current_message = Some(normalize_message(trimmed));
160        }
161    }
162
163    if let Some(name) = current_name.take() {
164        failures.push(TestFailure {
165            test_name: name,
166            message: current_message.take(),
167            location: current_location.take(),
168        });
169    }
170
171    TestResults {
172        framework: "cargo".to_string(),
173        total,
174        passed: passed_count,
175        failed: failed_count.max(failures.len() as u32),
176        ignored: ignored_count,
177        failures,
178        summary,
179    }
180}
181
182fn parse_cargo_panic_line(line: &str) -> Option<(String, Option<String>)> {
183    let panic_marker = "panicked at ";
184    let panic_start = line.find(panic_marker)?;
185    let rest = line[panic_start + panic_marker.len()..].trim();
186
187    if let Some((message, location)) = rest.rsplit_once(", ") {
188        let normalized_message = normalize_message(message.trim_matches('\''));
189        if !normalized_message.is_empty() && looks_like_location(location) {
190            return Some((location.trim().to_string(), Some(normalized_message)));
191        }
192    }
193
194    let location = rest.strip_suffix(':').unwrap_or(rest).trim();
195    if looks_like_location(location) {
196        return Some((location.to_string(), None));
197    }
198
199    None
200}
201
202fn looks_like_location(value: &str) -> bool {
203    let mut segments = value.rsplit(':');
204    let Some(column) = segments.next() else {
205        return false;
206    };
207    let Some(line) = segments.next() else {
208        return false;
209    };
210    column.chars().all(|c| c.is_ascii_digit()) && line.chars().all(|c| c.is_ascii_digit())
211}
212
213pub fn parse_pytest(output: &str, passed: bool) -> TestResults {
214    let summary_re =
215        Regex::new(r"=+\s+((?:\d+\s+\w+(?:,\s*)?)+)\s+in\s+[0-9.]+s\s+=+").expect("valid regex");
216    let failure_re = Regex::new(r"^_{2,}\s+(.+?)\s+_{2,}$").expect("valid regex");
217    let location_re = Regex::new(r"^(.+?):(\d+):\s+(?:AssertionError|E\s+)").expect("valid regex");
218
219    let mut results = TestResults {
220        framework: "pytest".to_string(),
221        total: None,
222        passed: 0,
223        failed: if passed { 0 } else { 1 },
224        ignored: 0,
225        failures: Vec::new(),
226        summary: None,
227    };
228
229    if let Some(captures) = summary_re.captures(output) {
230        let summary_text = captures[1].to_string();
231        results.summary = Some(summary_text.clone());
232        for chunk in summary_text.split(',') {
233            let parts = chunk.split_whitespace().collect::<Vec<_>>();
234            if parts.len() < 2 {
235                continue;
236            }
237            let count = parts[0].parse::<u32>().unwrap_or(0);
238            match parts[1] {
239                "passed" => results.passed = count,
240                "failed" => results.failed = count,
241                "skipped" | "xfailed" | "xpassed" => results.ignored += count,
242                _ => {}
243            }
244        }
245        results.total = Some(results.passed + results.failed + results.ignored);
246    }
247
248    let mut current: Option<TestFailure> = None;
249    for line in output.lines() {
250        let trimmed = line.trim_end();
251        if let Some(captures) = failure_re.captures(trimmed) {
252            if let Some(failure) = current.take() {
253                results.failures.push(failure);
254            }
255            current = Some(TestFailure {
256                test_name: captures[1].trim().to_string(),
257                message: None,
258                location: None,
259            });
260            continue;
261        }
262
263        let Some(failure) = current.as_mut() else {
264            continue;
265        };
266
267        if failure.location.is_none()
268            && let Some(captures) = location_re.captures(trimmed)
269        {
270            failure.location = Some(format!("{}:{}", captures[1].trim(), &captures[2]));
271            continue;
272        }
273
274        if failure.message.is_none() && trimmed.starts_with("E ") {
275            failure.message = Some(normalize_message(trimmed.trim_start_matches("E ")));
276        }
277    }
278
279    if let Some(failure) = current.take() {
280        results.failures.push(failure);
281    }
282
283    results.failed = results.failed.max(results.failures.len() as u32);
284    results
285}
286
287pub fn parse_jest(output: &str, passed: bool) -> TestResults {
288    let summary_re = Regex::new(
289        r"Tests:\s+(\d+)\s+failed(?:,\s+(\d+)\s+skipped)?(?:,\s+(\d+)\s+passed)?(?:,\s+(\d+)\s+total)?",
290    )
291    .expect("valid regex");
292    let fail_line_re = Regex::new(r"^\s*[xX]\s+(.+?)\s+\((\d+)\s*ms\)\s*$").expect("valid regex");
293    let suite_re = Regex::new(r"^FAIL\s+(.+)$").expect("valid regex");
294
295    let mut results = TestResults {
296        framework: "jest".to_string(),
297        total: None,
298        passed: 0,
299        failed: if passed { 0 } else { 1 },
300        ignored: 0,
301        failures: Vec::new(),
302        summary: None,
303    };
304
305    if let Some(captures) = summary_re.captures(output) {
306        results.failed = captures[1].parse().unwrap_or(results.failed);
307        results.ignored = captures
308            .get(2)
309            .and_then(|match_| match_.as_str().parse().ok())
310            .unwrap_or(0);
311        results.passed = captures
312            .get(3)
313            .and_then(|match_| match_.as_str().parse().ok())
314            .unwrap_or(0);
315        results.total = captures
316            .get(4)
317            .and_then(|match_| match_.as_str().parse().ok())
318            .or(Some(results.passed + results.failed + results.ignored));
319        results.summary = captures.get(0).map(|match_| match_.as_str().to_string());
320    }
321
322    let mut current_suite: Option<String> = None;
323    for line in output.lines() {
324        let trimmed = line.trim_end();
325        if let Some(captures) = suite_re.captures(trimmed) {
326            current_suite = Some(captures[1].trim().to_string());
327            continue;
328        }
329        if let Some(captures) = fail_line_re.captures(trimmed) {
330            let test_name = match current_suite.as_deref() {
331                Some(suite) => format!("{suite} :: {}", captures[1].trim()),
332                None => captures[1].trim().to_string(),
333            };
334            results.failures.push(TestFailure {
335                test_name,
336                message: None,
337                location: None,
338            });
339        }
340    }
341
342    results.failed = results.failed.max(results.failures.len() as u32);
343    results
344}
345
346fn normalize_message(message: &str) -> String {
347    message.split_whitespace().collect::<Vec<_>>().join(" ")
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn parses_cargo_failures_with_message_and_location() {
356        let output = r#"
357running 2 tests
358test tests::passes ... ok
359test tests::fails ... FAILED
360
361failures:
362
363---- tests::fails stdout ----
364thread 'tests::fails' panicked at src/lib.rs:12:9:
365assertion `left == right` failed
366  left: 4
367 right: 5
368
369failures:
370    tests::fails
371
372test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
373"#;
374
375        let results = parse_cargo_test(output, false);
376        assert_eq!(results.framework, "cargo");
377        assert_eq!(results.total, Some(2));
378        assert_eq!(results.passed, 1);
379        assert_eq!(results.failed, 1);
380        assert_eq!(results.failures.len(), 1);
381        assert_eq!(results.failures[0].test_name, "tests::fails");
382        assert_eq!(
383            results.failures[0].message.as_deref(),
384            Some("assertion `left == right` failed")
385        );
386        assert_eq!(
387            results.failures[0].location.as_deref(),
388            Some("src/lib.rs:12:9")
389        );
390    }
391
392    #[test]
393    fn parses_pytest_summary_and_failure() {
394        let output = r#"
395__________________________ test_dispatch_guard ___________________________
396
397tmp/test_dispatch.py:14: AssertionError
398E   assert 2 == 3
399
400=========================== short test summary info ============================
401FAILED tmp/test_dispatch.py::test_dispatch_guard - assert 2 == 3
402========================= 1 failed, 2 passed in 0.12s =========================
403"#;
404
405        let results = parse_pytest(output, false);
406        assert_eq!(results.framework, "pytest");
407        assert_eq!(results.total, Some(3));
408        assert_eq!(results.passed, 2);
409        assert_eq!(results.failed, 1);
410        assert_eq!(results.failures[0].test_name, "test_dispatch_guard");
411        assert_eq!(
412            results.failures[0].location.as_deref(),
413            Some("tmp/test_dispatch.py:14")
414        );
415        assert_eq!(
416            results.failures[0].message.as_deref(),
417            Some("assert 2 == 3")
418        );
419    }
420
421    #[test]
422    fn parses_jest_summary_and_failures() {
423        let output = r#"
424FAIL src/app.test.ts
425  feature
426    x renders details (5 ms)
427
428Tests:       1 failed, 2 passed, 3 total
429"#;
430
431        let results = parse_jest(output, false);
432        assert_eq!(results.framework, "jest");
433        assert_eq!(results.total, Some(3));
434        assert_eq!(results.passed, 2);
435        assert_eq!(results.failed, 1);
436        assert_eq!(
437            results.failures[0].test_name,
438            "src/app.test.ts :: renders details"
439        );
440    }
441
442    #[test]
443    fn formats_failure_summary() {
444        let results = TestResults {
445            framework: "cargo".to_string(),
446            total: Some(3),
447            passed: 1,
448            failed: 2,
449            ignored: 0,
450            failures: vec![
451                TestFailure {
452                    test_name: "a::fails".to_string(),
453                    message: Some("expected 2, got 3".to_string()),
454                    location: Some("src/a.rs:10".to_string()),
455                },
456                TestFailure {
457                    test_name: "b::fails".to_string(),
458                    message: None,
459                    location: None,
460                },
461            ],
462            summary: None,
463        };
464
465        assert_eq!(
466            results.failure_summary(),
467            "2 tests failed: a::fails (expected 2, got 3 at src/a.rs:10); b::fails"
468        );
469    }
470
471    #[test]
472    fn parses_legacy_cargo_panic_message_and_location() {
473        let output = r#"
474running 1 test
475test tests::fails ... FAILED
476
477failures:
478
479---- tests::fails stdout ----
480thread 'tests::fails' panicked at 'assertion `left == right` failed', src/lib.rs:12:9
481
482failures:
483    tests::fails
484
485test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
486"#;
487
488        let results = parse_cargo_test(output, false);
489        assert_eq!(
490            results.failures[0].message.as_deref(),
491            Some("assertion `left == right` failed")
492        );
493        assert_eq!(
494            results.failures[0].location.as_deref(),
495            Some("src/lib.rs:12:9")
496        );
497    }
498}