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;
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 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 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 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 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 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 Ok(ProjectGraph {
116 packages,
117 edges: vec![],
118 root: root.to_path_buf(),
119 })
120 }
121
122 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 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 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
164fn 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 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 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
213fn 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}