Skip to main content

affected_core/resolvers/
go.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::Path;
4use std::process::Command;
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct GoResolver;
10impl super::sealed::Sealed for GoResolver {}
11
12impl Resolver for GoResolver {
13    fn ecosystem(&self) -> Ecosystem {
14        Ecosystem::Go
15    }
16
17    fn detect(&self, root: &Path) -> bool {
18        root.join("go.work").exists() || root.join("go.mod").exists()
19    }
20
21    fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
22        if root.join("go.work").exists() {
23            self.resolve_workspace(root)
24        } else {
25            self.resolve_single_module(root)
26        }
27    }
28
29    fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
30        file_to_package(graph, file)
31    }
32
33    fn test_command(&self, package_id: &PackageId) -> Vec<String> {
34        vec![
35            "go".into(),
36            "test".into(),
37            format!("./{}/...", package_id.0),
38        ]
39    }
40}
41
42impl GoResolver {
43    /// Resolve a Go workspace (go.work file).
44    fn resolve_workspace(&self, root: &Path) -> Result<ProjectGraph> {
45        let go_work =
46            std::fs::read_to_string(root.join("go.work")).context("Failed to read go.work")?;
47
48        // Parse `use` directives from go.work
49        let module_dirs = parse_go_work_uses(&go_work);
50
51        let mut packages = HashMap::new();
52        let mut module_path_to_id = HashMap::new();
53
54        for dir_str in &module_dirs {
55            let dir = root.join(dir_str);
56            let go_mod_path = dir.join("go.mod");
57            if !go_mod_path.exists() {
58                continue;
59            }
60
61            let go_mod = std::fs::read_to_string(&go_mod_path)
62                .with_context(|| format!("Failed to read {}", go_mod_path.display()))?;
63
64            let module_path = parse_go_mod_module(&go_mod)
65                .with_context(|| format!("No module directive in {}", go_mod_path.display()))?;
66
67            // Use the directory name as the PackageId for simplicity
68            let pkg_id = PackageId(dir_str.clone());
69            module_path_to_id.insert(module_path.clone(), pkg_id.clone());
70
71            packages.insert(
72                pkg_id.clone(),
73                Package {
74                    id: pkg_id,
75                    name: module_path,
76                    version: None,
77                    path: dir.clone(),
78                    manifest_path: go_mod_path,
79                },
80            );
81        }
82
83        // Run `go mod graph` to get dependency edges
84        let edges = self.parse_mod_graph(root, &module_path_to_id)?;
85
86        Ok(ProjectGraph {
87            packages,
88            edges,
89            root: root.to_path_buf(),
90        })
91    }
92
93    /// Resolve a single Go module (just go.mod, no workspace).
94    fn resolve_single_module(&self, root: &Path) -> Result<ProjectGraph> {
95        let go_mod =
96            std::fs::read_to_string(root.join("go.mod")).context("Failed to read go.mod")?;
97
98        let module_path =
99            parse_go_mod_module(&go_mod).context("No module directive found in go.mod")?;
100
101        let pkg_id = PackageId(".".to_string());
102        let mut packages = HashMap::new();
103        packages.insert(
104            pkg_id.clone(),
105            Package {
106                id: pkg_id,
107                name: module_path,
108                version: None,
109                path: root.to_path_buf(),
110                manifest_path: root.join("go.mod"),
111            },
112        );
113
114        // Single module has no internal dependency edges
115        Ok(ProjectGraph {
116            packages,
117            edges: vec![],
118            root: root.to_path_buf(),
119        })
120    }
121
122    /// Parse `go mod graph` output and filter to workspace modules.
123    fn parse_mod_graph(
124        &self,
125        root: &Path,
126        module_path_to_id: &HashMap<String, PackageId>,
127    ) -> Result<Vec<(PackageId, PackageId)>> {
128        let output = Command::new("go")
129            .args(["mod", "graph"])
130            .current_dir(root)
131            .output()
132            .context("Failed to run 'go mod graph'. Is Go installed?")?;
133
134        if !output.status.success() {
135            // Non-fatal: just return no edges
136            return Ok(vec![]);
137        }
138
139        let stdout = String::from_utf8_lossy(&output.stdout);
140        let mut edges = Vec::new();
141
142        for line in stdout.lines() {
143            let parts: Vec<&str> = line.split_whitespace().collect();
144            if parts.len() != 2 {
145                continue;
146            }
147
148            // Strip version: "module@v1.0.0" -> "module"
149            let from_mod = parts[0].split('@').next().unwrap_or(parts[0]);
150            let to_mod = parts[1].split('@').next().unwrap_or(parts[1]);
151
152            if let (Some(from_id), Some(to_id)) = (
153                module_path_to_id.get(from_mod),
154                module_path_to_id.get(to_mod),
155            ) {
156                edges.push((from_id.clone(), to_id.clone()));
157            }
158        }
159
160        Ok(edges)
161    }
162}
163
164/// Parse `use` directives from go.work content.
165fn parse_go_work_uses(content: &str) -> Vec<String> {
166    let mut uses = Vec::new();
167    let mut in_use_block = false;
168
169    for line in content.lines() {
170        let trimmed = line.trim();
171
172        if trimmed.starts_with("use ") && !trimmed.contains('(') {
173            // Single-line use: `use ./path`
174            let path = trimmed
175                .trim_start_matches("use ")
176                .trim()
177                .trim_matches('.')
178                .trim_start_matches('/')
179                .to_string();
180            if !path.is_empty() {
181                uses.push(path);
182            } else {
183                // Handle `use ./path` -> just `path`
184                let raw = trimmed.trim_start_matches("use ").trim();
185                let cleaned = raw.trim_start_matches("./").to_string();
186                if !cleaned.is_empty() {
187                    uses.push(cleaned);
188                }
189            }
190            continue;
191        }
192
193        if trimmed == "use (" {
194            in_use_block = true;
195            continue;
196        }
197
198        if in_use_block {
199            if trimmed == ")" {
200                in_use_block = false;
201                continue;
202            }
203            let path = trimmed.trim_start_matches("./").to_string();
204            if !path.is_empty() {
205                uses.push(path);
206            }
207        }
208    }
209
210    uses
211}
212
213/// Parse the `module` directive from go.mod content.
214fn parse_go_mod_module(content: &str) -> Option<String> {
215    for line in content.lines() {
216        let trimmed = line.trim();
217        if trimmed.starts_with("module ") {
218            return Some(trimmed.trim_start_matches("module ").trim().to_string());
219        }
220    }
221    None
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_detect_go_work() {
230        let dir = tempfile::tempdir().unwrap();
231        std::fs::write(dir.path().join("go.work"), "go 1.21\n").unwrap();
232        assert!(GoResolver.detect(dir.path()));
233    }
234
235    #[test]
236    fn test_detect_go_mod() {
237        let dir = tempfile::tempdir().unwrap();
238        std::fs::write(dir.path().join("go.mod"), "module example.com/foo\n").unwrap();
239        assert!(GoResolver.detect(dir.path()));
240    }
241
242    #[test]
243    fn test_detect_no_go_files() {
244        let dir = tempfile::tempdir().unwrap();
245        assert!(!GoResolver.detect(dir.path()));
246    }
247
248    #[test]
249    fn test_parse_go_mod_module_basic() {
250        let content = "module example.com/mymod\n\ngo 1.21\n";
251        assert_eq!(
252            parse_go_mod_module(content),
253            Some("example.com/mymod".into())
254        );
255    }
256
257    #[test]
258    fn test_parse_go_mod_module_with_whitespace() {
259        let content = "  module   example.com/foo  \n";
260        assert_eq!(parse_go_mod_module(content), Some("example.com/foo".into()));
261    }
262
263    #[test]
264    fn test_parse_go_mod_module_not_found() {
265        let content = "go 1.21\nrequire example.com/bar v1.0.0\n";
266        assert!(parse_go_mod_module(content).is_none());
267    }
268
269    #[test]
270    fn test_parse_go_work_uses_block() {
271        let content = "go 1.21\n\nuse (\n\t./svc-a\n\t./svc-b\n)\n";
272        let uses = parse_go_work_uses(content);
273        assert_eq!(uses, vec!["svc-a", "svc-b"]);
274    }
275
276    #[test]
277    fn test_parse_go_work_uses_single_line() {
278        let content = "go 1.21\nuse ./mymod\n";
279        let uses = parse_go_work_uses(content);
280        assert_eq!(uses, vec!["mymod"]);
281    }
282
283    #[test]
284    fn test_parse_go_work_uses_empty() {
285        let content = "go 1.21\n";
286        let uses = parse_go_work_uses(content);
287        assert!(uses.is_empty());
288    }
289
290    #[test]
291    fn test_parse_go_work_uses_mixed() {
292        let content = "go 1.21\n\nuse ./standalone\n\nuse (\n\t./a\n\t./b\n)\n";
293        let uses = parse_go_work_uses(content);
294        assert_eq!(uses.len(), 3);
295        assert!(uses.contains(&"standalone".to_string()));
296        assert!(uses.contains(&"a".to_string()));
297        assert!(uses.contains(&"b".to_string()));
298    }
299
300    #[test]
301    fn test_resolve_single_module() {
302        let dir = tempfile::tempdir().unwrap();
303        std::fs::write(
304            dir.path().join("go.mod"),
305            "module example.com/solo\n\ngo 1.21\n",
306        )
307        .unwrap();
308
309        let graph = GoResolver.resolve(dir.path()).unwrap();
310        assert_eq!(graph.packages.len(), 1);
311        let pkg = graph.packages.values().next().unwrap();
312        assert_eq!(pkg.name, "example.com/solo");
313        assert!(graph.edges.is_empty());
314    }
315
316    #[test]
317    fn test_test_command() {
318        let cmd = GoResolver.test_command(&PackageId("svc-a".into()));
319        assert_eq!(cmd, vec!["go", "test", "./svc-a/..."]);
320    }
321}