Skip to main content

testx/adapters/
zig.rs

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        // Enrich with error details from zig test output
69        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
84/// Parse Zig test output.
85///
86/// Format:
87/// ```text
88/// Test [1/3] test.basic add... OK
89/// Test [2/3] test.advanced... OK
90/// Test [3/3] test.edge case... FAIL
91/// 2 passed; 1 failed.
92/// ```
93/// Or:
94/// ```text
95/// All 3 tests passed.
96/// ```
97fn 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        // "Test [1/3] test.basic add... OK"
104        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            // Extract test name: between "] " and "..."
116            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    // Fallback: parse summary
137    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        // "All 3 tests passed."
182        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        // "2 passed; 1 failed."
191        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    // Zig doesn't print total duration by default, but may print per-test timing
214    // Some Zig setups show "time: 0.001s"
215    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/// A parsed failure from Zig test output.
232#[derive(Debug, Clone)]
233struct ZigTestFailure {
234    /// Test name
235    test_name: String,
236    /// Error message (panic message or assertion error)
237    message: String,
238    /// Source location if available
239    location: Option<String>,
240}
241
242/// Parse Zig test failure details.
243///
244/// Zig test failures produce output like:
245/// ```text
246/// Test [3/3] test.edge case... FAIL
247/// /home/user/src/main.zig:42:5: 0x1234abcd in test.edge case (test)
248///     unreachable
249/// /usr/lib/zig/std/debug.zig:100:0: ...
250/// error: test.edge case... FAIL
251/// ```
252///
253/// Or panic messages:
254/// ```text
255/// Test [2/3] test.divide... thread 12345 panic:
256/// integer overflow
257/// /home/user/src/math.zig:10:12: 0x1234 in test.divide (test)
258/// ```
259///
260/// Or compile errors:
261/// ```text
262/// src/main.zig:42:5: error: expected type 'u32', found 'i32'
263/// ```
264fn 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        // "Test [N/M] test.name... FAIL"
273        if trimmed.starts_with("Test [") && trimmed.ends_with("FAIL") {
274            let test_name = extract_zig_test_name(trimmed);
275
276            // Collect following error lines
277            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                // Stop at next test or summary
285                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                    // Extract source location
294                    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                // Use the most informative line as the message
308                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        // Compile error: "src/main.zig:42:5: error: ..."
320        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        // Panic: "panic: ..." or "thread N panic:"
330        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            // Look ahead for the source location
339            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
362/// Extract test name from "Test [N/M] test.name... FAIL"
363fn 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
376/// Check if a line is a Zig source location.
377/// "/path/to/file.zig:42:5: ..."
378fn 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
385/// Extract location from a Zig source line.
386/// "/path/to/file.zig:42:5: 0x1234 in test.name" -> "/path/to/file.zig:42:5"
387fn extract_zig_location(line: &str) -> String {
388    // Find the first two colons after .zig
389    if let Some(zig_idx) = line.find(".zig:") {
390        let after_zig = &line[zig_idx + 5..]; // after ".zig:"
391        // Find the line number (digits)
392        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                // Also include column number
399                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
412/// Find the most informative error message from a list of lines.
413fn find_zig_error_message(lines: &[String]) -> String {
414    // Prefer lines with "error:", "panic:", "unreachable", "assertion"
415    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    // Fall back to first non-empty, non-location line
427    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
438/// Check if a line is a Zig compile error.
439fn is_zig_compile_error(line: &str) -> bool {
440    line.contains(".zig:") && line.contains(": error:")
441}
442
443/// Parse a Zig compile error line.
444/// "src/main.zig:42:5: error: expected type 'u32', found 'i32'"
445fn 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
455/// Truncate a message.
456fn 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
464/// Enrich test cases with failure details.
465fn 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
481/// Find a matching failure for a test name.
482fn 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        // Partial match
491        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}