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 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}