lean_ctx_patterns/
test.rs1use crate::ansi::strip_ansi;
4
5pub fn compress(output: &str) -> Option<String> {
6 if let Some(r) = try_cargo_test(output) {
7 return Some(r);
8 }
9 if let Some(r) = try_pytest(output) {
10 return Some(r);
11 }
12 if let Some(r) = try_vitest(output) {
13 return Some(r);
14 }
15 if let Some(r) = try_jest(output) {
16 return Some(r);
17 }
18 if let Some(r) = try_go_test(output) {
19 return Some(r);
20 }
21 if let Some(r) = try_rspec(output) {
22 return Some(r);
23 }
24 if let Some(r) = try_mocha(output) {
25 return Some(r);
26 }
27 None
28}
29
30fn try_cargo_test(output: &str) -> Option<String> {
31 if !output.contains("test result:") && !output.contains("running ") {
32 return None;
33 }
34 if !output.contains(" passed") {
35 return None;
36 }
37
38 let mut total_passed = 0u32;
39 let mut total_failed = 0u32;
40 let mut total_ignored = 0u32;
41 let mut total_filtered = 0u32;
42 let mut time = String::new();
43 let mut failures: Vec<String> = Vec::new();
44 let mut passed_names: Vec<String> = Vec::new();
45 let mut suites = 0u32;
46
47 for line in output.lines() {
48 let trimmed = line.trim();
49 if trimmed.starts_with("test result:") {
50 suites += 1;
51 for part in trimmed.split(';') {
52 let part = part.trim();
53 if let Some(n) = extract_cargo_counter(part, "passed") {
54 total_passed += n;
55 } else if let Some(n) = extract_cargo_counter(part, "failed") {
56 total_failed += n;
57 } else if let Some(n) = extract_cargo_counter(part, "ignored") {
58 total_ignored += n;
59 } else if let Some(n) = extract_cargo_counter(part, "filtered out") {
60 total_filtered += n;
61 }
62 }
63 if let Some(pos) = trimmed.find("finished in ") {
64 time = trimmed[pos + 12..].trim().to_string();
65 }
66 }
67 if trimmed.starts_with("test ") && trimmed.ends_with("... ok") {
68 if let Some(name) = trimmed
69 .strip_prefix("test ")
70 .and_then(|r| r.strip_suffix(" ... ok"))
71 {
72 passed_names.push(name.to_string());
73 }
74 }
75 if (trimmed.starts_with("test ") && trimmed.ends_with("... FAILED"))
76 || trimmed.starts_with("---- ")
77 && trimmed.ends_with(" ----")
78 && !trimmed.contains("output")
79 {
80 let name = if let Some(rest) = trimmed.strip_prefix("test ") {
81 rest.strip_suffix(" ... FAILED").unwrap_or(rest)
82 } else {
83 trimmed.trim_start_matches('-').trim_end_matches('-').trim()
84 };
85 if !name.is_empty() && !failures.iter().any(|f| f == name) {
86 failures.push(name.to_string());
87 }
88 }
89 }
90
91 if total_passed == 0 && total_failed == 0 {
92 return None;
93 }
94
95 let mut result = format!("cargo test: {total_passed} passed");
96 if total_failed > 0 {
97 result.push_str(&format!(", {total_failed} failed"));
98 }
99 if total_ignored > 0 {
100 result.push_str(&format!(", {total_ignored} ignored"));
101 }
102 if total_filtered > 0 {
103 result.push_str(&format!(", {total_filtered} filtered"));
104 }
105 if suites > 1 {
106 result.push_str(&format!(" ({suites} suites)"));
107 }
108 if !time.is_empty() {
109 result.push_str(&format!(" [{time}]"));
110 }
111
112 for f in failures.iter().take(10) {
113 result.push_str(&format!("\n FAIL: {f}"));
114 }
115
116 Some(result)
117}
118
119fn extract_cargo_counter(segment: &str, keyword: &str) -> Option<u32> {
120 let pos = segment.find(keyword)?;
121 let before = segment[..pos].trim();
122 let num_str = before.split_whitespace().last()?;
123 num_str.parse::<u32>().ok()
124}
125
126fn try_pytest(output: &str) -> Option<String> {
127 if !output.contains("test session starts") && !output.contains("pytest") {
128 return None;
129 }
130
131 let mut passed = 0u32;
132 let mut failed = 0u32;
133 let mut skipped = 0u32;
134 let mut xfailed = 0u32;
135 let mut xpassed = 0u32;
136 let mut warnings = 0u32;
137 let mut time = String::new();
138 let mut failures = Vec::new();
139 let mut passed_names = Vec::new();
140
141 for line in output.lines() {
142 let trimmed = line.trim();
143 if (trimmed.contains("passed")
144 || trimmed.contains("failed")
145 || trimmed.contains("error")
146 || trimmed.contains("xfailed")
147 || trimmed.contains("xpassed")
148 || trimmed.contains("warning"))
149 && (trimmed.starts_with('=') || trimmed.starts_with('-'))
150 {
151 for word in trimmed.split_whitespace() {
152 if let Some(n) = word.strip_suffix("passed").or_else(|| {
153 if trimmed.contains(" passed") {
154 word.parse::<u32>().ok().map(|_| word)
155 } else {
156 None
157 }
158 }) && let Ok(v) = n.trim().parse::<u32>()
159 {
160 passed = v;
161 }
162 }
163 passed = extract_pytest_counter(trimmed, " passed").unwrap_or(passed);
164 failed = extract_pytest_counter(trimmed, " failed").unwrap_or(failed);
165 skipped = extract_pytest_counter(trimmed, " skipped").unwrap_or(skipped);
166 xfailed = extract_pytest_counter(trimmed, " xfailed").unwrap_or(xfailed);
167 xpassed = extract_pytest_counter(trimmed, " xpassed").unwrap_or(xpassed);
168 warnings = extract_pytest_counter(trimmed, " warning").unwrap_or(warnings);
169 if let Some(pos) = trimmed.find(" in ") {
170 time = trimmed[pos + 4..].trim_end_matches('=').trim().to_string();
171 }
172 }
173 if trimmed.starts_with("FAILED ") {
174 failures.push(
175 trimmed
176 .strip_prefix("FAILED ")
177 .unwrap_or(trimmed)
178 .to_string(),
179 );
180 }
181 if trimmed.starts_with("PASSED ") || trimmed.ends_with(" PASSED") {
182 let name = trimmed
183 .strip_prefix("PASSED ")
184 .or_else(|| trimmed.strip_suffix(" PASSED"))
185 .unwrap_or(trimmed);
186 if name.len() <= 50 {
187 passed_names.push(name.to_string());
188 } else {
189 passed_names.push(format!("{}...", &name[..name.floor_char_boundary(47)]));
190 }
191 }
192 }
193
194 if passed == 0 && failed == 0 {
195 return None;
196 }
197
198 let mut result = format!("pytest: {passed} passed");
199 if failed > 0 {
200 result.push_str(&format!(", {failed} failed"));
201 }
202 if skipped > 0 {
203 result.push_str(&format!(", {skipped} skipped"));
204 }
205 if xfailed > 0 {
206 result.push_str(&format!(", {xfailed} xfailed"));
207 }
208 if xpassed > 0 {
209 result.push_str(&format!(", {xpassed} xpassed"));
210 }
211 if warnings > 0 {
212 result.push_str(&format!(", {warnings} warnings"));
213 }
214 if !time.is_empty() {
215 result.push_str(&format!(" ({time})"));
216 }
217
218 for f in failures.iter().take(5) {
219 result.push_str(&format!("\n FAIL: {f}"));
220 }
221
222 if failures.is_empty() && !passed_names.is_empty() {
223 let total = passed_names.len();
224 let shown: Vec<_> = passed_names.into_iter().take(5).collect();
225 let suffix = if total > 5 {
226 format!(" ...+{} more", total - 5)
227 } else {
228 String::new()
229 };
230 result.push_str(&format!("\n ran: {}{suffix}", shown.join(", ")));
231 }
232
233 Some(result)
234}
235
236fn extract_pytest_counter(line: &str, keyword: &str) -> Option<u32> {
237 let pos = line.find(keyword)?;
238 let before = &line[..pos];
239 let num_str = before.split_whitespace().last()?;
240 num_str.parse::<u32>().ok()
241}
242
243fn try_jest(output: &str) -> Option<String> {
244 if !output.contains("Tests:") && !output.contains("Test Suites:") {
245 return None;
246 }
247
248 let mut suites_line = String::new();
249 let mut tests_line = String::new();
250 let mut time_line = String::new();
251
252 for line in output.lines() {
253 let trimmed = line.trim();
254 if trimmed.starts_with("Test Suites:") {
255 suites_line = trimmed.to_string();
256 } else if trimmed.starts_with("Tests:") {
257 tests_line = trimmed.to_string();
258 } else if trimmed.starts_with("Time:") {
259 time_line = trimmed.to_string();
260 }
261 }
262
263 if tests_line.is_empty() {
264 return None;
265 }
266
267 let mut result = String::new();
268 if !suites_line.is_empty() {
269 result.push_str(&suites_line);
270 result.push('\n');
271 }
272 result.push_str(&tests_line);
273 if !time_line.is_empty() {
274 result.push('\n');
275 result.push_str(&time_line);
276 }
277
278 Some(result)
279}
280
281fn try_go_test(output: &str) -> Option<String> {
282 if !output.contains("--- PASS") && !output.contains("--- FAIL") && !output.contains("PASS\n") {
283 return None;
284 }
285
286 let mut passed = 0u32;
287 let mut failed = 0u32;
288 let mut failures = Vec::new();
289 let mut passed_names = Vec::new();
290 let mut packages = Vec::new();
291
292 for line in output.lines() {
293 let trimmed = line.trim();
294 if trimmed.starts_with("--- PASS:") {
295 passed += 1;
296 if let Some(name) = trimmed.strip_prefix("--- PASS: ") {
297 let name = name.split_whitespace().next().unwrap_or(name);
298 passed_names.push(name.to_string());
299 }
300 } else if trimmed.starts_with("--- FAIL:") {
301 failed += 1;
302 failures.push(
303 trimmed
304 .strip_prefix("--- FAIL: ")
305 .unwrap_or(trimmed)
306 .to_string(),
307 );
308 } else if trimmed.starts_with("ok ") || trimmed.starts_with("FAIL\t") {
309 packages.push(trimmed.to_string());
310 }
311 }
312
313 if passed == 0 && failed == 0 {
314 return None;
315 }
316
317 let mut result = format!("go test: {passed} passed");
318 if failed > 0 {
319 result.push_str(&format!(", {failed} failed"));
320 }
321
322 for pkg in &packages {
323 result.push_str(&format!("\n {pkg}"));
324 }
325
326 for f in failures.iter().take(5) {
327 result.push_str(&format!("\n FAIL: {f}"));
328 }
329
330 if failures.is_empty() && !passed_names.is_empty() {
331 let total = passed_names.len();
332 let shown: Vec<_> = passed_names.into_iter().take(5).collect();
333 let suffix = if total > 5 {
334 format!(" ...+{} more", total - 5)
335 } else {
336 String::new()
337 };
338 result.push_str(&format!("\n ran: {}{suffix}", shown.join(", ")));
339 }
340
341 Some(result)
342}
343
344fn try_vitest(output: &str) -> Option<String> {
345 if !output.contains("PASS") && !output.contains("FAIL") {
346 return None;
347 }
348 if !output.contains(" Tests ") && !output.contains("Test Files") {
349 return None;
350 }
351
352 let mut test_files_line = String::new();
353 let mut tests_line = String::new();
354 let mut duration_line = String::new();
355 let mut failures = Vec::new();
356
357 for line in output.lines() {
358 let trimmed = line.trim();
359 let plain = strip_ansi(trimmed);
360 if plain.contains("Test Files") {
361 test_files_line.clone_from(&plain);
362 } else if plain.starts_with("Tests") && plain.contains("passed") {
363 tests_line.clone_from(&plain);
364 } else if plain.contains("Duration") || plain.contains("Time") {
365 if plain.contains("ms") || plain.contains('s') {
366 duration_line.clone_from(&plain);
367 }
368 } else if plain.contains("FAIL")
369 && (plain.contains(".test.") || plain.contains(".spec.") || plain.contains("_test."))
370 {
371 failures.push(plain.clone());
372 }
373 }
374
375 if tests_line.is_empty() && test_files_line.is_empty() {
376 return None;
377 }
378
379 let mut result = String::new();
380 if !test_files_line.is_empty() {
381 result.push_str(&test_files_line);
382 }
383 if !tests_line.is_empty() {
384 if !result.is_empty() {
385 result.push('\n');
386 }
387 result.push_str(&tests_line);
388 }
389 if !duration_line.is_empty() {
390 result.push('\n');
391 result.push_str(&duration_line);
392 }
393
394 for f in failures.iter().take(10) {
395 result.push_str(&format!("\n FAIL: {f}"));
396 }
397
398 Some(result)
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}