Skip to main content

lean_ctx/core/patterns/
test.rs

1pub fn compress(output: &str) -> Option<String> {
2    if let Some(r) = try_cargo_test(output) {
3        return Some(r);
4    }
5    if let Some(r) = try_pytest(output) {
6        return Some(r);
7    }
8    if let Some(r) = try_vitest(output) {
9        return Some(r);
10    }
11    if let Some(r) = try_jest(output) {
12        return Some(r);
13    }
14    if let Some(r) = try_go_test(output) {
15        return Some(r);
16    }
17    if let Some(r) = try_rspec(output) {
18        return Some(r);
19    }
20    if let Some(r) = try_mocha(output) {
21        return Some(r);
22    }
23    None
24}
25
26fn try_cargo_test(output: &str) -> Option<String> {
27    if !output.contains("test result:") && !output.contains("running ") {
28        return None;
29    }
30    if !output.contains(" passed") {
31        return None;
32    }
33
34    let mut total_passed = 0u32;
35    let mut total_failed = 0u32;
36    let mut total_ignored = 0u32;
37    let mut total_filtered = 0u32;
38    let mut time = String::new();
39    let mut failures: Vec<String> = Vec::new();
40    let mut suites = 0u32;
41
42    for line in output.lines() {
43        let trimmed = line.trim();
44        if trimmed.starts_with("test result:") {
45            suites += 1;
46            for part in trimmed.split(';') {
47                let part = part.trim();
48                if let Some(n) = extract_cargo_counter(part, "passed") {
49                    total_passed += n;
50                } else if let Some(n) = extract_cargo_counter(part, "failed") {
51                    total_failed += n;
52                } else if let Some(n) = extract_cargo_counter(part, "ignored") {
53                    total_ignored += n;
54                } else if let Some(n) = extract_cargo_counter(part, "filtered out") {
55                    total_filtered += n;
56                }
57            }
58            if let Some(pos) = trimmed.find("finished in ") {
59                time = trimmed[pos + 12..].trim().to_string();
60            }
61        }
62        if (trimmed.starts_with("test ") && trimmed.ends_with("... FAILED"))
63            || trimmed.starts_with("---- ")
64                && trimmed.ends_with(" ----")
65                && !trimmed.contains("output")
66        {
67            let name = if let Some(rest) = trimmed.strip_prefix("test ") {
68                rest.strip_suffix(" ... FAILED").unwrap_or(rest)
69            } else {
70                trimmed.trim_start_matches('-').trim_end_matches('-').trim()
71            };
72            if !name.is_empty() && !failures.iter().any(|f| f == name) {
73                failures.push(name.to_string());
74            }
75        }
76    }
77
78    if total_passed == 0 && total_failed == 0 {
79        return None;
80    }
81
82    let mut result = format!("cargo test: {total_passed} passed");
83    if total_failed > 0 {
84        result.push_str(&format!(", {total_failed} failed"));
85    }
86    if total_ignored > 0 {
87        result.push_str(&format!(", {total_ignored} ignored"));
88    }
89    if total_filtered > 0 {
90        result.push_str(&format!(", {total_filtered} filtered"));
91    }
92    if suites > 1 {
93        result.push_str(&format!(" ({suites} suites)"));
94    }
95    if !time.is_empty() {
96        result.push_str(&format!(" [{time}]"));
97    }
98
99    for f in failures.iter().take(10) {
100        result.push_str(&format!("\n  FAIL: {f}"));
101    }
102
103    Some(result)
104}
105
106fn extract_cargo_counter(segment: &str, keyword: &str) -> Option<u32> {
107    let pos = segment.find(keyword)?;
108    let before = segment[..pos].trim();
109    let num_str = before.split_whitespace().last()?;
110    num_str.parse::<u32>().ok()
111}
112
113fn try_pytest(output: &str) -> Option<String> {
114    if !output.contains("test session starts") && !output.contains("pytest") {
115        return None;
116    }
117
118    let mut passed = 0u32;
119    let mut failed = 0u32;
120    let mut skipped = 0u32;
121    let mut xfailed = 0u32;
122    let mut xpassed = 0u32;
123    let mut warnings = 0u32;
124    let mut time = String::new();
125    let mut failures = Vec::new();
126    let mut passed_names = Vec::new();
127
128    for line in output.lines() {
129        let trimmed = line.trim();
130        if (trimmed.contains("passed")
131            || trimmed.contains("failed")
132            || trimmed.contains("error")
133            || trimmed.contains("xfailed")
134            || trimmed.contains("xpassed")
135            || trimmed.contains("warning"))
136            && (trimmed.starts_with('=') || trimmed.starts_with('-'))
137        {
138            for word in trimmed.split_whitespace() {
139                if let Some(n) = word.strip_suffix("passed").or_else(|| {
140                    if trimmed.contains(" passed") {
141                        word.parse::<u32>().ok().map(|_| word)
142                    } else {
143                        None
144                    }
145                }) && let Ok(v) = n.trim().parse::<u32>()
146                {
147                    passed = v;
148                }
149            }
150            passed = extract_pytest_counter(trimmed, " passed").unwrap_or(passed);
151            failed = extract_pytest_counter(trimmed, " failed").unwrap_or(failed);
152            skipped = extract_pytest_counter(trimmed, " skipped").unwrap_or(skipped);
153            xfailed = extract_pytest_counter(trimmed, " xfailed").unwrap_or(xfailed);
154            xpassed = extract_pytest_counter(trimmed, " xpassed").unwrap_or(xpassed);
155            warnings = extract_pytest_counter(trimmed, " warning").unwrap_or(warnings);
156            if let Some(pos) = trimmed.find(" in ") {
157                time = trimmed[pos + 4..].trim_end_matches('=').trim().to_string();
158            }
159        }
160        if trimmed.starts_with("FAILED ") {
161            failures.push(
162                trimmed
163                    .strip_prefix("FAILED ")
164                    .unwrap_or(trimmed)
165                    .to_string(),
166            );
167        }
168        if trimmed.starts_with("PASSED ") || trimmed.ends_with(" PASSED") {
169            let name = trimmed
170                .strip_prefix("PASSED ")
171                .or_else(|| trimmed.strip_suffix(" PASSED"))
172                .unwrap_or(trimmed);
173            if name.len() <= 50 {
174                passed_names.push(name.to_string());
175            } else {
176                passed_names.push(format!("{}...", &name[..name.floor_char_boundary(47)]));
177            }
178        }
179    }
180
181    if passed == 0 && failed == 0 {
182        return None;
183    }
184
185    let mut result = format!("pytest: {passed} passed");
186    if failed > 0 {
187        result.push_str(&format!(", {failed} failed"));
188    }
189    if skipped > 0 {
190        result.push_str(&format!(", {skipped} skipped"));
191    }
192    if xfailed > 0 {
193        result.push_str(&format!(", {xfailed} xfailed"));
194    }
195    if xpassed > 0 {
196        result.push_str(&format!(", {xpassed} xpassed"));
197    }
198    if warnings > 0 {
199        result.push_str(&format!(", {warnings} warnings"));
200    }
201    if !time.is_empty() {
202        result.push_str(&format!(" ({time})"));
203    }
204
205    for f in failures.iter().take(5) {
206        result.push_str(&format!("\n  FAIL: {f}"));
207    }
208
209    if failures.is_empty() && !passed_names.is_empty() {
210        let total = passed_names.len();
211        let shown: Vec<_> = passed_names.into_iter().take(5).collect();
212        let suffix = if total > 5 {
213            format!(" ...+{} more", total - 5)
214        } else {
215            String::new()
216        };
217        result.push_str(&format!("\n  ran: {}{suffix}", shown.join(", ")));
218    }
219
220    Some(result)
221}
222
223fn extract_pytest_counter(line: &str, keyword: &str) -> Option<u32> {
224    let pos = line.find(keyword)?;
225    let before = &line[..pos];
226    let num_str = before.split_whitespace().last()?;
227    num_str.parse::<u32>().ok()
228}
229
230fn try_jest(output: &str) -> Option<String> {
231    if !output.contains("Tests:") && !output.contains("Test Suites:") {
232        return None;
233    }
234
235    let mut suites_line = String::new();
236    let mut tests_line = String::new();
237    let mut time_line = String::new();
238
239    for line in output.lines() {
240        let trimmed = line.trim();
241        if trimmed.starts_with("Test Suites:") {
242            suites_line = trimmed.to_string();
243        } else if trimmed.starts_with("Tests:") {
244            tests_line = trimmed.to_string();
245        } else if trimmed.starts_with("Time:") {
246            time_line = trimmed.to_string();
247        }
248    }
249
250    if tests_line.is_empty() {
251        return None;
252    }
253
254    let mut result = String::new();
255    if !suites_line.is_empty() {
256        result.push_str(&suites_line);
257        result.push('\n');
258    }
259    result.push_str(&tests_line);
260    if !time_line.is_empty() {
261        result.push('\n');
262        result.push_str(&time_line);
263    }
264
265    Some(result)
266}
267
268fn try_go_test(output: &str) -> Option<String> {
269    if !output.contains("--- PASS") && !output.contains("--- FAIL") && !output.contains("PASS\n") {
270        return None;
271    }
272
273    let mut passed = 0u32;
274    let mut failed = 0u32;
275    let mut failures = Vec::new();
276    let mut passed_names = Vec::new();
277    let mut packages = Vec::new();
278
279    for line in output.lines() {
280        let trimmed = line.trim();
281        if trimmed.starts_with("--- PASS:") {
282            passed += 1;
283            if let Some(name) = trimmed.strip_prefix("--- PASS: ") {
284                let name = name.split_whitespace().next().unwrap_or(name);
285                passed_names.push(name.to_string());
286            }
287        } else if trimmed.starts_with("--- FAIL:") {
288            failed += 1;
289            failures.push(
290                trimmed
291                    .strip_prefix("--- FAIL: ")
292                    .unwrap_or(trimmed)
293                    .to_string(),
294            );
295        } else if trimmed.starts_with("ok ") || trimmed.starts_with("FAIL\t") {
296            packages.push(trimmed.to_string());
297        }
298    }
299
300    if passed == 0 && failed == 0 {
301        return None;
302    }
303
304    let mut result = format!("go test: {passed} passed");
305    if failed > 0 {
306        result.push_str(&format!(", {failed} failed"));
307    }
308
309    for pkg in &packages {
310        result.push_str(&format!("\n  {pkg}"));
311    }
312
313    for f in failures.iter().take(5) {
314        result.push_str(&format!("\n  FAIL: {f}"));
315    }
316
317    if failures.is_empty() && !passed_names.is_empty() {
318        let total = passed_names.len();
319        let shown: Vec<_> = passed_names.into_iter().take(5).collect();
320        let suffix = if total > 5 {
321            format!(" ...+{} more", total - 5)
322        } else {
323            String::new()
324        };
325        result.push_str(&format!("\n  ran: {}{suffix}", shown.join(", ")));
326    }
327
328    Some(result)
329}
330
331fn try_vitest(output: &str) -> Option<String> {
332    if !output.contains("PASS") && !output.contains("FAIL") {
333        return None;
334    }
335    if !output.contains(" Tests ") && !output.contains("Test Files") {
336        return None;
337    }
338
339    let mut test_files_line = String::new();
340    let mut tests_line = String::new();
341    let mut duration_line = String::new();
342    let mut failures = Vec::new();
343
344    for line in output.lines() {
345        let trimmed = line.trim();
346        let plain = strip_ansi(trimmed);
347        if plain.contains("Test Files") {
348            test_files_line.clone_from(&plain);
349        } else if plain.starts_with("Tests") && plain.contains("passed") {
350            tests_line.clone_from(&plain);
351        } else if plain.contains("Duration") || plain.contains("Time") {
352            if plain.contains("ms") || plain.contains('s') {
353                duration_line.clone_from(&plain);
354            }
355        } else if plain.contains("FAIL")
356            && (plain.contains(".test.") || plain.contains(".spec.") || plain.contains("_test."))
357        {
358            failures.push(plain.clone());
359        }
360    }
361
362    if tests_line.is_empty() && test_files_line.is_empty() {
363        return None;
364    }
365
366    let mut result = String::new();
367    if !test_files_line.is_empty() {
368        result.push_str(&test_files_line);
369    }
370    if !tests_line.is_empty() {
371        if !result.is_empty() {
372            result.push('\n');
373        }
374        result.push_str(&tests_line);
375    }
376    if !duration_line.is_empty() {
377        result.push('\n');
378        result.push_str(&duration_line);
379    }
380
381    for f in failures.iter().take(10) {
382        result.push_str(&format!("\n  FAIL: {f}"));
383    }
384
385    Some(result)
386}
387
388fn strip_ansi(s: &str) -> String {
389    crate::core::compressor::strip_ansi(s)
390}
391
392fn try_rspec(output: &str) -> Option<String> {
393    if !output.contains("examples") || !output.contains("failures") {
394        return None;
395    }
396
397    for line in output.lines().rev() {
398        let trimmed = line.trim();
399        if trimmed.contains("example") && trimmed.contains("failure") {
400            return Some(format!("rspec: {trimmed}"));
401        }
402    }
403
404    None
405}
406
407fn try_mocha(output: &str) -> Option<String> {
408    let has_passing = output.contains(" passing");
409    let has_failing = output.contains(" failing");
410    if !has_passing && !has_failing {
411        return None;
412    }
413
414    let mut passing = 0u32;
415    let mut failing = 0u32;
416    let mut duration = String::new();
417    let mut failures = Vec::new();
418    let mut in_failure = false;
419
420    for line in output.lines() {
421        let trimmed = line.trim();
422        if trimmed.contains(" passing") {
423            let before_passing = trimmed.split(" passing").next().unwrap_or("");
424            if let Ok(n) = before_passing.trim().parse::<u32>() {
425                passing = n;
426            }
427            if let Some(start) = trimmed.rfind('(')
428                && let Some(end) = trimmed.rfind(')')
429                && start < end
430            {
431                duration = trimmed[start + 1..end].to_string();
432            }
433        }
434        if trimmed.contains(" failing") {
435            let before_failing = trimmed.split(" failing").next().unwrap_or("");
436            if let Ok(n) = before_failing.trim().parse::<u32>() {
437                failing = n;
438                in_failure = true;
439            }
440        }
441        if in_failure
442            && trimmed.starts_with(|c: char| c.is_ascii_digit())
443            && trimmed.contains(')')
444            && let Some((_, desc)) = trimmed.split_once(')')
445        {
446            failures.push(desc.trim().to_string());
447        }
448    }
449
450    let mut result = format!("mocha: {passing} passed");
451    if failing > 0 {
452        result.push_str(&format!(", {failing} failed"));
453    }
454    if !duration.is_empty() {
455        result.push_str(&format!(" ({duration})"));
456    }
457
458    for f in failures.iter().take(10) {
459        result.push_str(&format!("\n  FAIL: {f}"));
460    }
461
462    Some(result)
463}
464
465#[cfg(test)]
466mod mocha_tests {
467    use super::*;
468
469    #[test]
470    fn mocha_passing_only() {
471        let output = "  3 passing (50ms)";
472        let result = try_mocha(output).expect("should match");
473        assert!(result.contains("3 passed"));
474        assert!(result.contains("50ms"));
475    }
476
477    #[test]
478    fn mocha_with_failures() {
479        let output =
480            "  2 passing (100ms)\n  1 failing\n\n  1) Array #indexOf():\n     Error: expected -1";
481        let result = try_mocha(output).expect("should match");
482        assert!(result.contains("2 passed"));
483        assert!(result.contains("1 failed"));
484        assert!(result.contains("FAIL:"));
485    }
486}
487
488#[cfg(test)]
489mod cargo_tests {
490    use super::*;
491
492    #[test]
493    fn cargo_test_all_passing() {
494        let output = "\
495   Compiling lean-ctx v3.9.11 (/Users/test/rust)
496     Running unittests src/lib.rs (target/debug/deps/lean_ctx-abc123)
497
498running 245 tests
499test core::tokens::tests::count_empty ... ok
500test core::tokens::tests::count_hello ... ok
501test core::config::tests::default_config ... ok
502test result: ok. 245 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 4.23s
503
504     Running tests/integration.rs (target/debug/deps/integration-def456)
505
506running 12 tests
507test api_read ... ok
508test api_search ... ok
509test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.10s";
510
511        let result = try_cargo_test(output).expect("should match");
512        assert!(result.contains("257 passed"));
513        assert!(result.contains("3 ignored"));
514        assert!(result.contains("2 suites"));
515        assert!(!result.contains("FAIL"));
516    }
517
518    #[test]
519    fn cargo_test_with_failures() {
520        let output = "\
521running 50 tests
522test core::foo ... ok
523test core::bar ... FAILED
524test result: ok. 49 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.0s";
525
526        let result = try_cargo_test(output).expect("should match");
527        assert!(result.contains("49 passed"));
528        assert!(result.contains("1 failed"));
529        assert!(result.contains("FAIL: core::bar"));
530    }
531
532    #[test]
533    fn cargo_test_single_suite() {
534        let output = "\
535running 10 tests
536test a ... ok
537test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.5s";
538
539        let result = try_cargo_test(output).expect("should match");
540        assert!(result.contains("10 passed"));
541        assert!(!result.contains("suites"));
542    }
543
544    #[test]
545    fn non_cargo_output_rejected() {
546        let output = "hello world\nfoo bar";
547        assert!(try_cargo_test(output).is_none());
548    }
549}