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