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