lean_ctx/core/patterns/
test.rs1pub 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}