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 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) .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"); cmd.arg("./..."); }
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 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 if (trimmed.starts_with("ok") || trimmed.starts_with("FAIL")) && trimmed.contains('\t')
152 {
153 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 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 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
197const 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 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
231fn 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 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 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 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 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 assert!(result.is_success());
462 }
463
464 #[test]
465 fn detect_go_needs_test_files() {
466 let dir = tempfile::tempdir().unwrap();
467 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}