Skip to main content

lean_ctx/core/patterns/
test.rs

1pub fn compress(output: &str) -> Option<String> {
2    if let Some(r) = try_pytest(output) {
3        return Some(r);
4    }
5    if let Some(r) = try_vitest(output) {
6        return Some(r);
7    }
8    if let Some(r) = try_jest(output) {
9        return Some(r);
10    }
11    if let Some(r) = try_go_test(output) {
12        return Some(r);
13    }
14    if let Some(r) = try_rspec(output) {
15        return Some(r);
16    }
17    if let Some(r) = try_mocha(output) {
18        return Some(r);
19    }
20    None
21}
22
23fn try_pytest(output: &str) -> Option<String> {
24    if !output.contains("test session starts") && !output.contains("pytest") {
25        return None;
26    }
27
28    let mut passed = 0u32;
29    let mut failed = 0u32;
30    let mut skipped = 0u32;
31    let mut xfailed = 0u32;
32    let mut xpassed = 0u32;
33    let mut warnings = 0u32;
34    let mut time = String::new();
35    let mut failures = Vec::new();
36    let mut passed_names = Vec::new();
37
38    for line in output.lines() {
39        let trimmed = line.trim();
40        if (trimmed.contains("passed")
41            || trimmed.contains("failed")
42            || trimmed.contains("error")
43            || trimmed.contains("xfailed")
44            || trimmed.contains("xpassed")
45            || trimmed.contains("warning"))
46            && (trimmed.starts_with('=') || trimmed.starts_with('-'))
47        {
48            for word in trimmed.split_whitespace() {
49                if let Some(n) = word.strip_suffix("passed").or_else(|| {
50                    if trimmed.contains(" passed") {
51                        word.parse::<u32>().ok().map(|_| word)
52                    } else {
53                        None
54                    }
55                }) {
56                    if let Ok(v) = n.trim().parse::<u32>() {
57                        passed = v;
58                    }
59                }
60            }
61            passed = extract_pytest_counter(trimmed, " passed").unwrap_or(passed);
62            failed = extract_pytest_counter(trimmed, " failed").unwrap_or(failed);
63            skipped = extract_pytest_counter(trimmed, " skipped").unwrap_or(skipped);
64            xfailed = extract_pytest_counter(trimmed, " xfailed").unwrap_or(xfailed);
65            xpassed = extract_pytest_counter(trimmed, " xpassed").unwrap_or(xpassed);
66            warnings = extract_pytest_counter(trimmed, " warning").unwrap_or(warnings);
67            if let Some(pos) = trimmed.find(" in ") {
68                time = trimmed[pos + 4..].trim_end_matches('=').trim().to_string();
69            }
70        }
71        if trimmed.starts_with("FAILED ") {
72            failures.push(
73                trimmed
74                    .strip_prefix("FAILED ")
75                    .unwrap_or(trimmed)
76                    .to_string(),
77            );
78        }
79        if trimmed.starts_with("PASSED ") || trimmed.ends_with(" PASSED") {
80            let name = trimmed
81                .strip_prefix("PASSED ")
82                .or_else(|| trimmed.strip_suffix(" PASSED"))
83                .unwrap_or(trimmed);
84            if name.len() <= 50 {
85                passed_names.push(name.to_string());
86            } else {
87                passed_names.push(format!("{}...", &name[..47]));
88            }
89        }
90    }
91
92    if passed == 0 && failed == 0 {
93        return None;
94    }
95
96    let mut result = format!("pytest: {passed} passed");
97    if failed > 0 {
98        result.push_str(&format!(", {failed} failed"));
99    }
100    if skipped > 0 {
101        result.push_str(&format!(", {skipped} skipped"));
102    }
103    if xfailed > 0 {
104        result.push_str(&format!(", {xfailed} xfailed"));
105    }
106    if xpassed > 0 {
107        result.push_str(&format!(", {xpassed} xpassed"));
108    }
109    if warnings > 0 {
110        result.push_str(&format!(", {warnings} warnings"));
111    }
112    if !time.is_empty() {
113        result.push_str(&format!(" ({time})"));
114    }
115
116    for f in failures.iter().take(5) {
117        result.push_str(&format!("\n  FAIL: {f}"));
118    }
119
120    if failures.is_empty() && !passed_names.is_empty() {
121        let total = passed_names.len();
122        let shown: Vec<_> = passed_names.into_iter().take(5).collect();
123        let suffix = if total > 5 {
124            format!(" ...+{} more", total - 5)
125        } else {
126            String::new()
127        };
128        result.push_str(&format!("\n  ran: {}{suffix}", shown.join(", ")));
129    }
130
131    Some(result)
132}
133
134fn extract_pytest_counter(line: &str, keyword: &str) -> Option<u32> {
135    let pos = line.find(keyword)?;
136    let before = &line[..pos];
137    let num_str = before.split_whitespace().last()?;
138    num_str.parse::<u32>().ok()
139}
140
141fn try_jest(output: &str) -> Option<String> {
142    if !output.contains("Tests:") && !output.contains("Test Suites:") {
143        return None;
144    }
145
146    let mut suites_line = String::new();
147    let mut tests_line = String::new();
148    let mut time_line = String::new();
149
150    for line in output.lines() {
151        let trimmed = line.trim();
152        if trimmed.starts_with("Test Suites:") {
153            suites_line = trimmed.to_string();
154        } else if trimmed.starts_with("Tests:") {
155            tests_line = trimmed.to_string();
156        } else if trimmed.starts_with("Time:") {
157            time_line = trimmed.to_string();
158        }
159    }
160
161    if tests_line.is_empty() {
162        return None;
163    }
164
165    let mut result = String::new();
166    if !suites_line.is_empty() {
167        result.push_str(&suites_line);
168        result.push('\n');
169    }
170    result.push_str(&tests_line);
171    if !time_line.is_empty() {
172        result.push('\n');
173        result.push_str(&time_line);
174    }
175
176    Some(result)
177}
178
179fn try_go_test(output: &str) -> Option<String> {
180    if !output.contains("--- PASS") && !output.contains("--- FAIL") && !output.contains("PASS\n") {
181        return None;
182    }
183
184    let mut passed = 0u32;
185    let mut failed = 0u32;
186    let mut failures = Vec::new();
187    let mut passed_names = Vec::new();
188    let mut packages = Vec::new();
189
190    for line in output.lines() {
191        let trimmed = line.trim();
192        if trimmed.starts_with("--- PASS:") {
193            passed += 1;
194            if let Some(name) = trimmed.strip_prefix("--- PASS: ") {
195                let name = name.split_whitespace().next().unwrap_or(name);
196                passed_names.push(name.to_string());
197            }
198        } else if trimmed.starts_with("--- FAIL:") {
199            failed += 1;
200            failures.push(
201                trimmed
202                    .strip_prefix("--- FAIL: ")
203                    .unwrap_or(trimmed)
204                    .to_string(),
205            );
206        } else if trimmed.starts_with("ok ") || trimmed.starts_with("FAIL\t") {
207            packages.push(trimmed.to_string());
208        }
209    }
210
211    if passed == 0 && failed == 0 {
212        return None;
213    }
214
215    let mut result = format!("go test: {passed} passed");
216    if failed > 0 {
217        result.push_str(&format!(", {failed} failed"));
218    }
219
220    for pkg in &packages {
221        result.push_str(&format!("\n  {pkg}"));
222    }
223
224    for f in failures.iter().take(5) {
225        result.push_str(&format!("\n  FAIL: {f}"));
226    }
227
228    if failures.is_empty() && !passed_names.is_empty() {
229        let total = passed_names.len();
230        let shown: Vec<_> = passed_names.into_iter().take(5).collect();
231        let suffix = if total > 5 {
232            format!(" ...+{} more", total - 5)
233        } else {
234            String::new()
235        };
236        result.push_str(&format!("\n  ran: {}{suffix}", shown.join(", ")));
237    }
238
239    Some(result)
240}
241
242fn try_vitest(output: &str) -> Option<String> {
243    if !output.contains("PASS") && !output.contains("FAIL") {
244        return None;
245    }
246    if !output.contains(" Tests ") && !output.contains("Test Files") {
247        return None;
248    }
249
250    let mut test_files_line = String::new();
251    let mut tests_line = String::new();
252    let mut duration_line = String::new();
253    let mut failures = Vec::new();
254
255    for line in output.lines() {
256        let trimmed = line.trim();
257        let plain = strip_ansi(trimmed);
258        if plain.contains("Test Files") {
259            test_files_line.clone_from(&plain);
260        } else if plain.starts_with("Tests") && plain.contains("passed") {
261            tests_line.clone_from(&plain);
262        } else if plain.contains("Duration") || plain.contains("Time") {
263            if plain.contains("ms") || plain.contains('s') {
264                duration_line.clone_from(&plain);
265            }
266        } else if plain.contains("FAIL")
267            && (plain.contains(".test.") || plain.contains(".spec.") || plain.contains("_test."))
268        {
269            failures.push(plain.clone());
270        }
271    }
272
273    if tests_line.is_empty() && test_files_line.is_empty() {
274        return None;
275    }
276
277    let mut result = String::new();
278    if !test_files_line.is_empty() {
279        result.push_str(&test_files_line);
280    }
281    if !tests_line.is_empty() {
282        if !result.is_empty() {
283            result.push('\n');
284        }
285        result.push_str(&tests_line);
286    }
287    if !duration_line.is_empty() {
288        result.push('\n');
289        result.push_str(&duration_line);
290    }
291
292    for f in failures.iter().take(10) {
293        result.push_str(&format!("\n  FAIL: {f}"));
294    }
295
296    Some(result)
297}
298
299fn strip_ansi(s: &str) -> String {
300    crate::core::compressor::strip_ansi(s)
301}
302
303fn try_rspec(output: &str) -> Option<String> {
304    if !output.contains("examples") || !output.contains("failures") {
305        return None;
306    }
307
308    for line in output.lines().rev() {
309        let trimmed = line.trim();
310        if trimmed.contains("example") && trimmed.contains("failure") {
311            return Some(format!("rspec: {trimmed}"));
312        }
313    }
314
315    None
316}
317
318fn try_mocha(output: &str) -> Option<String> {
319    let has_passing = output.contains(" passing");
320    let has_failing = output.contains(" failing");
321    if !has_passing && !has_failing {
322        return None;
323    }
324
325    let mut passing = 0u32;
326    let mut failing = 0u32;
327    let mut duration = String::new();
328    let mut failures = Vec::new();
329    let mut in_failure = false;
330
331    for line in output.lines() {
332        let trimmed = line.trim();
333        if trimmed.contains(" passing") {
334            let before_passing = trimmed.split(" passing").next().unwrap_or("");
335            if let Ok(n) = before_passing.trim().parse::<u32>() {
336                passing = n;
337            }
338            if let Some(start) = trimmed.rfind('(') {
339                if let Some(end) = trimmed.rfind(')') {
340                    if start < end {
341                        duration = trimmed[start + 1..end].to_string();
342                    }
343                }
344            }
345        }
346        if trimmed.contains(" failing") {
347            let before_failing = trimmed.split(" failing").next().unwrap_or("");
348            if let Ok(n) = before_failing.trim().parse::<u32>() {
349                failing = n;
350                in_failure = true;
351            }
352        }
353        if in_failure && trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(')')
354        {
355            if let Some((_, desc)) = trimmed.split_once(')') {
356                failures.push(desc.trim().to_string());
357            }
358        }
359    }
360
361    let mut result = format!("mocha: {passing} passed");
362    if failing > 0 {
363        result.push_str(&format!(", {failing} failed"));
364    }
365    if !duration.is_empty() {
366        result.push_str(&format!(" ({duration})"));
367    }
368
369    for f in failures.iter().take(10) {
370        result.push_str(&format!("\n  FAIL: {f}"));
371    }
372
373    Some(result)
374}
375
376#[cfg(test)]
377mod mocha_tests {
378    use super::*;
379
380    #[test]
381    fn mocha_passing_only() {
382        let output = "  3 passing (50ms)";
383        let result = try_mocha(output).expect("should match");
384        assert!(result.contains("3 passed"));
385        assert!(result.contains("50ms"));
386    }
387
388    #[test]
389    fn mocha_with_failures() {
390        let output =
391            "  2 passing (100ms)\n  1 failing\n\n  1) Array #indexOf():\n     Error: expected -1";
392        let result = try_mocha(output).expect("should match");
393        assert!(result.contains("2 passed"));
394        assert!(result.contains("1 failed"));
395        assert!(result.contains("FAIL:"));
396    }
397}