Skip to main content

zeph_tools/filter/
test_output.rs

1use std::fmt::Write;
2use std::sync::LazyLock;
3
4use super::{
5    CommandMatcher, FilterConfidence, FilterResult, OutputFilter, TestFilterConfig, make_result,
6};
7
8static TEST_MATCHER: LazyLock<CommandMatcher> = LazyLock::new(|| {
9    CommandMatcher::Custom(Box::new(|command| {
10        let cmd = command.to_lowercase();
11        let tokens: Vec<&str> = cmd.split_whitespace().collect();
12        if tokens.first() != Some(&"cargo") {
13            return false;
14        }
15        tokens
16            .iter()
17            .skip(1)
18            .any(|t| *t == "test" || *t == "nextest")
19    }))
20});
21
22pub struct TestOutputFilter {
23    config: TestFilterConfig,
24}
25
26impl TestOutputFilter {
27    #[must_use]
28    pub fn new(config: TestFilterConfig) -> Self {
29        Self { config }
30    }
31}
32
33impl OutputFilter for TestOutputFilter {
34    fn name(&self) -> &'static str {
35        "test"
36    }
37
38    fn matcher(&self) -> &CommandMatcher {
39        &TEST_MATCHER
40    }
41
42    fn filter(&self, _command: &str, raw_output: &str, exit_code: i32) -> FilterResult {
43        let mut passed = 0u64;
44        let mut failed = 0u64;
45        let mut ignored = 0u64;
46        let mut filtered_out = 0u64;
47        let mut failure_blocks: Vec<String> = Vec::new();
48        let mut in_failure_block = false;
49        let mut current_block = String::new();
50        let mut has_summary = false;
51
52        for line in raw_output.lines() {
53            let trimmed = line.trim();
54
55            if trimmed.starts_with("FAIL [") || trimmed.starts_with("FAIL  [") {
56                failed += 1;
57                continue;
58            }
59            if trimmed.starts_with("PASS [") || trimmed.starts_with("PASS  [") {
60                passed += 1;
61                continue;
62            }
63
64            // Standard cargo test failure block
65            if trimmed.starts_with("---- ") && trimmed.ends_with(" stdout ----") {
66                in_failure_block = true;
67                current_block.clear();
68                current_block.push_str(line);
69                current_block.push('\n');
70                continue;
71            }
72
73            if in_failure_block {
74                current_block.push_str(line);
75                current_block.push('\n');
76                if trimmed == "failures:" || trimmed.starts_with("---- ") {
77                    failure_blocks.push(current_block.clone());
78                    in_failure_block = trimmed.starts_with("---- ");
79                    if in_failure_block {
80                        current_block.clear();
81                        current_block.push_str(line);
82                        current_block.push('\n');
83                    }
84                }
85                continue;
86            }
87
88            if trimmed == "failures:" && !current_block.is_empty() {
89                failure_blocks.push(current_block.clone());
90                current_block.clear();
91            }
92
93            // Parse summary line
94            if trimmed.starts_with("test result:") {
95                has_summary = true;
96                for part in trimmed.split(';') {
97                    let part = part.trim();
98                    if let Some(n) = extract_count(part, "passed") {
99                        passed += n;
100                    } else if let Some(n) = extract_count(part, "failed") {
101                        failed += n;
102                    } else if let Some(n) = extract_count(part, "ignored") {
103                        ignored += n;
104                    } else if let Some(n) = extract_count(part, "filtered out") {
105                        filtered_out += n;
106                    }
107                }
108            }
109
110            if trimmed.contains("tests run:") {
111                has_summary = true;
112            }
113        }
114
115        if in_failure_block && !current_block.is_empty() {
116            failure_blocks.push(current_block);
117        }
118
119        if !has_summary && passed == 0 && failed == 0 {
120            return make_result(
121                raw_output,
122                raw_output.to_owned(),
123                FilterConfidence::Fallback,
124            );
125        }
126
127        let mut output = String::new();
128
129        if exit_code != 0 && !failure_blocks.is_empty() {
130            format_failures(&mut output, &failure_blocks, &self.config);
131        }
132
133        let status = if failed > 0 { "FAILED" } else { "ok" };
134        let _ = write!(
135            output,
136            "test result: {status}. {passed} passed; {failed} failed; \
137             {ignored} ignored; {filtered_out} filtered out"
138        );
139
140        make_result(raw_output, output, FilterConfidence::Full)
141    }
142}
143
144fn format_failures(output: &mut String, blocks: &[String], config: &TestFilterConfig) {
145    output.push_str("FAILURES:\n\n");
146    let max = config.max_failures;
147    for block in blocks.iter().take(max) {
148        let lines: Vec<&str> = block.lines().collect();
149        if lines.len() > config.truncate_stack_trace {
150            for line in &lines[..config.truncate_stack_trace] {
151                output.push_str(line);
152                output.push('\n');
153            }
154            let remaining = lines.len() - config.truncate_stack_trace;
155            let _ = writeln!(output, "... ({remaining} more lines)");
156        } else {
157            output.push_str(block);
158        }
159        output.push('\n');
160    }
161    if blocks.len() > max {
162        let _ = writeln!(output, "... and {} more failure(s)", blocks.len() - max);
163    }
164}
165
166fn extract_count(s: &str, label: &str) -> Option<u64> {
167    let idx = s.find(label)?;
168    let before = s[..idx].trim();
169    let num_str = before.rsplit_once(' ').map_or(before, |(_, n)| n);
170    let num_str = num_str.trim_end_matches('.');
171    let num_str = num_str.rsplit('.').next().unwrap_or(num_str).trim();
172    num_str.parse().ok()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn make_filter() -> TestOutputFilter {
180        TestOutputFilter::new(TestFilterConfig::default())
181    }
182
183    #[test]
184    fn matches_cargo_test() {
185        let f = make_filter();
186        assert!(f.matcher().matches("cargo test"));
187        assert!(f.matcher().matches("cargo test --workspace"));
188        assert!(f.matcher().matches("cargo +nightly test"));
189        assert!(f.matcher().matches("cargo nextest run"));
190        assert!(!f.matcher().matches("cargo build"));
191        assert!(!f.matcher().matches("cargo test-helper"));
192        assert!(!f.matcher().matches("cargo install cargo-nextest"));
193    }
194
195    #[test]
196    fn filter_success_compresses() {
197        let f = make_filter();
198        let raw = "\
199running 3 tests
200test foo::test_a ... ok
201test foo::test_b ... ok
202test foo::test_c ... ok
203
204test result: ok. 3 passed; 0 failed; 0 ignored; 0 filtered out; finished in 0.01s
205";
206        let result = f.filter("cargo test", raw, 0);
207        assert!(result.output.contains("3 passed"));
208        assert!(result.output.contains("0 failed"));
209        assert!(!result.output.contains("test_a"));
210        assert!(result.savings_pct() > 30.0);
211        assert_eq!(result.confidence, FilterConfidence::Full);
212    }
213
214    #[test]
215    fn filter_failure_preserves_details() {
216        let f = make_filter();
217        let raw = "\
218running 2 tests
219test foo::test_a ... ok
220test foo::test_b ... FAILED
221
222---- foo::test_b stdout ----
223thread 'foo::test_b' panicked at 'assertion failed: false'
224
225failures:
226    foo::test_b
227
228test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 filtered out; finished in 0.01s
229";
230        let result = f.filter("cargo test", raw, 1);
231        assert!(result.output.contains("FAILURES:"));
232        assert!(result.output.contains("foo::test_b"));
233        assert!(result.output.contains("assertion failed"));
234        assert!(result.output.contains("1 failed"));
235    }
236
237    #[test]
238    fn filter_no_summary_passthrough() {
239        let f = make_filter();
240        let raw = "some random output with no test results";
241        let result = f.filter("cargo test", raw, 0);
242        assert_eq!(result.output, raw);
243        assert_eq!(result.confidence, FilterConfidence::Fallback);
244    }
245}