affected_core/resolvers/
go.rs1use 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 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 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 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 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 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 Ok(ProjectGraph {
115 packages,
116 edges: vec![],
117 root: root.to_path_buf(),
118 })
119 }
120
121 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 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 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
163fn 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 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 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
212fn 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}