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