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