1use std::path::Path;
2use std::process::Command;
3use std::time::Duration;
4
5use anyhow::Result;
6
7use super::util::duration_from_secs_safe;
8use super::{
9 DetectionResult, TestAdapter, TestCase, TestError, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct ZigAdapter;
13
14impl Default for ZigAdapter {
15 fn default() -> Self {
16 Self::new()
17 }
18}
19
20impl ZigAdapter {
21 pub fn new() -> Self {
22 Self
23 }
24}
25
26impl TestAdapter for ZigAdapter {
27 fn name(&self) -> &str {
28 "Zig"
29 }
30
31 fn check_runner(&self) -> Option<String> {
32 if which::which("zig").is_err() {
33 return Some("zig not found. Install Zig.".into());
34 }
35 None
36 }
37
38 fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
39 if !project_dir.join("build.zig").exists() {
40 return None;
41 }
42
43 Some(DetectionResult {
44 language: "Zig".into(),
45 framework: "zig test".into(),
46 confidence: 0.95,
47 })
48 }
49
50 fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
51 let mut cmd = Command::new("zig");
52 cmd.arg("build");
53 cmd.arg("test");
54
55 for arg in extra_args {
56 cmd.arg(arg);
57 }
58
59 cmd.current_dir(project_dir);
60 Ok(cmd)
61 }
62
63 fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
64 let combined = format!("{}\n{}", stdout, stderr);
65
66 let mut suites = parse_zig_output(&combined, exit_code);
67
68 let failures = parse_zig_failures(&combined);
70 if !failures.is_empty() {
71 enrich_with_errors(&mut suites, &failures);
72 }
73
74 let duration = parse_zig_duration(&combined).unwrap_or(Duration::from_secs(0));
75
76 TestRunResult {
77 suites,
78 duration,
79 raw_exit_code: exit_code,
80 }
81 }
82}
83
84fn parse_zig_output(output: &str, exit_code: i32) -> Vec<TestSuite> {
98 let mut tests = Vec::new();
99
100 for line in output.lines() {
101 let trimmed = line.trim();
102
103 if trimmed.starts_with("Test [") {
105 let status = if trimmed.ends_with("OK") {
106 TestStatus::Passed
107 } else if trimmed.ends_with("FAIL") || trimmed.contains("FAIL") {
108 TestStatus::Failed
109 } else if trimmed.ends_with("SKIP") {
110 TestStatus::Skipped
111 } else {
112 continue;
113 };
114
115 let name = if let Some(bracket_end) = trimmed.find("] ") {
117 let after = &trimmed[bracket_end + 2..];
118 after
119 .rfind("...")
120 .map(|i| after[..i].trim())
121 .unwrap_or(after)
122 .to_string()
123 } else {
124 "unknown".into()
125 };
126
127 tests.push(TestCase {
128 name,
129 status,
130 duration: Duration::from_millis(0),
131 error: None,
132 });
133 }
134 }
135
136 if tests.is_empty()
138 && let Some((passed, failed)) = parse_zig_summary(output)
139 {
140 for i in 0..passed {
141 tests.push(TestCase {
142 name: format!("test_{}", i + 1),
143 status: TestStatus::Passed,
144 duration: Duration::from_millis(0),
145 error: None,
146 });
147 }
148 for i in 0..failed {
149 tests.push(TestCase {
150 name: format!("failed_test_{}", i + 1),
151 status: TestStatus::Failed,
152 duration: Duration::from_millis(0),
153 error: None,
154 });
155 }
156 }
157
158 if tests.is_empty() {
159 tests.push(TestCase {
160 name: "test_suite".into(),
161 status: if exit_code == 0 {
162 TestStatus::Passed
163 } else {
164 TestStatus::Failed
165 },
166 duration: Duration::from_millis(0),
167 error: None,
168 });
169 }
170
171 vec![TestSuite {
172 name: "tests".into(),
173 tests,
174 }]
175}
176
177fn parse_zig_summary(output: &str) -> Option<(usize, usize)> {
178 for line in output.lines() {
179 let trimmed = line.trim();
180
181 if trimmed.starts_with("All ") && trimmed.contains("passed") {
183 let words: Vec<&str> = trimmed.split_whitespace().collect();
184 if words.len() >= 2 {
185 let count: usize = words[1].parse().ok()?;
186 return Some((count, 0));
187 }
188 }
189
190 if trimmed.contains("passed") && trimmed.contains("failed") {
192 let mut passed = 0usize;
193 let mut failed = 0usize;
194 for part in trimmed.split(';') {
195 let part = part.trim().trim_end_matches('.');
196 let words: Vec<&str> = part.split_whitespace().collect();
197 if words.len() >= 2 {
198 let count: usize = words[0].parse().unwrap_or(0);
199 if words[1].starts_with("passed") {
200 passed = count;
201 } else if words[1].starts_with("failed") {
202 failed = count;
203 }
204 }
205 }
206 return Some((passed, failed));
207 }
208 }
209 None
210}
211
212fn parse_zig_duration(output: &str) -> Option<Duration> {
213 for line in output.lines() {
216 if let Some(idx) = line.find("time:") {
217 let after = &line[idx + 5..];
218 let num_str: String = after
219 .trim()
220 .chars()
221 .take_while(|c| c.is_ascii_digit() || *c == '.')
222 .collect();
223 if let Ok(secs) = num_str.parse::<f64>() {
224 return Some(duration_from_secs_safe(secs));
225 }
226 }
227 }
228 None
229}
230
231#[derive(Debug, Clone)]
233struct ZigTestFailure {
234 test_name: String,
236 message: String,
238 location: Option<String>,
240}
241
242fn parse_zig_failures(output: &str) -> Vec<ZigTestFailure> {
265 let mut failures = Vec::new();
266 let lines: Vec<&str> = output.lines().collect();
267 let mut i = 0;
268
269 while i < lines.len() {
270 let trimmed = lines[i].trim();
271
272 if trimmed.starts_with("Test [") && trimmed.ends_with("FAIL") {
274 let test_name = extract_zig_test_name(trimmed);
275
276 let mut error_lines = Vec::new();
278 let mut location = None;
279 i += 1;
280
281 while i < lines.len() {
282 let line = lines[i].trim();
283
284 if line.starts_with("Test [")
286 || line.contains("passed")
287 || line.is_empty() && error_lines.len() > 3
288 {
289 break;
290 }
291
292 if !line.is_empty() {
293 if location.is_none() && is_zig_source_location(line) {
295 location = Some(extract_zig_location(line));
296 }
297
298 error_lines.push(line.to_string());
299 }
300
301 i += 1;
302 }
303
304 let message = if error_lines.is_empty() {
305 "Test failed".to_string()
306 } else {
307 find_zig_error_message(&error_lines)
309 };
310
311 failures.push(ZigTestFailure {
312 test_name,
313 message: truncate_zig_message(&message, 500),
314 location,
315 });
316 continue;
317 }
318
319 if is_zig_compile_error(trimmed) {
321 let (location, message) = parse_zig_compile_error(trimmed);
322 failures.push(ZigTestFailure {
323 test_name: "compile_error".into(),
324 message: truncate_zig_message(&message, 500),
325 location: Some(location),
326 });
327 }
328
329 if trimmed.contains("panic:") && !trimmed.starts_with("Test [") {
331 let message = trimmed
332 .split("panic:")
333 .nth(1)
334 .unwrap_or(trimmed)
335 .trim()
336 .to_string();
337
338 let mut location = None;
340 let mut j = i + 1;
341 while j < lines.len() && j < i + 5 {
342 if is_zig_source_location(lines[j].trim()) {
343 location = Some(extract_zig_location(lines[j].trim()));
344 break;
345 }
346 j += 1;
347 }
348
349 failures.push(ZigTestFailure {
350 test_name: "panic".into(),
351 message: truncate_zig_message(&message, 500),
352 location,
353 });
354 }
355
356 i += 1;
357 }
358
359 failures
360}
361
362fn extract_zig_test_name(line: &str) -> String {
364 if let Some(bracket_end) = line.find("] ") {
365 let after = &line[bracket_end + 2..];
366 after
367 .rfind("...")
368 .map(|i| after[..i].trim())
369 .unwrap_or(after)
370 .to_string()
371 } else {
372 "unknown".into()
373 }
374}
375
376fn is_zig_source_location(line: &str) -> bool {
379 line.contains(".zig:") && {
380 let parts: Vec<&str> = line.splitn(4, ':').collect();
381 parts.len() >= 3 && parts[1].chars().all(|c| c.is_ascii_digit())
382 }
383}
384
385fn extract_zig_location(line: &str) -> String {
388 if let Some(zig_idx) = line.find(".zig:") {
390 let after_zig = &line[zig_idx + 5..]; let num_end = after_zig
393 .find(|c: char| !c.is_ascii_digit())
394 .unwrap_or(after_zig.len());
395 if num_end > 0 {
396 let after_line = &after_zig[num_end..];
397 if let Some(col_str) = after_line.strip_prefix(':') {
398 let col_end = col_str
400 .find(|c: char| !c.is_ascii_digit())
401 .unwrap_or(col_str.len());
402 if col_end > 0 {
403 return line[..zig_idx + 5 + num_end + 1 + col_end].to_string();
404 }
405 }
406 return line[..zig_idx + 5 + num_end].to_string();
407 }
408 }
409 line.to_string()
410}
411
412fn find_zig_error_message(lines: &[String]) -> String {
414 for line in lines {
416 let lower = line.to_lowercase();
417 if lower.contains("error:")
418 || lower.contains("panic:")
419 || lower.contains("unreachable")
420 || lower.contains("assertion")
421 || lower.contains("expected")
422 {
423 return line.clone();
424 }
425 }
426 for line in lines {
428 if !is_zig_source_location(line) && !line.trim().is_empty() {
429 return line.clone();
430 }
431 }
432 lines
433 .first()
434 .cloned()
435 .unwrap_or_else(|| "Test failed".to_string())
436}
437
438fn is_zig_compile_error(line: &str) -> bool {
440 line.contains(".zig:") && line.contains(": error:")
441}
442
443fn parse_zig_compile_error(line: &str) -> (String, String) {
446 if let Some(error_idx) = line.find(": error:") {
447 let location = line[..error_idx].to_string();
448 let message = line[error_idx + 8..].trim().to_string();
449 (location, message)
450 } else {
451 (line.to_string(), "compile error".to_string())
452 }
453}
454
455fn truncate_zig_message(msg: &str, max_len: usize) -> String {
457 if msg.len() <= max_len {
458 msg.to_string()
459 } else {
460 format!("{}...", &msg[..max_len])
461 }
462}
463
464fn enrich_with_errors(suites: &mut [TestSuite], failures: &[ZigTestFailure]) {
466 for suite in suites.iter_mut() {
467 for test in suite.tests.iter_mut() {
468 if test.status != TestStatus::Failed || test.error.is_some() {
469 continue;
470 }
471 if let Some(failure) = find_matching_zig_failure(&test.name, failures) {
472 test.error = Some(TestError {
473 message: failure.message.clone(),
474 location: failure.location.clone(),
475 });
476 }
477 }
478 }
479}
480
481fn find_matching_zig_failure<'a>(
483 test_name: &str,
484 failures: &'a [ZigTestFailure],
485) -> Option<&'a ZigTestFailure> {
486 for failure in failures {
487 if failure.test_name == test_name {
488 return Some(failure);
489 }
490 if test_name.contains(&failure.test_name) || failure.test_name.contains(test_name) {
492 return Some(failure);
493 }
494 }
495 if failures.len() == 1 {
496 return Some(&failures[0]);
497 }
498 None
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn detect_zig_project() {
507 let dir = tempfile::tempdir().unwrap();
508 std::fs::write(
509 dir.path().join("build.zig"),
510 "const std = @import(\"std\");\n",
511 )
512 .unwrap();
513 let adapter = ZigAdapter::new();
514 let det = adapter.detect(dir.path()).unwrap();
515 assert_eq!(det.language, "Zig");
516 assert_eq!(det.framework, "zig test");
517 }
518
519 #[test]
520 fn detect_no_zig() {
521 let dir = tempfile::tempdir().unwrap();
522 let adapter = ZigAdapter::new();
523 assert!(adapter.detect(dir.path()).is_none());
524 }
525
526 #[test]
527 fn parse_zig_detailed_output() {
528 let stdout = r#"
529Test [1/3] test.basic add... OK
530Test [2/3] test.advanced... OK
531Test [3/3] test.edge case... FAIL
5322 passed; 1 failed.
533"#;
534 let adapter = ZigAdapter::new();
535 let result = adapter.parse_output(stdout, "", 1);
536
537 assert_eq!(result.total_tests(), 3);
538 assert_eq!(result.total_passed(), 2);
539 assert_eq!(result.total_failed(), 1);
540 assert!(!result.is_success());
541 }
542
543 #[test]
544 fn parse_zig_all_pass() {
545 let stdout = r#"
546Test [1/2] test.add... OK
547Test [2/2] test.sub... OK
548All 2 tests passed.
549"#;
550 let adapter = ZigAdapter::new();
551 let result = adapter.parse_output(stdout, "", 0);
552
553 assert_eq!(result.total_tests(), 2);
554 assert_eq!(result.total_passed(), 2);
555 assert!(result.is_success());
556 }
557
558 #[test]
559 fn parse_zig_summary_only() {
560 let stdout = "All 5 tests passed.\n";
561 let adapter = ZigAdapter::new();
562 let result = adapter.parse_output(stdout, "", 0);
563
564 assert_eq!(result.total_tests(), 5);
565 assert_eq!(result.total_passed(), 5);
566 assert!(result.is_success());
567 }
568
569 #[test]
570 fn parse_zig_summary_with_failures() {
571 let stdout = "2 passed; 3 failed.\n";
572 let adapter = ZigAdapter::new();
573 let result = adapter.parse_output(stdout, "", 1);
574
575 assert_eq!(result.total_tests(), 5);
576 assert_eq!(result.total_passed(), 2);
577 assert_eq!(result.total_failed(), 3);
578 }
579
580 #[test]
581 fn parse_zig_empty_output() {
582 let adapter = ZigAdapter::new();
583 let result = adapter.parse_output("", "", 0);
584
585 assert_eq!(result.total_tests(), 1);
586 assert!(result.is_success());
587 }
588
589 #[test]
590 fn parse_zig_skipped_test() {
591 let stdout = "Test [1/2] test.basic... OK\nTest [2/2] test.skip... SKIP\n";
592 let adapter = ZigAdapter::new();
593 let result = adapter.parse_output(stdout, "", 0);
594
595 assert_eq!(result.total_tests(), 2);
596 assert_eq!(result.total_passed(), 1);
597 assert_eq!(result.total_skipped(), 1);
598 }
599
600 #[test]
601 fn parse_zig_failure_with_error_details() {
602 let stdout = r#"
603Test [1/2] test.basic... OK
604Test [2/2] test.edge case... FAIL
605/home/user/src/main.zig:42:5: 0x1234abcd in test.edge case (test)
606 unreachable
607/usr/lib/zig/std/debug.zig:100:0: in std.debug.panicImpl
608
6091 passed; 1 failed.
610"#;
611 let adapter = ZigAdapter::new();
612 let result = adapter.parse_output(stdout, "", 1);
613
614 assert_eq!(result.total_tests(), 2);
615 assert_eq!(result.total_failed(), 1);
616 let failed = result.suites[0]
617 .tests
618 .iter()
619 .find(|t| t.status == TestStatus::Failed)
620 .unwrap();
621 assert!(failed.error.is_some());
622 let err = failed.error.as_ref().unwrap();
623 assert!(err.location.is_some());
624 }
625
626 #[test]
627 fn parse_zig_failures_basic() {
628 let output = r#"
629Test [1/1] test.divide... FAIL
630/home/user/src/math.zig:10:12: 0x1234 in test.divide (test)
631 integer overflow
632"#;
633 let failures = parse_zig_failures(output);
634 assert_eq!(failures.len(), 1);
635 assert_eq!(failures[0].test_name, "test.divide");
636 assert!(failures[0].location.is_some());
637 }
638
639 #[test]
640 fn parse_zig_compile_error_test() {
641 let output = "src/main.zig:42:5: error: expected type 'u32', found 'i32'\n";
642 let failures = parse_zig_failures(output);
643 assert_eq!(failures.len(), 1);
644 assert_eq!(failures[0].test_name, "compile_error");
645 assert!(failures[0].message.contains("expected type"));
646 }
647
648 #[test]
649 fn parse_zig_panic_test() {
650 let output = r#"
651thread 12345 panic: integer overflow
652/home/user/src/math.zig:10:12: in test_fn
653"#;
654 let failures = parse_zig_failures(output);
655 assert_eq!(failures.len(), 1);
656 assert!(failures[0].message.contains("integer overflow"));
657 }
658
659 #[test]
660 fn extract_zig_test_name_test() {
661 assert_eq!(
662 extract_zig_test_name("Test [1/3] test.basic add... OK"),
663 "test.basic add"
664 );
665 assert_eq!(
666 extract_zig_test_name("Test [2/3] test.edge... FAIL"),
667 "test.edge"
668 );
669 }
670
671 #[test]
672 fn is_zig_source_location_test() {
673 assert!(is_zig_source_location(
674 "/home/user/src/main.zig:42:5: in test"
675 ));
676 assert!(is_zig_source_location("src/math.zig:10:12: error"));
677 assert!(!is_zig_source_location("not a location"));
678 assert!(!is_zig_source_location(
679 "some text.zig without colon numbers"
680 ));
681 }
682
683 #[test]
684 fn extract_zig_location_test() {
685 assert_eq!(
686 extract_zig_location("/home/user/src/main.zig:42:5: 0x1234 in test"),
687 "/home/user/src/main.zig:42:5"
688 );
689 assert_eq!(
690 extract_zig_location("src/math.zig:10:12: error: boom"),
691 "src/math.zig:10:12"
692 );
693 }
694
695 #[test]
696 fn find_zig_error_message_test() {
697 let lines = vec![
698 "/src/main.zig:42:5: 0x1234".into(),
699 "unreachable".into(),
700 "/lib/debug.zig:100:0: in something".into(),
701 ];
702 let msg = find_zig_error_message(&lines);
703 assert_eq!(msg, "unreachable");
704 }
705
706 #[test]
707 fn find_zig_error_message_with_error() {
708 let lines = vec![
709 "error: expected type 'u32'".into(),
710 "some other line".into(),
711 ];
712 let msg = find_zig_error_message(&lines);
713 assert!(msg.contains("error:"));
714 }
715
716 #[test]
717 fn is_zig_compile_error_test() {
718 assert!(is_zig_compile_error(
719 "src/main.zig:42:5: error: expected type"
720 ));
721 assert!(!is_zig_compile_error("not a compile error"));
722 }
723
724 #[test]
725 fn parse_zig_compile_error_line() {
726 let (loc, msg) =
727 parse_zig_compile_error("src/main.zig:42:5: error: expected type 'u32', found 'i32'");
728 assert_eq!(loc, "src/main.zig:42:5");
729 assert_eq!(msg, "expected type 'u32', found 'i32'");
730 }
731
732 #[test]
733 fn truncate_zig_message_test() {
734 assert_eq!(truncate_zig_message("short", 100), "short");
735 let long = "z".repeat(600);
736 let truncated = truncate_zig_message(&long, 500);
737 assert!(truncated.ends_with("..."));
738 }
739
740 #[test]
741 fn enrich_with_errors_test() {
742 let mut suites = vec![TestSuite {
743 name: "tests".into(),
744 tests: vec![
745 TestCase {
746 name: "test.add".into(),
747 status: TestStatus::Passed,
748 duration: Duration::from_millis(0),
749 error: None,
750 },
751 TestCase {
752 name: "test.edge".into(),
753 status: TestStatus::Failed,
754 duration: Duration::from_millis(0),
755 error: None,
756 },
757 ],
758 }];
759 let failures = vec![ZigTestFailure {
760 test_name: "test.edge".into(),
761 message: "unreachable".into(),
762 location: Some("/src/main.zig:42:5".into()),
763 }];
764 enrich_with_errors(&mut suites, &failures);
765 assert!(suites[0].tests[0].error.is_none());
766 let err = suites[0].tests[1].error.as_ref().unwrap();
767 assert_eq!(err.message, "unreachable");
768 assert!(err.location.as_ref().unwrap().contains("main.zig:42:5"));
769 }
770
771 #[test]
772 fn parse_zig_test_integration() {
773 let stdout = r#"
774Test [1/3] test.basic add... OK
775Test [2/3] test.advanced... OK
776Test [3/3] test.edge case... FAIL
777/src/main.zig:42:5: 0x1234 in test.edge case
778 error: assertion failed
7792 passed; 1 failed.
780"#;
781 let adapter = ZigAdapter::new();
782 let result = adapter.parse_output(stdout, "", 1);
783
784 assert_eq!(result.total_tests(), 3);
785 assert_eq!(result.total_passed(), 2);
786 assert_eq!(result.total_failed(), 1);
787 let failed = result.suites[0]
788 .tests
789 .iter()
790 .find(|t| t.status == TestStatus::Failed)
791 .unwrap();
792 assert!(failed.error.is_some());
793 }
794}