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