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;
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            // Gradle module ':foo' maps to directory 'foo'
38            // Gradle module ':foo:bar' maps to directory 'foo/bar'
39            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            // Find the build file for this module
51            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        // Build dependency edges by scanning build.gradle(.kts) for project(':...') references
77        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    /// Read settings.gradle or settings.gradle.kts from the root.
112    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
124/// Parse `include` directives from a settings.gradle(.kts) file.
125///
126/// Handles these patterns:
127/// - `include ':module-a'`
128/// - `include ':module-a', ':module-b'`
129/// - `include(":module-a")`
130/// - `include(":module-a", ":module-b")`
131fn parse_include_directives(content: &str) -> Vec<String> {
132    let mut modules = Vec::new();
133
134    // Match quoted module names after include (both Groovy and Kotlin DSL forms).
135    // Captures the colon-prefixed module name inside quotes.
136    let re =
137        Regex::new(r#"include\s*\(?\s*(?:['"]:[\w-]+['"]\s*,\s*)*['"]:?([\w-]+)['"]"#).unwrap();
138
139    // A simpler approach: find all quoted :module references on lines starting with include
140    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        // Extract all module names from this include line
149        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    // Suppress unused variable warning
160    let _ = re;
161
162    modules
163}
164
165/// Parse `project(':...')` references from a build.gradle(.kts) file.
166///
167/// Matches patterns like:
168/// - `project(':core')`
169/// - `project(":core")`
170/// - `project(':sub-module')`
171fn 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        // lib module
315        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        // app module depends on lib
323        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        // app depends on lib
336        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        // core module
352        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        // api module depends on core
360        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        // api depends on core
373        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        // 'missing' directory is not created
424
425        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}