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