Skip to main content

affected_core/resolvers/
gradle.rs

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
9/// GradleResolver detects Gradle multi-project builds via `settings.gradle(.kts)`.
10///
11/// Uses regex to parse `include` directives and `project(':...')` dependency references.
12pub 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            // Gradle module ':foo' maps to directory 'foo'
37            // Gradle module ':foo:bar' maps to directory 'foo/bar'
38            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            // Find the build file for this module
50            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        // Build dependency edges by scanning build.gradle(.kts) for project(':...') references
76        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    /// Read settings.gradle or settings.gradle.kts from the root.
111    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
123/// Parse `include` directives from a settings.gradle(.kts) file.
124///
125/// Handles these patterns:
126/// - `include ':module-a'`
127/// - `include ':module-a', ':module-b'`
128/// - `include(":module-a")`
129/// - `include(":module-a", ":module-b")`
130fn parse_include_directives(content: &str) -> Vec<String> {
131    let mut modules = Vec::new();
132
133    // Match quoted module names after include (both Groovy and Kotlin DSL forms).
134    // Captures the colon-prefixed module name inside quotes.
135    let re =
136        Regex::new(r#"include\s*\(?\s*(?:['"]:[\w-]+['"]\s*,\s*)*['"]:?([\w-]+)['"]"#).unwrap();
137
138    // A simpler approach: find all quoted :module references on lines starting with include
139    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        // Extract all module names from this include line
148        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    // Suppress unused variable warning
159    let _ = re;
160
161    modules
162}
163
164/// Parse `project(':...')` references from a build.gradle(.kts) file.
165///
166/// Matches patterns like:
167/// - `project(':core')`
168/// - `project(":core")`
169/// - `project(':sub-module')`
170fn 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        // lib module
314        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        // app module depends on lib
322        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        // app depends on lib
335        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        // core module
351        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        // api module depends on core
359        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        // api depends on core
372        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        // 'missing' directory is not created
423
424        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}