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