1use regex::Regex;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct TestFailure {
6 pub test_name: String,
7 #[serde(default, skip_serializing_if = "Option::is_none")]
8 pub message: Option<String>,
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub location: Option<String>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct TestResults {
15 pub framework: String,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub total: Option<u32>,
18 pub passed: u32,
19 pub failed: u32,
20 pub ignored: u32,
21 #[serde(default)]
22 pub failures: Vec<TestFailure>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub summary: Option<String>,
25}
26
27impl TestResults {
28 pub fn failure_summary(&self) -> String {
29 if self.failed == 0 || self.failures.is_empty() {
30 return "tests failed".to_string();
31 }
32
33 let details = self
34 .failures
35 .iter()
36 .take(3)
37 .map(TestFailure::summary)
38 .collect::<Vec<_>>()
39 .join("; ");
40 let more = self.failures.len().saturating_sub(3);
41 if more > 0 {
42 format!("{} tests failed: {}; +{} more", self.failed, details, more)
43 } else {
44 format!("{} tests failed: {}", self.failed, details)
45 }
46 }
47}
48
49impl TestFailure {
50 fn summary(&self) -> String {
51 let mut out = self.test_name.clone();
52 if let Some(message) = self
53 .message
54 .as_deref()
55 .filter(|message| !message.is_empty())
56 {
57 out.push_str(" (");
58 out.push_str(message);
59 if let Some(location) = self
60 .location
61 .as_deref()
62 .filter(|location| !location.is_empty())
63 {
64 out.push_str(" at ");
65 out.push_str(location);
66 }
67 out.push(')');
68 return out;
69 }
70 if let Some(location) = self
71 .location
72 .as_deref()
73 .filter(|location| !location.is_empty())
74 {
75 out.push_str(" (at ");
76 out.push_str(location);
77 out.push(')');
78 }
79 out
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct TestRunOutput {
85 pub passed: bool,
86 pub output: String,
87 pub results: TestResults,
88}
89
90pub fn parse(command_text: &str, output: &str, passed: bool) -> TestResults {
91 let command = command_text.to_ascii_lowercase();
92 if command.contains("pytest") {
93 parse_pytest(output, passed)
94 } else if command.contains("jest") {
95 parse_jest(output, passed)
96 } else {
97 parse_cargo_test(output, passed)
98 }
99}
100
101pub fn parse_cargo_test(output: &str, passed: bool) -> TestResults {
102 let summary_re = Regex::new(
103 r"test result:\s+(?:ok|FAILED)\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored;",
104 )
105 .expect("valid regex");
106 let block_header_re = Regex::new(r"^----\s+(.+?)\s+stdout\s+----$").expect("valid regex");
107
108 let mut passed_count = 0;
109 let mut failed_count = if passed { 0 } else { 1 };
110 let mut ignored_count = 0;
111 let mut total = None;
112 let mut summary = None;
113
114 if let Some(captures) = summary_re.captures(output) {
115 passed_count = captures[1].parse().unwrap_or(0);
116 failed_count = captures[2].parse().unwrap_or(0);
117 ignored_count = captures[3].parse().unwrap_or(0);
118 total = Some(passed_count + failed_count + ignored_count);
119 summary = captures.get(0).map(|match_| match_.as_str().to_string());
120 }
121
122 let mut failures = Vec::new();
123 let mut current_name: Option<String> = None;
124 let mut current_message: Option<String> = None;
125 let mut current_location: Option<String> = None;
126
127 for line in output.lines() {
128 let trimmed = line.trim();
129 if let Some(captures) = block_header_re.captures(trimmed) {
130 if let Some(name) = current_name.take() {
131 failures.push(TestFailure {
132 test_name: name,
133 message: current_message.take(),
134 location: current_location.take(),
135 });
136 }
137 current_name = Some(captures[1].trim().to_string());
138 continue;
139 }
140
141 if current_name.is_none() {
142 continue;
143 }
144
145 if let Some((location, message)) = parse_cargo_panic_line(trimmed) {
146 current_location = Some(location);
147 if current_message.is_none() {
148 current_message = message;
149 }
150 continue;
151 }
152
153 if current_message.is_none()
154 && !trimmed.is_empty()
155 && !trimmed.starts_with("stack backtrace:")
156 && !trimmed.starts_with("note:")
157 && !trimmed.starts_with("failures:")
158 {
159 current_message = Some(normalize_message(trimmed));
160 }
161 }
162
163 if let Some(name) = current_name.take() {
164 failures.push(TestFailure {
165 test_name: name,
166 message: current_message.take(),
167 location: current_location.take(),
168 });
169 }
170
171 TestResults {
172 framework: "cargo".to_string(),
173 total,
174 passed: passed_count,
175 failed: failed_count.max(failures.len() as u32),
176 ignored: ignored_count,
177 failures,
178 summary,
179 }
180}
181
182fn parse_cargo_panic_line(line: &str) -> Option<(String, Option<String>)> {
183 let panic_marker = "panicked at ";
184 let panic_start = line.find(panic_marker)?;
185 let rest = line[panic_start + panic_marker.len()..].trim();
186
187 if let Some((message, location)) = rest.rsplit_once(", ") {
188 let normalized_message = normalize_message(message.trim_matches('\''));
189 if !normalized_message.is_empty() && looks_like_location(location) {
190 return Some((location.trim().to_string(), Some(normalized_message)));
191 }
192 }
193
194 let location = rest.strip_suffix(':').unwrap_or(rest).trim();
195 if looks_like_location(location) {
196 return Some((location.to_string(), None));
197 }
198
199 None
200}
201
202fn looks_like_location(value: &str) -> bool {
203 let mut segments = value.rsplit(':');
204 let Some(column) = segments.next() else {
205 return false;
206 };
207 let Some(line) = segments.next() else {
208 return false;
209 };
210 column.chars().all(|c| c.is_ascii_digit()) && line.chars().all(|c| c.is_ascii_digit())
211}
212
213pub fn parse_pytest(output: &str, passed: bool) -> TestResults {
214 let summary_re =
215 Regex::new(r"=+\s+((?:\d+\s+\w+(?:,\s*)?)+)\s+in\s+[0-9.]+s\s+=+").expect("valid regex");
216 let failure_re = Regex::new(r"^_{2,}\s+(.+?)\s+_{2,}$").expect("valid regex");
217 let location_re = Regex::new(r"^(.+?):(\d+):\s+(?:AssertionError|E\s+)").expect("valid regex");
218
219 let mut results = TestResults {
220 framework: "pytest".to_string(),
221 total: None,
222 passed: 0,
223 failed: if passed { 0 } else { 1 },
224 ignored: 0,
225 failures: Vec::new(),
226 summary: None,
227 };
228
229 if let Some(captures) = summary_re.captures(output) {
230 let summary_text = captures[1].to_string();
231 results.summary = Some(summary_text.clone());
232 for chunk in summary_text.split(',') {
233 let parts = chunk.split_whitespace().collect::<Vec<_>>();
234 if parts.len() < 2 {
235 continue;
236 }
237 let count = parts[0].parse::<u32>().unwrap_or(0);
238 match parts[1] {
239 "passed" => results.passed = count,
240 "failed" => results.failed = count,
241 "skipped" | "xfailed" | "xpassed" => results.ignored += count,
242 _ => {}
243 }
244 }
245 results.total = Some(results.passed + results.failed + results.ignored);
246 }
247
248 let mut current: Option<TestFailure> = None;
249 for line in output.lines() {
250 let trimmed = line.trim_end();
251 if let Some(captures) = failure_re.captures(trimmed) {
252 if let Some(failure) = current.take() {
253 results.failures.push(failure);
254 }
255 current = Some(TestFailure {
256 test_name: captures[1].trim().to_string(),
257 message: None,
258 location: None,
259 });
260 continue;
261 }
262
263 let Some(failure) = current.as_mut() else {
264 continue;
265 };
266
267 if failure.location.is_none()
268 && let Some(captures) = location_re.captures(trimmed)
269 {
270 failure.location = Some(format!("{}:{}", captures[1].trim(), &captures[2]));
271 continue;
272 }
273
274 if failure.message.is_none() && trimmed.starts_with("E ") {
275 failure.message = Some(normalize_message(trimmed.trim_start_matches("E ")));
276 }
277 }
278
279 if let Some(failure) = current.take() {
280 results.failures.push(failure);
281 }
282
283 results.failed = results.failed.max(results.failures.len() as u32);
284 results
285}
286
287pub fn parse_jest(output: &str, passed: bool) -> TestResults {
288 let summary_re = Regex::new(
289 r"Tests:\s+(\d+)\s+failed(?:,\s+(\d+)\s+skipped)?(?:,\s+(\d+)\s+passed)?(?:,\s+(\d+)\s+total)?",
290 )
291 .expect("valid regex");
292 let fail_line_re = Regex::new(r"^\s*[xX]\s+(.+?)\s+\((\d+)\s*ms\)\s*$").expect("valid regex");
293 let suite_re = Regex::new(r"^FAIL\s+(.+)$").expect("valid regex");
294
295 let mut results = TestResults {
296 framework: "jest".to_string(),
297 total: None,
298 passed: 0,
299 failed: if passed { 0 } else { 1 },
300 ignored: 0,
301 failures: Vec::new(),
302 summary: None,
303 };
304
305 if let Some(captures) = summary_re.captures(output) {
306 results.failed = captures[1].parse().unwrap_or(results.failed);
307 results.ignored = captures
308 .get(2)
309 .and_then(|match_| match_.as_str().parse().ok())
310 .unwrap_or(0);
311 results.passed = captures
312 .get(3)
313 .and_then(|match_| match_.as_str().parse().ok())
314 .unwrap_or(0);
315 results.total = captures
316 .get(4)
317 .and_then(|match_| match_.as_str().parse().ok())
318 .or(Some(results.passed + results.failed + results.ignored));
319 results.summary = captures.get(0).map(|match_| match_.as_str().to_string());
320 }
321
322 let mut current_suite: Option<String> = None;
323 for line in output.lines() {
324 let trimmed = line.trim_end();
325 if let Some(captures) = suite_re.captures(trimmed) {
326 current_suite = Some(captures[1].trim().to_string());
327 continue;
328 }
329 if let Some(captures) = fail_line_re.captures(trimmed) {
330 let test_name = match current_suite.as_deref() {
331 Some(suite) => format!("{suite} :: {}", captures[1].trim()),
332 None => captures[1].trim().to_string(),
333 };
334 results.failures.push(TestFailure {
335 test_name,
336 message: None,
337 location: None,
338 });
339 }
340 }
341
342 results.failed = results.failed.max(results.failures.len() as u32);
343 results
344}
345
346fn normalize_message(message: &str) -> String {
347 message.split_whitespace().collect::<Vec<_>>().join(" ")
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn parses_cargo_failures_with_message_and_location() {
356 let output = r#"
357running 2 tests
358test tests::passes ... ok
359test tests::fails ... FAILED
360
361failures:
362
363---- tests::fails stdout ----
364thread 'tests::fails' panicked at src/lib.rs:12:9:
365assertion `left == right` failed
366 left: 4
367 right: 5
368
369failures:
370 tests::fails
371
372test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
373"#;
374
375 let results = parse_cargo_test(output, false);
376 assert_eq!(results.framework, "cargo");
377 assert_eq!(results.total, Some(2));
378 assert_eq!(results.passed, 1);
379 assert_eq!(results.failed, 1);
380 assert_eq!(results.failures.len(), 1);
381 assert_eq!(results.failures[0].test_name, "tests::fails");
382 assert_eq!(
383 results.failures[0].message.as_deref(),
384 Some("assertion `left == right` failed")
385 );
386 assert_eq!(
387 results.failures[0].location.as_deref(),
388 Some("src/lib.rs:12:9")
389 );
390 }
391
392 #[test]
393 fn parses_pytest_summary_and_failure() {
394 let output = r#"
395__________________________ test_dispatch_guard ___________________________
396
397tmp/test_dispatch.py:14: AssertionError
398E assert 2 == 3
399
400=========================== short test summary info ============================
401FAILED tmp/test_dispatch.py::test_dispatch_guard - assert 2 == 3
402========================= 1 failed, 2 passed in 0.12s =========================
403"#;
404
405 let results = parse_pytest(output, false);
406 assert_eq!(results.framework, "pytest");
407 assert_eq!(results.total, Some(3));
408 assert_eq!(results.passed, 2);
409 assert_eq!(results.failed, 1);
410 assert_eq!(results.failures[0].test_name, "test_dispatch_guard");
411 assert_eq!(
412 results.failures[0].location.as_deref(),
413 Some("tmp/test_dispatch.py:14")
414 );
415 assert_eq!(
416 results.failures[0].message.as_deref(),
417 Some("assert 2 == 3")
418 );
419 }
420
421 #[test]
422 fn parses_jest_summary_and_failures() {
423 let output = r#"
424FAIL src/app.test.ts
425 feature
426 x renders details (5 ms)
427
428Tests: 1 failed, 2 passed, 3 total
429"#;
430
431 let results = parse_jest(output, false);
432 assert_eq!(results.framework, "jest");
433 assert_eq!(results.total, Some(3));
434 assert_eq!(results.passed, 2);
435 assert_eq!(results.failed, 1);
436 assert_eq!(
437 results.failures[0].test_name,
438 "src/app.test.ts :: renders details"
439 );
440 }
441
442 #[test]
443 fn formats_failure_summary() {
444 let results = TestResults {
445 framework: "cargo".to_string(),
446 total: Some(3),
447 passed: 1,
448 failed: 2,
449 ignored: 0,
450 failures: vec![
451 TestFailure {
452 test_name: "a::fails".to_string(),
453 message: Some("expected 2, got 3".to_string()),
454 location: Some("src/a.rs:10".to_string()),
455 },
456 TestFailure {
457 test_name: "b::fails".to_string(),
458 message: None,
459 location: None,
460 },
461 ],
462 summary: None,
463 };
464
465 assert_eq!(
466 results.failure_summary(),
467 "2 tests failed: a::fails (expected 2, got 3 at src/a.rs:10); b::fails"
468 );
469 }
470
471 #[test]
472 fn parses_legacy_cargo_panic_message_and_location() {
473 let output = r#"
474running 1 test
475test tests::fails ... FAILED
476
477failures:
478
479---- tests::fails stdout ----
480thread 'tests::fails' panicked at 'assertion `left == right` failed', src/lib.rs:12:9
481
482failures:
483 tests::fails
484
485test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
486"#;
487
488 let results = parse_cargo_test(output, false);
489 assert_eq!(
490 results.failures[0].message.as_deref(),
491 Some("assertion `left == right` failed")
492 );
493 assert_eq!(
494 results.failures[0].location.as_deref(),
495 Some("src/lib.rs:12:9")
496 );
497 }
498}