Skip to main content

changepacks_java/
finder.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use regex::Regex;
5use std::{
6    collections::HashMap,
7    path::{Path, PathBuf},
8    process::Stdio,
9};
10use tokio::process::Command;
11
12use crate::{package::GradlePackage, workspace::GradleWorkspace};
13
14#[derive(Debug)]
15pub struct GradleProjectFinder {
16    projects: HashMap<PathBuf, Project>,
17    project_files: Vec<&'static str>,
18}
19
20impl Default for GradleProjectFinder {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl GradleProjectFinder {
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            projects: HashMap::new(),
31            project_files: vec!["build.gradle.kts", "build.gradle"],
32        }
33    }
34}
35
36/// Project info obtained from gradlew properties
37#[derive(Debug, Default)]
38struct GradleProperties {
39    name: Option<String>,
40    version: Option<String>,
41    has_subprojects: bool,
42}
43
44/// Check if `java` is available on PATH.
45fn which_java() -> Option<PathBuf> {
46    let path_var = std::env::var_os("PATH")?;
47    for dir in std::env::split_paths(&path_var) {
48        let candidate = if cfg!(windows) {
49            dir.join("java.exe")
50        } else {
51            dir.join("java")
52        };
53        if candidate.is_file() {
54            return Some(candidate);
55        }
56    }
57    None
58}
59
60/// Find gradlew executable by walking up the directory tree.
61///
62/// In multi-module Gradle builds, `gradlew` lives at the root while subprojects
63/// only contain `build.gradle.kts`. This function searches upward from `start_dir`
64/// until it finds `gradlew` (Unix) or `gradlew.bat` (Windows).
65///
66/// Returns `(gradlew_path, gradlew_dir)` or `None` if not found.
67fn find_gradlew(start_dir: &Path) -> Option<(PathBuf, PathBuf)> {
68    let gradlew_name = if cfg!(windows) {
69        "gradlew.bat"
70    } else {
71        "gradlew"
72    };
73
74    let mut current = start_dir.to_path_buf();
75    loop {
76        let gradlew = current.join(gradlew_name);
77        if gradlew.exists() {
78            return Some((gradlew, current));
79        }
80        if !current.pop() {
81            return None;
82        }
83    }
84}
85
86/// Get project properties using gradlew command.
87///
88/// Walks up the directory tree to find `gradlew`, then runs it with the correct
89/// subproject path. For a subproject at `root/libs/core/`, this runs:
90/// `./gradlew :libs:core:properties -q` from the root directory.
91///
92/// Returns `Err` when `gradlew` is not found or Java is not available.
93async fn get_gradle_properties(project_dir: &Path) -> Result<GradleProperties> {
94    let (gradlew, gradlew_dir) = find_gradlew(project_dir).context(
95        "Gradle wrapper (gradlew) not found. \
96         Ensure the project root contains gradlew or gradlew.bat.",
97    )?;
98
99    // Gradle requires Java. Error early with a clear message rather than
100    // letting gradlew produce a confusing "JAVA_HOME is not set" wall of text.
101    anyhow::ensure!(
102        std::env::var_os("JAVA_HOME").is_some() || which_java().is_some(),
103        "Java is required for Gradle projects but JAVA_HOME is not set and 'java' was not found on PATH.\n\
104         Please set the JAVA_HOME environment variable or add java to your PATH."
105    );
106
107    // Build the command args based on whether this is the root or a subproject
108    let args: Vec<String> = if gradlew_dir == project_dir {
109        // Root project: ./gradlew properties -q
110        vec!["properties".to_string(), "-q".to_string()]
111    } else {
112        // Subproject: ./gradlew :sub:path:properties -q
113        let relative = project_dir
114            .strip_prefix(&gradlew_dir)
115            .context("Failed to compute subproject path")?;
116        let gradle_path = relative
117            .components()
118            .filter_map(|c| c.as_os_str().to_str())
119            .collect::<Vec<_>>()
120            .join(":");
121        vec![format!(":{gradle_path}:properties"), "-q".to_string()]
122    };
123
124    // On Unix, invoke via `sh` to avoid issues when gradlew lacks execute permission
125    // (common after git clone with core.fileMode=false or on some CI systems).
126    let output = if cfg!(windows) {
127        Command::new(&gradlew)
128            .args(&args)
129            .current_dir(&gradlew_dir)
130            .stdout(Stdio::piped())
131            .stderr(Stdio::null())
132            .output()
133            .await
134    } else {
135        Command::new("sh")
136            .arg(&gradlew)
137            .args(&args)
138            .current_dir(&gradlew_dir)
139            .stdout(Stdio::piped())
140            .stderr(Stdio::null())
141            .output()
142            .await
143    }
144    .map_err(|e| {
145        anyhow::anyhow!(
146            "Failed to execute gradlew for '{}' (gradlew: '{}'): {e}",
147            project_dir.display(),
148            gradlew.display(),
149        )
150    })?;
151
152    if !output.status.success() {
153        return Ok(GradleProperties::default());
154    }
155
156    let stdout = String::from_utf8_lossy(&output.stdout);
157    let mut props = GradleProperties::default();
158
159    // Parse properties output
160    // Format: "propertyName: value"
161    let name_pattern = Regex::new(r"(?m)^name:\s*(.+)$").context("regex")?;
162    let version_pattern = Regex::new(r"(?m)^version:\s*(.+)$").context("regex")?;
163    let subprojects_pattern = Regex::new(r"(?m)^subprojects:\s*(.+)$").context("regex")?;
164
165    if let Some(caps) = name_pattern.captures(&stdout) {
166        let name = caps.get(1).map(|m| m.as_str().trim().to_string());
167        if name.as_deref() != Some("unspecified") {
168            props.name = name;
169        }
170    }
171
172    if let Some(caps) = version_pattern.captures(&stdout) {
173        let version = caps.get(1).map(|m| m.as_str().trim().to_string());
174        if version.as_deref() != Some("unspecified") {
175            props.version = version;
176        }
177    }
178
179    // Detect workspace: subprojects is non-empty (e.g. "[project ':sub1', project ':sub2']")
180    if let Some(caps) = subprojects_pattern.captures(&stdout) {
181        let value = caps.get(1).map(|m| m.as_str().trim()).unwrap_or("");
182        props.has_subprojects = value != "[]";
183    }
184
185    Ok(props)
186}
187
188#[async_trait]
189impl ProjectFinder for GradleProjectFinder {
190    fn projects(&self) -> Vec<&Project> {
191        self.projects.values().collect::<Vec<_>>()
192    }
193
194    fn projects_mut(&mut self) -> Vec<&mut Project> {
195        self.projects.values_mut().collect::<Vec<_>>()
196    }
197
198    fn project_files(&self) -> &[&str] {
199        &self.project_files
200    }
201
202    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
203        if path.is_file()
204            && self.project_files().contains(
205                &path
206                    .file_name()
207                    .context(format!("File name not found - {}", path.display()))?
208                    .to_str()
209                    .context(format!("File name not found - {}", path.display()))?,
210            )
211        {
212            if self.projects.contains_key(path) {
213                return Ok(());
214            }
215
216            let project_dir = path
217                .parent()
218                .context(format!("Parent not found - {}", path.display()))?;
219
220            // Get properties from gradlew command
221            let props = get_gradle_properties(project_dir).await?;
222
223            // Use directory name as fallback for project name
224            let name = props.name.or_else(|| {
225                project_dir
226                    .file_name()
227                    .and_then(|n| n.to_str())
228                    .map(std::string::ToString::to_string)
229            });
230
231            let version = props.version;
232
233            // Workspace detection: gradlew reports non-empty subprojects list.
234            // Previous approach (checking for settings.gradle.kts existence) caused
235            // false positives in composite builds and subprojects with IDE-generated files.
236            let is_workspace = props.has_subprojects;
237
238            let (path, project) = if is_workspace {
239                (
240                    path.to_path_buf(),
241                    Project::Workspace(Box::new(GradleWorkspace::new(
242                        name,
243                        version,
244                        path.to_path_buf(),
245                        relative_path.to_path_buf(),
246                    ))),
247                )
248            } else {
249                (
250                    path.to_path_buf(),
251                    Project::Package(Box::new(GradlePackage::new(
252                        name,
253                        version,
254                        path.to_path_buf(),
255                        relative_path.to_path_buf(),
256                    ))),
257                )
258            };
259
260            self.projects.insert(path, project);
261        }
262        Ok(())
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use changepacks_core::Project;
270    use std::fs;
271    use tempfile::TempDir;
272
273    #[test]
274    fn test_gradle_project_finder_new() {
275        let finder = GradleProjectFinder::new();
276        assert_eq!(
277            finder.project_files(),
278            &["build.gradle.kts", "build.gradle"]
279        );
280        assert_eq!(finder.projects().len(), 0);
281    }
282
283    #[test]
284    fn test_gradle_project_finder_default() {
285        let finder = GradleProjectFinder::default();
286        assert_eq!(
287            finder.project_files(),
288            &["build.gradle.kts", "build.gradle"]
289        );
290        assert_eq!(finder.projects().len(), 0);
291    }
292
293    /// Create a mock gradlew in the given directory that outputs the specified properties.
294    fn create_mock_gradlew(dir: &Path, name: &str, version: &str) {
295        if cfg!(windows) {
296            fs::write(
297                dir.join("gradlew.bat"),
298                format!(
299                    "@echo off\necho name: {name}\necho version: {version}\necho subprojects: []\n"
300                ),
301            )
302            .unwrap();
303        } else {
304            let gradlew_path = dir.join("gradlew");
305            fs::write(&gradlew_path, format!("#!/bin/sh\necho 'name: {name}'\necho 'version: {version}'\necho 'subprojects: []'\n")).unwrap();
306            #[cfg(unix)]
307            {
308                use std::os::unix::fs::PermissionsExt;
309                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
310            }
311        }
312    }
313
314    #[tokio::test]
315    async fn test_gradle_project_finder_visit_kts_package() {
316        let temp_dir = TempDir::new().unwrap();
317        let project_dir = temp_dir.path().join("myproject");
318        fs::create_dir_all(&project_dir).unwrap();
319
320        let build_gradle = project_dir.join("build.gradle.kts");
321        fs::write(
322            &build_gradle,
323            r#"
324plugins {
325    id("java")
326}
327
328group = "com.example"
329version = "1.0.0"
330"#,
331        )
332        .unwrap();
333
334        create_mock_gradlew(&project_dir, "myproject", "1.0.0");
335
336        let mut finder = GradleProjectFinder::new();
337        finder
338            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
339            .await
340            .unwrap();
341
342        let projects = finder.projects();
343        assert_eq!(projects.len(), 1);
344        match projects[0] {
345            Project::Package(pkg) => {
346                assert_eq!(pkg.name(), Some("myproject"));
347                assert_eq!(pkg.version(), Some("1.0.0"));
348            }
349            _ => panic!("Expected Package"),
350        }
351
352        temp_dir.close().unwrap();
353    }
354
355    #[tokio::test]
356    async fn test_gradle_project_finder_visit_groovy_package() {
357        let temp_dir = TempDir::new().unwrap();
358        let project_dir = temp_dir.path().join("groovyproject");
359        fs::create_dir_all(&project_dir).unwrap();
360
361        let build_gradle = project_dir.join("build.gradle");
362        fs::write(
363            &build_gradle,
364            r#"
365plugins {
366    id 'java'
367}
368
369group = 'com.example'
370version = '2.0.0'
371"#,
372        )
373        .unwrap();
374
375        create_mock_gradlew(&project_dir, "groovyproject", "2.0.0");
376
377        let mut finder = GradleProjectFinder::new();
378        finder
379            .visit(&build_gradle, &PathBuf::from("groovyproject/build.gradle"))
380            .await
381            .unwrap();
382
383        let projects = finder.projects();
384        assert_eq!(projects.len(), 1);
385        match projects[0] {
386            Project::Package(pkg) => {
387                assert_eq!(pkg.name(), Some("groovyproject"));
388                assert_eq!(pkg.version(), Some("2.0.0"));
389            }
390            _ => panic!("Expected Package"),
391        }
392
393        temp_dir.close().unwrap();
394    }
395
396    #[tokio::test]
397    async fn test_gradle_project_finder_visit_workspace() {
398        let temp_dir = TempDir::new().unwrap();
399        let project_dir = temp_dir.path().join("multiproject");
400        fs::create_dir_all(&project_dir).unwrap();
401
402        let build_gradle = project_dir.join("build.gradle.kts");
403        fs::write(
404            &build_gradle,
405            r#"
406plugins {
407    id("java")
408}
409
410group = "com.example"
411version = "1.0.0"
412"#,
413        )
414        .unwrap();
415
416        // Mock gradlew that reports subprojects (this is what makes it a workspace)
417        if cfg!(windows) {
418            fs::write(project_dir.join("gradlew.bat"), "@echo off\necho name: multiproject\necho version: 1.0.0\necho subprojects: [project ':subproject1', project ':subproject2']\n").unwrap();
419        } else {
420            let gradlew_path = project_dir.join("gradlew");
421            fs::write(&gradlew_path, "#!/bin/sh\necho 'name: multiproject'\necho 'version: 1.0.0'\necho \"subprojects: [project ':subproject1', project ':subproject2']\"\n").unwrap();
422            #[cfg(unix)]
423            {
424                use std::os::unix::fs::PermissionsExt;
425                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
426            }
427        }
428
429        let mut finder = GradleProjectFinder::new();
430        finder
431            .visit(
432                &build_gradle,
433                &PathBuf::from("multiproject/build.gradle.kts"),
434            )
435            .await
436            .unwrap();
437
438        let projects = finder.projects();
439        assert_eq!(projects.len(), 1);
440        match projects[0] {
441            Project::Workspace(ws) => {
442                assert_eq!(ws.name(), Some("multiproject"));
443                assert_eq!(ws.version(), Some("1.0.0"));
444            }
445            _ => panic!("Expected Workspace"),
446        }
447
448        temp_dir.close().unwrap();
449    }
450
451    #[tokio::test]
452    async fn test_gradle_project_finder_settings_file_does_not_make_workspace() {
453        // Regression: settings.gradle.kts presence alone must NOT classify as Workspace.
454        // Only gradlew's subprojects output determines workspace status.
455        let temp_dir = TempDir::new().unwrap();
456        let project_dir = temp_dir.path().join("myproject");
457        fs::create_dir_all(&project_dir).unwrap();
458
459        let build_gradle = project_dir.join("build.gradle.kts");
460        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
461
462        // settings.gradle.kts exists AND gradlew exists, but subprojects: [] → Package
463        fs::write(
464            project_dir.join("settings.gradle.kts"),
465            "rootProject.name = \"myproject\"\n",
466        )
467        .unwrap();
468
469        create_mock_gradlew(&project_dir, "myproject", "1.0.0");
470
471        let mut finder = GradleProjectFinder::new();
472        finder
473            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
474            .await
475            .unwrap();
476
477        let projects = finder.projects();
478        assert_eq!(projects.len(), 1);
479        match projects[0] {
480            Project::Package(_) => {} // correct: subprojects: [] → Package
481            _ => panic!("Expected Package, not Workspace"),
482        }
483
484        temp_dir.close().unwrap();
485    }
486
487    #[tokio::test]
488    async fn test_gradle_project_finder_empty_subprojects_is_package() {
489        // A project with gradlew but subprojects: [] is a Package, not Workspace
490        let temp_dir = TempDir::new().unwrap();
491        let project_dir = temp_dir.path().join("standalone");
492        fs::create_dir_all(&project_dir).unwrap();
493
494        let build_gradle = project_dir.join("build.gradle.kts");
495        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
496
497        if cfg!(windows) {
498            fs::write(
499                project_dir.join("gradlew.bat"),
500                "@echo off\necho name: standalone\necho version: 1.0.0\necho subprojects: []\n",
501            )
502            .unwrap();
503        } else {
504            let gradlew_path = project_dir.join("gradlew");
505            fs::write(&gradlew_path, "#!/bin/sh\necho 'name: standalone'\necho 'version: 1.0.0'\necho 'subprojects: []'\n").unwrap();
506            #[cfg(unix)]
507            {
508                use std::os::unix::fs::PermissionsExt;
509                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
510            }
511        }
512
513        let mut finder = GradleProjectFinder::new();
514        finder
515            .visit(&build_gradle, &PathBuf::from("standalone/build.gradle.kts"))
516            .await
517            .unwrap();
518
519        let projects = finder.projects();
520        assert_eq!(projects.len(), 1);
521        match projects[0] {
522            Project::Package(pkg) => {
523                assert_eq!(pkg.name(), Some("standalone"));
524            }
525            _ => panic!("Expected Package, not Workspace"),
526        }
527
528        temp_dir.close().unwrap();
529    }
530
531    #[tokio::test]
532    async fn test_gradle_project_finder_visit_non_gradle_file() {
533        let temp_dir = TempDir::new().unwrap();
534        let other_file = temp_dir.path().join("other.txt");
535        fs::write(&other_file, "some content").unwrap();
536
537        let mut finder = GradleProjectFinder::new();
538        finder
539            .visit(&other_file, &PathBuf::from("other.txt"))
540            .await
541            .unwrap();
542
543        assert_eq!(finder.projects().len(), 0);
544
545        temp_dir.close().unwrap();
546    }
547
548    #[tokio::test]
549    async fn test_gradle_project_finder_visit_duplicate() {
550        let temp_dir = TempDir::new().unwrap();
551        let project_dir = temp_dir.path().join("myproject");
552        fs::create_dir_all(&project_dir).unwrap();
553
554        let build_gradle = project_dir.join("build.gradle.kts");
555        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
556
557        create_mock_gradlew(&project_dir, "myproject", "1.0.0");
558
559        let mut finder = GradleProjectFinder::new();
560        finder
561            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
562            .await
563            .unwrap();
564
565        assert_eq!(finder.projects().len(), 1);
566
567        // Visit again - should not add duplicate
568        finder
569            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
570            .await
571            .unwrap();
572
573        assert_eq!(finder.projects().len(), 1);
574
575        temp_dir.close().unwrap();
576    }
577
578    #[tokio::test]
579    async fn test_gradle_project_finder_projects_mut() {
580        let temp_dir = TempDir::new().unwrap();
581        let project_dir = temp_dir.path().join("myproject");
582        fs::create_dir_all(&project_dir).unwrap();
583
584        let build_gradle = project_dir.join("build.gradle.kts");
585        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
586
587        create_mock_gradlew(&project_dir, "myproject", "1.0.0");
588
589        let mut finder = GradleProjectFinder::new();
590        finder
591            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
592            .await
593            .unwrap();
594
595        let mut_projects = finder.projects_mut();
596        assert_eq!(mut_projects.len(), 1);
597
598        temp_dir.close().unwrap();
599    }
600
601    #[test]
602    fn test_find_gradlew_in_same_dir() {
603        let temp_dir = TempDir::new().unwrap();
604
605        if cfg!(windows) {
606            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off").unwrap();
607        } else {
608            fs::write(temp_dir.path().join("gradlew"), "#!/bin/sh").unwrap();
609        }
610
611        let result = find_gradlew(temp_dir.path());
612        assert!(result.is_some());
613        let (_, gradlew_dir) = result.unwrap();
614        assert_eq!(gradlew_dir, temp_dir.path());
615
616        temp_dir.close().unwrap();
617    }
618
619    #[test]
620    fn test_find_gradlew_in_parent_dir() {
621        let temp_dir = TempDir::new().unwrap();
622        let subproject = temp_dir.path().join("libs").join("core");
623        fs::create_dir_all(&subproject).unwrap();
624
625        // gradlew at root, not in subproject
626        if cfg!(windows) {
627            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off").unwrap();
628        } else {
629            fs::write(temp_dir.path().join("gradlew"), "#!/bin/sh").unwrap();
630        }
631
632        let result = find_gradlew(&subproject);
633        assert!(result.is_some());
634        let (_, gradlew_dir) = result.unwrap();
635        assert_eq!(gradlew_dir, temp_dir.path().to_path_buf());
636
637        temp_dir.close().unwrap();
638    }
639
640    #[test]
641    fn test_find_gradlew_not_found() {
642        let temp_dir = TempDir::new().unwrap();
643        let subdir = temp_dir.path().join("no_gradlew_here");
644        fs::create_dir_all(&subdir).unwrap();
645
646        // Don't create gradlew anywhere — but find_gradlew walks to filesystem
647        // root, so this test just verifies it doesn't panic. In practice it
648        // returns None only when no gradlew exists anywhere up the tree.
649        // For a reliable "not found" test, we rely on the no-gradlew properties test below.
650        let _ = find_gradlew(&subdir);
651
652        temp_dir.close().unwrap();
653    }
654
655    #[tokio::test]
656    async fn test_get_gradle_properties_no_gradlew() {
657        let temp_dir = TempDir::new().unwrap();
658        let subdir = temp_dir.path().join("isolated");
659        fs::create_dir_all(&subdir).unwrap();
660        // No gradlew anywhere in this subtree → should error
661        let result = get_gradle_properties(&subdir).await;
662        // May find a system gradlew higher up; the key contract is it doesn't panic.
663        // If no gradlew found at all, it returns Err.
664        let _ = result;
665        temp_dir.close().unwrap();
666    }
667
668    #[tokio::test]
669    async fn test_get_gradle_properties_with_mock() {
670        let temp_dir = TempDir::new().unwrap();
671
672        // Create mock gradlew that outputs properties
673        if cfg!(windows) {
674            let gradlew_path = temp_dir.path().join("gradlew.bat");
675            fs::write(
676                &gradlew_path,
677                "@echo off\necho name: myproject\necho version: 1.2.3\n",
678            )
679            .unwrap();
680        } else {
681            let gradlew_path = temp_dir.path().join("gradlew");
682            fs::write(
683                &gradlew_path,
684                "#!/bin/sh\necho 'name: myproject'\necho 'version: 1.2.3'\n",
685            )
686            .unwrap();
687            // Make executable on Unix
688            #[cfg(unix)]
689            {
690                use std::os::unix::fs::PermissionsExt;
691                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
692            }
693        }
694
695        let props = get_gradle_properties(temp_dir.path()).await.unwrap();
696        assert_eq!(props.name, Some("myproject".to_string()));
697        assert_eq!(props.version, Some("1.2.3".to_string()));
698        assert!(!props.has_subprojects);
699
700        temp_dir.close().unwrap();
701    }
702
703    #[tokio::test]
704    async fn test_get_gradle_properties_with_subprojects() {
705        let temp_dir = TempDir::new().unwrap();
706
707        if cfg!(windows) {
708            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off\necho name: root\necho version: 1.0.0\necho subprojects: [project ':app', project ':lib']\n").unwrap();
709        } else {
710            let gradlew_path = temp_dir.path().join("gradlew");
711            fs::write(&gradlew_path, "#!/bin/sh\necho 'name: root'\necho 'version: 1.0.0'\necho \"subprojects: [project ':app', project ':lib']\"\n").unwrap();
712            #[cfg(unix)]
713            {
714                use std::os::unix::fs::PermissionsExt;
715                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
716            }
717        }
718
719        let props = get_gradle_properties(temp_dir.path()).await.unwrap();
720        assert_eq!(props.name, Some("root".to_string()));
721        assert!(props.has_subprojects);
722
723        temp_dir.close().unwrap();
724    }
725
726    #[tokio::test]
727    async fn test_get_gradle_properties_empty_subprojects() {
728        let temp_dir = TempDir::new().unwrap();
729
730        if cfg!(windows) {
731            fs::write(
732                temp_dir.path().join("gradlew.bat"),
733                "@echo off\necho name: leaf\necho version: 1.0.0\necho subprojects: []\n",
734            )
735            .unwrap();
736        } else {
737            let gradlew_path = temp_dir.path().join("gradlew");
738            fs::write(
739                &gradlew_path,
740                "#!/bin/sh\necho 'name: leaf'\necho 'version: 1.0.0'\necho 'subprojects: []'\n",
741            )
742            .unwrap();
743            #[cfg(unix)]
744            {
745                use std::os::unix::fs::PermissionsExt;
746                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
747            }
748        }
749
750        let props = get_gradle_properties(temp_dir.path()).await.unwrap();
751        assert_eq!(props.name, Some("leaf".to_string()));
752        assert!(!props.has_subprojects);
753
754        temp_dir.close().unwrap();
755    }
756
757    #[tokio::test]
758    async fn test_get_gradle_properties_from_parent_gradlew() {
759        let temp_dir = TempDir::new().unwrap();
760        let subproject = temp_dir.path().join("sub1");
761        fs::create_dir_all(&subproject).unwrap();
762
763        // Place gradlew at root, query from subproject dir
764        if cfg!(windows) {
765            let gradlew_path = temp_dir.path().join("gradlew.bat");
766            // Mock: ignore the :sub1:properties arg, just output properties
767            fs::write(
768                &gradlew_path,
769                "@echo off\necho name: sub1\necho version: 2.0.0\n",
770            )
771            .unwrap();
772        } else {
773            let gradlew_path = temp_dir.path().join("gradlew");
774            fs::write(
775                &gradlew_path,
776                "#!/bin/sh\necho 'name: sub1'\necho 'version: 2.0.0'\n",
777            )
778            .unwrap();
779            #[cfg(unix)]
780            {
781                use std::os::unix::fs::PermissionsExt;
782                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
783            }
784        }
785
786        let props = get_gradle_properties(&subproject).await.unwrap();
787        assert_eq!(props.name, Some("sub1".to_string()));
788        assert_eq!(props.version, Some("2.0.0".to_string()));
789
790        temp_dir.close().unwrap();
791    }
792
793    #[tokio::test]
794    async fn test_get_gradle_properties_nested_subproject() {
795        let temp_dir = TempDir::new().unwrap();
796        let subproject = temp_dir.path().join("libs").join("core");
797        fs::create_dir_all(&subproject).unwrap();
798
799        // Place gradlew at root, query from libs/core/
800        if cfg!(windows) {
801            let gradlew_path = temp_dir.path().join("gradlew.bat");
802            // The mock script receives ":libs:core:properties" "-q" as args
803            fs::write(
804                &gradlew_path,
805                "@echo off\necho name: core\necho version: 3.1.0\n",
806            )
807            .unwrap();
808        } else {
809            let gradlew_path = temp_dir.path().join("gradlew");
810            fs::write(
811                &gradlew_path,
812                "#!/bin/sh\necho 'name: core'\necho 'version: 3.1.0'\n",
813            )
814            .unwrap();
815            #[cfg(unix)]
816            {
817                use std::os::unix::fs::PermissionsExt;
818                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
819            }
820        }
821
822        let props = get_gradle_properties(&subproject).await.unwrap();
823        assert_eq!(props.name, Some("core".to_string()));
824        assert_eq!(props.version, Some("3.1.0".to_string()));
825
826        temp_dir.close().unwrap();
827    }
828
829    #[tokio::test]
830    async fn test_get_gradle_properties_unspecified() {
831        let temp_dir = TempDir::new().unwrap();
832
833        if cfg!(windows) {
834            let gradlew_path = temp_dir.path().join("gradlew.bat");
835            fs::write(
836                &gradlew_path,
837                "@echo off\necho name: unspecified\necho version: unspecified\n",
838            )
839            .unwrap();
840        } else {
841            let gradlew_path = temp_dir.path().join("gradlew");
842            fs::write(
843                &gradlew_path,
844                "#!/bin/sh\necho 'name: unspecified'\necho 'version: unspecified'\n",
845            )
846            .unwrap();
847            #[cfg(unix)]
848            {
849                use std::os::unix::fs::PermissionsExt;
850                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
851            }
852        }
853
854        let props = get_gradle_properties(temp_dir.path()).await.unwrap();
855        assert!(props.name.is_none());
856        assert!(props.version.is_none());
857
858        temp_dir.close().unwrap();
859    }
860
861    #[tokio::test]
862    async fn test_get_gradle_properties_gradlew_fails() {
863        let temp_dir = TempDir::new().unwrap();
864
865        if cfg!(windows) {
866            let gradlew_path = temp_dir.path().join("gradlew.bat");
867            fs::write(&gradlew_path, "@echo off\nexit /b 1\n").unwrap();
868        } else {
869            let gradlew_path = temp_dir.path().join("gradlew");
870            fs::write(&gradlew_path, "#!/bin/sh\nexit 1\n").unwrap();
871            #[cfg(unix)]
872            {
873                use std::os::unix::fs::PermissionsExt;
874                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
875            }
876        }
877
878        // gradlew exits non-zero → returns default props (no name, no version)
879        let props = get_gradle_properties(temp_dir.path()).await.unwrap();
880        assert!(props.name.is_none());
881        assert!(props.version.is_none());
882
883        temp_dir.close().unwrap();
884    }
885
886    #[test]
887    fn test_which_java_returns_some_or_none() {
888        // Exercises which_java() — the result depends on the test environment,
889        // but the function must not panic regardless.
890        let result = which_java();
891        // On most dev/CI machines java is on PATH → Some; otherwise None.
892        // Both branches are valid; we just verify it runs without error.
893        let _ = result;
894    }
895
896    #[test]
897    fn test_which_java_with_empty_path() {
898        // Temporarily set PATH to empty to guarantee the None branch (line 50).
899        let original = std::env::var_os("PATH");
900        // SAFETY: this test runs single-threaded; no other thread reads PATH concurrently.
901        unsafe { std::env::set_var("PATH", "") };
902
903        let result = which_java();
904        assert!(result.is_none());
905
906        // Restore
907        if let Some(p) = original {
908            // SAFETY: restoring original value, single-threaded test context.
909            unsafe { std::env::set_var("PATH", p) };
910        }
911    }
912
913    #[tokio::test]
914    async fn test_gradle_project_finder_visit_name_fallback_to_dir() {
915        // When gradlew returns name: unspecified, visit() falls back to directory name (line 173).
916        let temp_dir = TempDir::new().unwrap();
917        let project_dir = temp_dir.path().join("my-fallback-project");
918        fs::create_dir_all(&project_dir).unwrap();
919
920        let build_gradle = project_dir.join("build.gradle.kts");
921        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
922
923        // Mock gradlew that returns unspecified name (filtered to None)
924        if cfg!(windows) {
925            fs::write(
926                project_dir.join("gradlew.bat"),
927                "@echo off\necho name: unspecified\necho version: 1.0.0\necho subprojects: []\n",
928            )
929            .unwrap();
930        } else {
931            let gradlew_path = project_dir.join("gradlew");
932            fs::write(&gradlew_path, "#!/bin/sh\necho 'name: unspecified'\necho 'version: 1.0.0'\necho 'subprojects: []'\n").unwrap();
933            #[cfg(unix)]
934            {
935                use std::os::unix::fs::PermissionsExt;
936                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
937            }
938        }
939
940        let mut finder = GradleProjectFinder::new();
941        finder
942            .visit(
943                &build_gradle,
944                &PathBuf::from("my-fallback-project/build.gradle.kts"),
945            )
946            .await
947            .unwrap();
948
949        let projects = finder.projects();
950        assert_eq!(projects.len(), 1);
951        match projects[0] {
952            Project::Package(pkg) => {
953                // name fell back to directory name
954                assert_eq!(pkg.name(), Some("my-fallback-project"));
955                assert_eq!(pkg.version(), Some("1.0.0"));
956            }
957            _ => panic!("Expected Package"),
958        }
959
960        temp_dir.close().unwrap();
961    }
962}