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::{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        // zig test uses --test-filter
72        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        // Enrich with error details from zig test output
81        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
96/// Parse Zig test output.
97///
98/// Format:
99/// ```text
100/// Test [1/3] test.basic add... OK
101/// Test [2/3] test.advanced... OK
102/// Test [3/3] test.edge case... FAIL
103/// 2 passed; 1 failed.
104/// ```
105/// Or:
106/// ```text
107/// All 3 tests passed.
108/// ```
109fn 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        // "Test [1/3] test.basic add... OK"
116        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            // Extract test name: between "] " and "..."
128            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    // Fallback: parse summary
149    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        // "All 3 tests passed."
194        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        // "2 passed; 1 failed."
203        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    // Zig doesn't print total duration by default, but may print per-test timing
226    // Some Zig setups show "time: 0.001s"
227    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/// A parsed failure from Zig test output.
244#[derive(Debug, Clone)]
245struct ZigTestFailure {
246    /// Test name
247    test_name: String,
248    /// Error message (panic message or assertion error)
249    message: String,
250    /// Source location if available
251    location: Option<String>,
252}
253
254/// Parse Zig test failure details.
255///
256/// Zig test failures produce output like:
257/// ```text
258/// Test [3/3] test.edge case... FAIL
259/// /home/user/src/main.zig:42:5: 0x1234abcd in test.edge case (test)
260///     unreachable
261/// /usr/lib/zig/std/debug.zig:100:0: ...
262/// error: test.edge case... FAIL
263/// ```
264///
265/// Or panic messages:
266/// ```text
267/// Test [2/3] test.divide... thread 12345 panic:
268/// integer overflow
269/// /home/user/src/math.zig:10:12: 0x1234 in test.divide (test)
270/// ```
271///
272/// Or compile errors:
273/// ```text
274/// src/main.zig:42:5: error: expected type 'u32', found 'i32'
275/// ```
276fn 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        // "Test [N/M] test.name... FAIL"
285        if trimmed.starts_with("Test [") && trimmed.ends_with("FAIL") {
286            let test_name = extract_zig_test_name(trimmed);
287
288            // Collect following error lines
289            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                // Stop at next test or summary
297                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                    // Extract source location
306                    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                // Use the most informative line as the message
320                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        // Compile error: "src/main.zig:42:5: error: ..."
332        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        // Panic: "panic: ..." or "thread N panic:"
342        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            // Look ahead for the source location
351            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
374/// Extract test name from "Test [N/M] test.name... FAIL"
375fn 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
388/// Check if a line is a Zig source location.
389/// "/path/to/file.zig:42:5: ..."
390fn 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
397/// Extract location from a Zig source line.
398/// "/path/to/file.zig:42:5: 0x1234 in test.name" -> "/path/to/file.zig:42:5"
399fn extract_zig_location(line: &str) -> String {
400    // Find the first two colons after .zig
401    if let Some(zig_idx) = line.find(".zig:") {
402        let after_zig = &line[zig_idx + 5..]; // after ".zig:"
403        // Find the line number (digits)
404        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                // Also include column number
411                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
424/// Find the most informative error message from a list of lines.
425fn find_zig_error_message(lines: &[String]) -> String {
426    // Prefer lines with "error:", "panic:", "unreachable", "assertion"
427    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    // Fall back to first non-empty, non-location line
439    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
450/// Check if a line is a Zig compile error.
451fn is_zig_compile_error(line: &str) -> bool {
452    line.contains(".zig:") && line.contains(": error:")
453}
454
455/// Parse a Zig compile error line.
456/// "src/main.zig:42:5: error: expected type 'u32', found 'i32'"
457fn 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
467/// Enrich test cases with failure details.
468fn 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
484/// Find a matching failure for a test name.
485fn 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        // Partial match
494        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}