Skip to main content

testx/adapters/
go.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, ensure_non_empty};
8use super::{
9    ConfidenceScore, DetectionResult, TestAdapter, TestCase, TestRunResult, TestStatus, TestSuite,
10};
11
12pub struct GoAdapter;
13
14impl Default for GoAdapter {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl GoAdapter {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl TestAdapter for GoAdapter {
27    fn name(&self) -> &str {
28        "Go"
29    }
30
31    fn check_runner(&self) -> Option<String> {
32        if which::which("go").is_err() {
33            Some("go".into())
34        } else {
35            None
36        }
37    }
38
39    fn detect(&self, project_dir: &Path) -> Option<DetectionResult> {
40        if !project_dir.join("go.mod").exists() {
41            return None;
42        }
43
44        // Check for test files
45        let has_tests = std::fs::read_dir(project_dir).ok()?.any(|entry| {
46            entry
47                .ok()
48                .is_some_and(|e| e.file_name().to_string_lossy().ends_with("_test.go"))
49        }) || find_test_files_recursive(project_dir);
50
51        if !has_tests {
52            return None;
53        }
54
55        let confidence = ConfidenceScore::base(0.50)
56            .signal(0.20, true) // test files already confirmed above
57            .signal(0.10, project_dir.join("go.sum").exists())
58            .signal(0.10, which::which("go").is_ok())
59            .finish();
60
61        Some(DetectionResult {
62            language: "Go".into(),
63            framework: "go test".into(),
64            confidence,
65        })
66    }
67
68    fn build_command(&self, project_dir: &Path, extra_args: &[String]) -> Result<Command> {
69        let mut cmd = Command::new("go");
70        cmd.arg("test");
71
72        if extra_args.is_empty() {
73            cmd.arg("-v"); // verbose for parsing individual tests
74            cmd.arg("./..."); // all packages
75        }
76
77        for arg in extra_args {
78            cmd.arg(arg);
79        }
80
81        cmd.current_dir(project_dir);
82        Ok(cmd)
83    }
84
85    fn filter_args(&self, pattern: &str) -> Vec<String> {
86        vec!["-run".to_string(), pattern.to_string()]
87    }
88
89    fn parse_output(&self, stdout: &str, stderr: &str, exit_code: i32) -> TestRunResult {
90        let combined = combined_output(stdout, stderr);
91        let failure_messages = parse_go_failures(&combined);
92        let mut suites: Vec<TestSuite> = Vec::new();
93        let mut current_pkg = String::new();
94        let mut current_tests: Vec<TestCase> = Vec::new();
95
96        for line in combined.lines() {
97            let trimmed = line.trim();
98
99            // Go test verbose output:
100            // "=== RUN   TestFoo"
101            // "--- PASS: TestFoo (0.00s)"
102            // "--- FAIL: TestFoo (0.05s)"
103            // "--- SKIP: TestFoo (0.00s)"
104
105            if trimmed.starts_with("--- PASS:")
106                || trimmed.starts_with("--- FAIL:")
107                || trimmed.starts_with("--- SKIP:")
108            {
109                let status = if trimmed.starts_with("--- PASS:") {
110                    TestStatus::Passed
111                } else if trimmed.starts_with("--- FAIL:") {
112                    TestStatus::Failed
113                } else {
114                    TestStatus::Skipped
115                };
116
117                let rest = trimmed.split(':').nth(1).unwrap_or("").trim();
118                let parts: Vec<&str> = rest.split_whitespace().collect();
119                let name = parts.first().unwrap_or(&"unknown").to_string();
120                let duration = parts
121                    .get(1)
122                    .and_then(|s| {
123                        let s = s.trim_matches(|c| c == '(' || c == ')' || c == 's');
124                        s.parse::<f64>().ok()
125                    })
126                    .map(duration_from_secs_safe)
127                    .unwrap_or(Duration::from_millis(0));
128
129                let error = if status == TestStatus::Failed {
130                    failure_messages
131                        .get(name.as_str())
132                        .map(|msg| super::TestError {
133                            message: msg.clone(),
134                            location: None,
135                        })
136                } else {
137                    None
138                };
139
140                current_tests.push(TestCase {
141                    name,
142                    status,
143                    duration,
144                    error,
145                });
146                continue;
147            }
148
149            // Package result line: "ok  	github.com/user/pkg	0.005s"
150            // or: "FAIL	github.com/user/pkg	0.005s"
151            if (trimmed.starts_with("ok") || trimmed.starts_with("FAIL")) && trimmed.contains('\t')
152            {
153                // Flush current tests to this new package suite
154                let parts: Vec<&str> = trimmed.split('\t').collect();
155                let pkg_name = parts.get(1).unwrap_or(&"").trim().to_string();
156
157                if !current_tests.is_empty() {
158                    suites.push(TestSuite {
159                        name: if current_pkg.is_empty() {
160                            pkg_name.clone()
161                        } else {
162                            current_pkg.clone()
163                        },
164                        tests: std::mem::take(&mut current_tests),
165                    });
166                }
167                current_pkg = pkg_name;
168            }
169        }
170
171        // Flush remaining
172        if !current_tests.is_empty() {
173            let name = if current_pkg.is_empty() {
174                "tests".into()
175            } else {
176                current_pkg
177            };
178            suites.push(TestSuite {
179                name,
180                tests: current_tests,
181            });
182        }
183
184        ensure_non_empty(&mut suites, exit_code, "tests");
185
186        // Parse total duration from last "ok" or "FAIL" line
187        let duration = parse_go_total_duration(&combined).unwrap_or(Duration::from_secs(0));
188
189        TestRunResult {
190            suites,
191            duration,
192            raw_exit_code: exit_code,
193        }
194    }
195}
196
197/// Maximum recursion depth for Go test file discovery.
198const MAX_GO_SCAN_DEPTH: usize = 20;
199
200fn find_test_files_recursive(dir: &Path) -> bool {
201    find_test_files_recursive_inner(dir, 0)
202}
203
204fn find_test_files_recursive_inner(dir: &Path, depth: usize) -> bool {
205    if depth > MAX_GO_SCAN_DEPTH {
206        return false;
207    }
208    let Ok(entries) = std::fs::read_dir(dir) else {
209        return false;
210    };
211    for entry in entries.flatten() {
212        let path = entry.path();
213        if path.is_file() && path.to_string_lossy().ends_with("_test.go") {
214            return true;
215        }
216        if path.is_dir() {
217            let name = path.file_name().unwrap_or_default().to_string_lossy();
218            // Skip hidden dirs and vendor
219            if !name.starts_with('.')
220                && name != "vendor"
221                && name != "node_modules"
222                && find_test_files_recursive_inner(&path, depth + 1)
223            {
224                return true;
225            }
226        }
227    }
228    false
229}
230
231/// Parse go test failure output to extract error messages per test.
232/// Go test verbose output shows errors as indented lines between `=== RUN` and `--- FAIL:`:
233/// ```text
234/// === RUN   TestDivide
235///     math_test.go:15: expected 2, got 0
236/// --- FAIL: TestDivide (0.00s)
237/// ```
238fn parse_go_failures(output: &str) -> std::collections::HashMap<String, String> {
239    let mut failures = std::collections::HashMap::new();
240    let lines: Vec<&str> = output.lines().collect();
241
242    let mut i = 0;
243    while i < lines.len() {
244        let trimmed = lines[i].trim();
245        // Match "=== RUN   TestName"
246        if let Some(rest) = trimmed.strip_prefix("=== RUN") {
247            let test_name = rest.trim().to_string();
248            if !test_name.is_empty() {
249                let mut msg_lines = Vec::new();
250                i += 1;
251                while i < lines.len() {
252                    let l = lines[i].trim();
253                    if l.starts_with("--- FAIL:")
254                        || l.starts_with("--- PASS:")
255                        || l.starts_with("--- SKIP:")
256                        || l.starts_with("=== RUN")
257                    {
258                        break;
259                    }
260                    if !l.is_empty() {
261                        msg_lines.push(l.to_string());
262                    }
263                    i += 1;
264                }
265                // Only store if this test actually failed
266                if i < lines.len()
267                    && lines[i].trim().starts_with("--- FAIL:")
268                    && !msg_lines.is_empty()
269                {
270                    failures.insert(test_name, msg_lines.join(" | "));
271                }
272                continue;
273            }
274        }
275        i += 1;
276    }
277    failures
278}
279
280fn parse_go_total_duration(output: &str) -> Option<Duration> {
281    let mut total = Duration::from_secs(0);
282    let mut found = false;
283    for line in output.lines() {
284        let trimmed = line.trim();
285        if (trimmed.starts_with("ok") || trimmed.starts_with("FAIL")) && trimmed.contains('\t') {
286            let parts: Vec<&str> = trimmed.split('\t').collect();
287            if let Some(time_str) = parts.last() {
288                let time_str = time_str.trim().trim_end_matches('s');
289                if let Ok(secs) = time_str.parse::<f64>() {
290                    total += duration_from_secs_safe(secs);
291                    found = true;
292                }
293            }
294        }
295    }
296    if found { Some(total) } else { None }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn parse_go_verbose_output() {
305        let stdout = r#"
306=== RUN   TestAdd
307--- PASS: TestAdd (0.00s)
308=== RUN   TestSubtract
309--- PASS: TestSubtract (0.00s)
310=== RUN   TestDivide
311    math_test.go:15: expected 2, got 0
312--- FAIL: TestDivide (0.05s)
313FAIL
314FAIL	github.com/user/mathpkg	0.052s
315"#;
316        let adapter = GoAdapter::new();
317        let result = adapter.parse_output(stdout, "", 1);
318
319        assert_eq!(result.total_tests(), 3);
320        assert_eq!(result.total_passed(), 2);
321        assert_eq!(result.total_failed(), 1);
322        assert!(!result.is_success());
323
324        // Verify error message was captured
325        let failed = &result.suites[0].failures();
326        assert_eq!(failed.len(), 1);
327        assert!(failed[0].error.is_some());
328        assert!(
329            failed[0]
330                .error
331                .as_ref()
332                .unwrap()
333                .message
334                .contains("expected 2, got 0")
335        );
336    }
337
338    #[test]
339    fn parse_go_all_pass() {
340        let stdout = r#"
341=== RUN   TestHello
342--- PASS: TestHello (0.00s)
343=== RUN   TestWorld
344--- PASS: TestWorld (0.01s)
345ok  	github.com/user/pkg	0.015s
346"#;
347        let adapter = GoAdapter::new();
348        let result = adapter.parse_output(stdout, "", 0);
349
350        assert_eq!(result.total_passed(), 2);
351        assert_eq!(result.total_failed(), 0);
352        assert!(result.is_success());
353    }
354
355    #[test]
356    fn parse_go_skipped() {
357        let stdout = r#"
358=== RUN   TestFoo
359--- SKIP: TestFoo (0.00s)
360ok  	github.com/user/pkg	0.001s
361"#;
362        let adapter = GoAdapter::new();
363        let result = adapter.parse_output(stdout, "", 0);
364
365        assert_eq!(result.total_skipped(), 1);
366        assert!(result.is_success());
367    }
368
369    #[test]
370    fn parse_go_multiple_packages() {
371        let stdout = r#"
372=== RUN   TestA
373--- PASS: TestA (0.00s)
374ok  	github.com/user/pkg/a	0.005s
375=== RUN   TestB
376--- FAIL: TestB (0.02s)
377FAIL	github.com/user/pkg/b	0.025s
378"#;
379        let adapter = GoAdapter::new();
380        let result = adapter.parse_output(stdout, "", 1);
381
382        assert_eq!(result.total_tests(), 2);
383        assert_eq!(result.total_passed(), 1);
384        assert_eq!(result.total_failed(), 1);
385    }
386
387    #[test]
388    fn parse_go_duration() {
389        let output = "ok  \tgithub.com/user/pkg\t1.234s\n";
390        let dur = parse_go_total_duration(output).unwrap();
391        assert_eq!(dur, Duration::from_millis(1234));
392    }
393
394    #[test]
395    fn detect_go_project() {
396        let dir = tempfile::tempdir().unwrap();
397        std::fs::write(dir.path().join("go.mod"), "module example.com/test\n").unwrap();
398        std::fs::write(dir.path().join("main_test.go"), "package main\n").unwrap();
399        let adapter = GoAdapter::new();
400        let det = adapter.detect(dir.path()).unwrap();
401        assert_eq!(det.framework, "go test");
402    }
403
404    #[test]
405    fn detect_no_go() {
406        let dir = tempfile::tempdir().unwrap();
407        let adapter = GoAdapter::new();
408        assert!(adapter.detect(dir.path()).is_none());
409    }
410
411    #[test]
412    fn parse_go_empty_output() {
413        let adapter = GoAdapter::new();
414        let result = adapter.parse_output("", "", 0);
415
416        assert_eq!(result.total_tests(), 1);
417        assert!(result.is_success());
418    }
419
420    #[test]
421    fn parse_go_subtests() {
422        let stdout = r#"
423=== RUN   TestMath
424=== RUN   TestMath/Add
425--- PASS: TestMath/Add (0.00s)
426=== RUN   TestMath/Subtract
427--- PASS: TestMath/Subtract (0.00s)
428--- PASS: TestMath (0.00s)
429ok  	github.com/user/pkg	0.003s
430"#;
431        let adapter = GoAdapter::new();
432        let result = adapter.parse_output(stdout, "", 0);
433
434        // Should capture parent and subtests
435        assert!(result.total_passed() >= 2);
436        assert!(result.is_success());
437    }
438
439    #[test]
440    fn parse_go_panic_output() {
441        let stdout = r#"
442=== RUN   TestCrash
443--- FAIL: TestCrash (0.00s)
444panic: runtime error: index out of range [recovered]
445FAIL	github.com/user/pkg	0.001s
446"#;
447        let adapter = GoAdapter::new();
448        let result = adapter.parse_output(stdout, "", 1);
449
450        assert_eq!(result.total_failed(), 1);
451        assert!(!result.is_success());
452    }
453
454    #[test]
455    fn parse_go_no_test_files() {
456        let stdout = "?   \tgithub.com/user/pkg\t[no test files]\n";
457        let adapter = GoAdapter::new();
458        let result = adapter.parse_output(stdout, "", 0);
459
460        // Should create a synthetic passing suite
461        assert!(result.is_success());
462    }
463
464    #[test]
465    fn detect_go_needs_test_files() {
466        let dir = tempfile::tempdir().unwrap();
467        // go.mod but no *_test.go files
468        std::fs::write(dir.path().join("go.mod"), "module example.com/test\n").unwrap();
469        std::fs::write(dir.path().join("main.go"), "package main\n").unwrap();
470        let adapter = GoAdapter::new();
471        assert!(adapter.detect(dir.path()).is_none());
472    }
473}