1use anyhow::{Context, Result};
2use regex::Regex;
3use std::collections::HashMap;
4use std::path::Path;
5
6use crate::resolvers::{file_to_package, Resolver};
7use crate::types::{Ecosystem, Package, PackageId, ProjectGraph};
8
9pub struct GradleResolver;
13
14impl Resolver for GradleResolver {
15 fn ecosystem(&self) -> Ecosystem {
16 Ecosystem::Gradle
17 }
18
19 fn detect(&self, root: &Path) -> bool {
20 root.join("settings.gradle").exists() || root.join("settings.gradle.kts").exists()
21 }
22
23 fn resolve(&self, root: &Path) -> Result<ProjectGraph> {
24 let settings_content = self.read_settings(root)?;
25 let module_names = parse_include_directives(&settings_content);
26
27 tracing::debug!(
28 "Gradle: found {} included modules: {:?}",
29 module_names.len(),
30 module_names
31 );
32
33 let mut packages = HashMap::new();
34
35 for module_name in &module_names {
36 let dir_path = module_name.replace(':', "/");
39 let module_dir = root.join(&dir_path);
40
41 if !module_dir.exists() {
42 tracing::debug!(
43 "Gradle: module '{}' directory does not exist, skipping",
44 module_name
45 );
46 continue;
47 }
48
49 let build_file = if module_dir.join("build.gradle.kts").exists() {
51 module_dir.join("build.gradle.kts")
52 } else if module_dir.join("build.gradle").exists() {
53 module_dir.join("build.gradle")
54 } else {
55 tracing::debug!(
56 "Gradle: module '{}' has no build.gradle(.kts), skipping",
57 module_name
58 );
59 continue;
60 };
61
62 let pkg_id = PackageId(module_name.clone());
63 packages.insert(
64 pkg_id.clone(),
65 Package {
66 id: pkg_id,
67 name: module_name.clone(),
68 version: None,
69 path: module_dir,
70 manifest_path: build_file,
71 },
72 );
73 }
74
75 let mut edges = Vec::new();
77 let module_set: std::collections::HashSet<&str> =
78 module_names.iter().map(|s| s.as_str()).collect();
79
80 for (pkg_id, pkg) in &packages {
81 let build_content = std::fs::read_to_string(&pkg.manifest_path)
82 .with_context(|| format!("Failed to read {}", pkg.manifest_path.display()))?;
83
84 let project_refs = parse_project_dependencies(&build_content);
85
86 for dep_name in &project_refs {
87 if module_set.contains(dep_name.as_str()) && dep_name != &pkg_id.0 {
88 edges.push((pkg_id.clone(), PackageId(dep_name.clone())));
89 }
90 }
91 }
92
93 Ok(ProjectGraph {
94 packages,
95 edges,
96 root: root.to_path_buf(),
97 })
98 }
99
100 fn package_for_file(&self, graph: &ProjectGraph, file: &Path) -> Option<PackageId> {
101 file_to_package(graph, file)
102 }
103
104 fn test_command(&self, package_id: &PackageId) -> Vec<String> {
105 vec!["gradle".into(), format!(":{}:test", package_id.0)]
106 }
107}
108
109impl GradleResolver {
110 fn read_settings(&self, root: &Path) -> Result<String> {
112 let kts_path = root.join("settings.gradle.kts");
113 if kts_path.exists() {
114 return std::fs::read_to_string(&kts_path)
115 .context("Failed to read settings.gradle.kts");
116 }
117
118 let groovy_path = root.join("settings.gradle");
119 std::fs::read_to_string(&groovy_path).context("Failed to read settings.gradle")
120 }
121}
122
123fn parse_include_directives(content: &str) -> Vec<String> {
131 let mut modules = Vec::new();
132
133 let re =
136 Regex::new(r#"include\s*\(?\s*(?:['"]:[\w-]+['"]\s*,\s*)*['"]:?([\w-]+)['"]"#).unwrap();
137
138 let module_re = Regex::new(r#"['"]:([\w-]+)['"]"#).unwrap();
140
141 for line in content.lines() {
142 let trimmed = line.trim();
143 if !trimmed.starts_with("include") {
144 continue;
145 }
146
147 for cap in module_re.captures_iter(trimmed) {
149 if let Some(name) = cap.get(1) {
150 let module_name = name.as_str().to_string();
151 if !modules.contains(&module_name) {
152 modules.push(module_name);
153 }
154 }
155 }
156 }
157
158 let _ = re;
160
161 modules
162}
163
164fn parse_project_dependencies(content: &str) -> Vec<String> {
171 let re = Regex::new(r#"project\(\s*['"]:([\w-]+)['"]\s*\)"#).unwrap();
172 let mut deps = Vec::new();
173
174 for cap in re.captures_iter(content) {
175 if let Some(name) = cap.get(1) {
176 let dep_name = name.as_str().to_string();
177 if !deps.contains(&dep_name) {
178 deps.push(dep_name);
179 }
180 }
181 }
182
183 deps
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn test_detect_settings_gradle() {
192 let dir = tempfile::tempdir().unwrap();
193 std::fs::write(
194 dir.path().join("settings.gradle"),
195 "include ':app', ':lib'\n",
196 )
197 .unwrap();
198 assert!(GradleResolver.detect(dir.path()));
199 }
200
201 #[test]
202 fn test_detect_settings_gradle_kts() {
203 let dir = tempfile::tempdir().unwrap();
204 std::fs::write(
205 dir.path().join("settings.gradle.kts"),
206 "include(\":app\")\n",
207 )
208 .unwrap();
209 assert!(GradleResolver.detect(dir.path()));
210 }
211
212 #[test]
213 fn test_detect_no_settings() {
214 let dir = tempfile::tempdir().unwrap();
215 assert!(!GradleResolver.detect(dir.path()));
216 }
217
218 #[test]
219 fn test_parse_include_groovy_single() {
220 let content = "include ':app'\n";
221 let modules = parse_include_directives(content);
222 assert_eq!(modules, vec!["app"]);
223 }
224
225 #[test]
226 fn test_parse_include_groovy_multiple() {
227 let content = "include ':app', ':lib', ':core'\n";
228 let modules = parse_include_directives(content);
229 assert_eq!(modules, vec!["app", "lib", "core"]);
230 }
231
232 #[test]
233 fn test_parse_include_kts_single() {
234 let content = "include(\":app\")\n";
235 let modules = parse_include_directives(content);
236 assert_eq!(modules, vec!["app"]);
237 }
238
239 #[test]
240 fn test_parse_include_kts_multiple() {
241 let content = "include(\":app\", \":lib\")\n";
242 let modules = parse_include_directives(content);
243 assert_eq!(modules, vec!["app", "lib"]);
244 }
245
246 #[test]
247 fn test_parse_include_multi_line() {
248 let content = "include ':app'\ninclude ':lib'\n";
249 let modules = parse_include_directives(content);
250 assert_eq!(modules, vec!["app", "lib"]);
251 }
252
253 #[test]
254 fn test_parse_include_ignores_non_include_lines() {
255 let content = "rootProject.name = 'my-project'\ninclude ':app'\n// include ':commented'\n";
256 let modules = parse_include_directives(content);
257 assert_eq!(modules, vec!["app"]);
258 }
259
260 #[test]
261 fn test_parse_include_no_duplicates() {
262 let content = "include ':app'\ninclude ':app'\n";
263 let modules = parse_include_directives(content);
264 assert_eq!(modules, vec!["app"]);
265 }
266
267 #[test]
268 fn test_parse_project_dependencies() {
269 let content = r#"
270dependencies {
271 implementation project(':core')
272 testImplementation project(':test-utils')
273 api project(":shared")
274}
275"#;
276 let deps = parse_project_dependencies(content);
277 assert_eq!(deps, vec!["core", "test-utils", "shared"]);
278 }
279
280 #[test]
281 fn test_parse_project_dependencies_kts() {
282 let content = r#"
283dependencies {
284 implementation(project(":core"))
285 testImplementation(project(":test-utils"))
286}
287"#;
288 let deps = parse_project_dependencies(content);
289 assert_eq!(deps, vec!["core", "test-utils"]);
290 }
291
292 #[test]
293 fn test_parse_project_dependencies_none() {
294 let content = r#"
295dependencies {
296 implementation "org.example:lib:1.0"
297}
298"#;
299 let deps = parse_project_dependencies(content);
300 assert!(deps.is_empty());
301 }
302
303 #[test]
304 fn test_resolve_gradle_project() {
305 let dir = tempfile::tempdir().unwrap();
306
307 std::fs::write(
308 dir.path().join("settings.gradle"),
309 "include ':app', ':lib'\n",
310 )
311 .unwrap();
312
313 std::fs::create_dir_all(dir.path().join("lib")).unwrap();
315 std::fs::write(
316 dir.path().join("lib/build.gradle"),
317 "apply plugin: 'java'\n",
318 )
319 .unwrap();
320
321 std::fs::create_dir_all(dir.path().join("app")).unwrap();
323 std::fs::write(
324 dir.path().join("app/build.gradle"),
325 "apply plugin: 'java'\ndependencies {\n implementation project(':lib')\n}\n",
326 )
327 .unwrap();
328
329 let graph = GradleResolver.resolve(dir.path()).unwrap();
330 assert_eq!(graph.packages.len(), 2);
331 assert!(graph.packages.contains_key(&PackageId("app".into())));
332 assert!(graph.packages.contains_key(&PackageId("lib".into())));
333
334 assert!(graph
336 .edges
337 .contains(&(PackageId("app".into()), PackageId("lib".into()),)));
338 }
339
340 #[test]
341 fn test_resolve_gradle_kts_project() {
342 let dir = tempfile::tempdir().unwrap();
343
344 std::fs::write(
345 dir.path().join("settings.gradle.kts"),
346 "include(\":core\", \":api\")\n",
347 )
348 .unwrap();
349
350 std::fs::create_dir_all(dir.path().join("core")).unwrap();
352 std::fs::write(
353 dir.path().join("core/build.gradle.kts"),
354 "plugins { java }\n",
355 )
356 .unwrap();
357
358 std::fs::create_dir_all(dir.path().join("api")).unwrap();
360 std::fs::write(
361 dir.path().join("api/build.gradle.kts"),
362 "plugins { java }\ndependencies {\n implementation(project(\":core\"))\n}\n",
363 )
364 .unwrap();
365
366 let graph = GradleResolver.resolve(dir.path()).unwrap();
367 assert_eq!(graph.packages.len(), 2);
368 assert!(graph.packages.contains_key(&PackageId("core".into())));
369 assert!(graph.packages.contains_key(&PackageId("api".into())));
370
371 assert!(graph
373 .edges
374 .contains(&(PackageId("api".into()), PackageId("core".into()),)));
375 }
376
377 #[test]
378 fn test_resolve_gradle_no_internal_deps() {
379 let dir = tempfile::tempdir().unwrap();
380
381 std::fs::write(
382 dir.path().join("settings.gradle"),
383 "include ':alpha', ':beta'\n",
384 )
385 .unwrap();
386
387 std::fs::create_dir_all(dir.path().join("alpha")).unwrap();
388 std::fs::write(
389 dir.path().join("alpha/build.gradle"),
390 "apply plugin: 'java'\n",
391 )
392 .unwrap();
393
394 std::fs::create_dir_all(dir.path().join("beta")).unwrap();
395 std::fs::write(
396 dir.path().join("beta/build.gradle"),
397 "apply plugin: 'java'\n",
398 )
399 .unwrap();
400
401 let graph = GradleResolver.resolve(dir.path()).unwrap();
402 assert_eq!(graph.packages.len(), 2);
403 assert!(graph.edges.is_empty());
404 }
405
406 #[test]
407 fn test_resolve_gradle_skips_missing_dir() {
408 let dir = tempfile::tempdir().unwrap();
409
410 std::fs::write(
411 dir.path().join("settings.gradle"),
412 "include ':exists', ':missing'\n",
413 )
414 .unwrap();
415
416 std::fs::create_dir_all(dir.path().join("exists")).unwrap();
417 std::fs::write(
418 dir.path().join("exists/build.gradle"),
419 "apply plugin: 'java'\n",
420 )
421 .unwrap();
422 let graph = GradleResolver.resolve(dir.path()).unwrap();
425 assert_eq!(graph.packages.len(), 1);
426 assert!(graph.packages.contains_key(&PackageId("exists".into())));
427 }
428
429 #[test]
430 fn test_test_command() {
431 let cmd = GradleResolver.test_command(&PackageId("app".into()));
432 assert_eq!(cmd, vec!["gradle", ":app:test"]);
433 }
434}