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