zeph_tools/filter/
test_output.rs1use 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 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 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}