Skip to main content

changepacks_java/
finder.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder, manifest_parent_dir};
4#[cfg(test)]
5use std::process::Stdio;
6use std::{
7    collections::HashMap,
8    ffi::{OsStr, OsString},
9    path::{Path, PathBuf},
10};
11use tokio::process::Command;
12
13use crate::{
14    gradle_dependency_lexer::extract_gradle_project_dependencies,
15    gradle_metadata::{GradleProperties, GradleWrapperMetadata, get_gradle_metadata},
16    package::GradlePackage,
17    read_gradle_build_file,
18    version_lexer::gradle_dialect_for,
19    workspace::GradleWorkspace,
20};
21
22/// Manifest filenames this finder recognizes. Static because the list is
23/// compile-time constant — no per-instance heap `Vec` is needed and the
24/// `ProjectFinder::project_files` return type (`&[&str]`) already accepts
25/// a `&'static [&'static str]`.
26const PROJECT_FILES: &[&str] = &["build.gradle.kts", "build.gradle"];
27
28/// OS-specific Java executable filename, used by `which_java_in` and
29/// `java_home_has_java` to avoid repeating the `cfg!(windows)` branch.
30#[cfg(windows)]
31const JAVA_EXECUTABLE: &str = "java.exe";
32#[cfg(not(windows))]
33const JAVA_EXECUTABLE: &str = "java";
34
35#[derive(Debug, Default)]
36pub struct GradleProjectFinder {
37    projects: HashMap<PathBuf, Project>,
38    java_available: Option<bool>,
39    metadata_by_wrapper: HashMap<PathBuf, GradleWrapperMetadata>,
40    /// Raw `gradlew_dir` (as returned by `find_gradlew`) to its canonicalized
41    /// form. Every subproject of a Gradle monorepo resolves to the SAME wrapper
42    /// root, so without this cache each visited manifest repeats an identical
43    /// `canonicalize` syscall just to build the `metadata_by_wrapper` key.
44    wrapper_dir_canonical: HashMap<PathBuf, PathBuf>,
45}
46
47impl GradleProjectFinder {
48    #[must_use]
49    pub fn new() -> Self {
50        Self::default()
51    }
52
53    /// Resolve the repository-bounded Gradle wrapper for `manifest_path` and make
54    /// sure this finder holds that wrapper's batched metadata.
55    ///
56    /// Owns the three cache-shaped steps of discovery: the bounded `find_gradlew`
57    /// ancestor walk, the `wrapper_dir_canonical` memoization of the wrapper root,
58    /// and the one-shot `metadata_by_wrapper` fill. Returns the normalized wrapper
59    /// root (the `metadata_by_wrapper` key) together with the wrapper path, which
60    /// the caller still needs for its error context.
61    async fn resolve_wrapper_metadata(
62        &mut self,
63        manifest_path: &Path,
64        project_dir: &Path,
65        relative_path: &Path,
66        java_available: bool,
67    ) -> Result<(PathBuf, PathBuf)> {
68        // Bound the gradlew search to the repository root: `relative_path` is
69        // the build file's path relative to the git repo root, so its component
70        // count equals the number of directories from `project_dir` up to and
71        // INCLUDING the repo root (root project: `build.gradle.kts` → count 1 →
72        // check `project_dir` only). This stops the ancestor walk at the repo
73        // boundary so an out-of-repo `gradlew` is never discovered or executed.
74        // Mirrors the C# finder's `is_workspace` bound.
75        let max_depth = relative_path.components().count();
76
77        let (gradlew, gradlew_dir) = find_gradlew(project_dir, max_depth)
78            .await?
79            .with_context(|| gradlew_not_found(manifest_path))?;
80        // Sibling subprojects all report the same `gradlew_dir`, so canonicalize
81        // it once per wrapper root instead of once per manifest.
82        let normalized_wrapper_dir = if let Some(cached) =
83            self.wrapper_dir_canonical.get(&gradlew_dir)
84        {
85            cached.clone()
86        } else {
87            let normalized = tokio::fs::canonicalize(&gradlew_dir)
88                .await
89                .with_context(|| {
90                    format!(
91                        "Failed to normalize Gradle wrapper root '{}' for '{}'",
92                        gradlew_dir.display(),
93                        manifest_path.display()
94                    )
95                })?;
96            self.wrapper_dir_canonical
97                .insert(gradlew_dir.clone(), normalized.clone());
98            normalized
99        };
100
101        if !self
102            .metadata_by_wrapper
103            .contains_key(&normalized_wrapper_dir)
104        {
105            let metadata = get_gradle_metadata(&gradlew, &gradlew_dir, java_available).await?;
106            self.metadata_by_wrapper
107                .insert(normalized_wrapper_dir.clone(), metadata);
108        }
109
110        Ok((normalized_wrapper_dir, gradlew))
111    }
112
113    /// Look up the metadata record Gradle emitted for one project directory.
114    ///
115    /// The two lookups fail for structurally different reasons and therefore
116    /// carry distinct contexts: the first means this wrapper root produced no
117    /// batch at all, the second means the batch exists but never mentioned this
118    /// project directory. The wrapper record is handed back alongside the
119    /// project's Gradle path and properties because the caller resolves
120    /// dependency project names against that same record and must not repeat
121    /// the lookup.
122    fn gradle_project_metadata<'a>(
123        &'a self,
124        normalized_wrapper_dir: &Path,
125        project_dir: &Path,
126        normalized_project_dir: &Path,
127        gradlew: &Path,
128    ) -> Result<(&'a GradleWrapperMetadata, String, GradleProperties)> {
129        let wrapper_metadata = self
130            .metadata_by_wrapper
131            .get(normalized_wrapper_dir)
132            .with_context(|| {
133                format!(
134                    "missing Gradle metadata batch for wrapper root '{}' (wrapper '{}') while resolving project directory '{}'",
135                    normalized_wrapper_dir.display(),
136                    gradlew.display(),
137                    project_dir.display()
138                )
139            })?;
140        let metadata = wrapper_metadata
141            .by_project_dir
142            .get(normalized_project_dir)
143            .with_context(|| {
144                format!(
145                    "missing Gradle metadata record for project directory '{}' (normalized: '{}') in the batch emitted by wrapper '{}'",
146                    project_dir.display(),
147                    normalized_project_dir.display(),
148                    gradlew.display()
149                )
150            })?;
151        Ok((
152            wrapper_metadata,
153            metadata.project_path.clone(),
154            metadata.properties.clone(),
155        ))
156    }
157
158    /// Map the `project(":a:b")` dependency paths lexed out of a build file to
159    /// the project names Gradle reported for them.
160    ///
161    /// A miss means the build file references a project the wrapper never
162    /// emitted, so the error names every field needed to locate the mismatch.
163    fn dependency_project_names<'a>(
164        project_names_by_path: &'a HashMap<String, String>,
165        dependencies: &[&str],
166        name: Option<&str>,
167        project_path: &str,
168        manifest_path: &Path,
169        gradlew: &Path,
170    ) -> Result<Vec<&'a str>> {
171        dependencies
172            .iter()
173            .map(|dependency_path| {
174                project_names_by_path
175                    .get(*dependency_path)
176                    .map(String::as_str)
177                    .with_context(|| {
178                        format!(
179                            "Gradle dependency project path '{}' declared by project '{}' (Gradle path '{}', manifest '{}') is missing from metadata emitted by wrapper '{}'",
180                            dependency_path,
181                            name.unwrap_or("<unnamed>"),
182                            project_path,
183                            manifest_path.display(),
184                            gradlew.display()
185                        )
186                    })
187            })
188            .collect::<Result<Vec<_>>>()
189    }
190}
191
192/// Whether `path` is an existing regular file that could be the `java`
193/// executable (on Unix: with at least one exec bit set).
194///
195/// The missing/non-regular/other-error triage is delegated to
196/// [`changepacks_core::regular_file_metadata`], which is also what
197/// [`changepacks_core::is_regular_file`] is built on, so the ladder and its
198/// `Failed to read metadata for ...` context live in exactly one place. That
199/// helper hands back the very [`std::fs::Metadata`] it stat'ed, so the Unix
200/// permission check costs no second syscall.
201async fn is_java_executable_candidate(path: &Path) -> Result<bool> {
202    #[cfg(unix)]
203    {
204        use std::os::unix::fs::PermissionsExt;
205
206        Ok(changepacks_core::regular_file_metadata(path)
207            .await?
208            .is_some_and(|metadata| metadata.permissions().mode() & 0o111 != 0))
209    }
210    #[cfg(not(unix))]
211    {
212        // No exec bit to inspect: being a regular file is the whole test, and
213        // `is_regular_file` is the same `regular_file_metadata` ladder.
214        changepacks_core::is_regular_file(path).await
215    }
216}
217
218/// Core logic for finding `java` in a given PATH value.
219///
220/// Scans the split paths for a `java` / `java.exe` executable.
221/// Returns `None` if `path_var` is `None` or empty.
222///
223/// Metadata errors other than missing candidates are propagated.
224///
225/// This function is testable without mutating process env.
226async fn which_java_in(path_var: Option<&OsStr>) -> Result<Option<PathBuf>> {
227    let Some(path_var) = path_var else {
228        return Ok(None);
229    };
230    if path_var.is_empty() {
231        return Ok(None);
232    }
233    for dir in std::env::split_paths(path_var) {
234        let candidate = dir.join(JAVA_EXECUTABLE);
235        if is_java_executable_candidate(&candidate).await? {
236            return Ok(Some(candidate));
237        }
238    }
239    Ok(None)
240}
241
242async fn java_home_has_java(java_home: Option<&OsStr>) -> Result<bool> {
243    let Some(java_home) = java_home else {
244        return Ok(false);
245    };
246    if java_home.is_empty() {
247        return Ok(false);
248    }
249
250    let candidate = Path::new(java_home).join("bin").join(JAVA_EXECUTABLE);
251    is_java_executable_candidate(&candidate).await
252}
253
254/// The platform-appropriate Gradle wrapper filename.
255fn gradle_wrapper_name(windows: bool) -> &'static str {
256    if windows { "gradlew.bat" } else { "gradlew" }
257}
258
259/// Single source of truth for the "wrapper missing" error message.
260///
261/// Both the discovery path (`GradleProjectFinder::visit`) and the publish path
262/// (`run_gradle_publish`) fail here, and each names the offending manifest the
263/// same way every other fallible step in those functions does. The leading
264/// sentence is a stability contract: `crates/java/src/lib.rs` and
265/// `crates/java/src/package.rs` assert on
266/// `.contains("Gradle wrapper (gradlew) not found")`, so the prefix must stay
267/// byte-identical and the manifest path is appended after it.
268fn gradlew_not_found(manifest: &Path) -> String {
269    format!(
270        "Gradle wrapper (gradlew) not found for '{}'. \
271         Ensure the project root contains gradlew or gradlew.bat.",
272        manifest.display()
273    )
274}
275
276async fn find_gradlew(start_dir: &Path, max_depth: usize) -> Result<Option<(PathBuf, PathBuf)>> {
277    find_gradlew_named(start_dir, max_depth, gradle_wrapper_name(cfg!(windows))).await
278}
279
280/// Find gradlew executable by walking up the directory tree.
281///
282/// In multi-module Gradle builds, `gradlew` lives at the root while subprojects
283/// only contain `build.gradle.kts`. This function searches upward from `start_dir`
284/// until it finds `gradlew` (Unix) or `gradlew.bat` (Windows).
285///
286/// The ancestor walk is BOUNDED to the repository root by `max_depth`: the
287/// caller passes `relative_path.components().count()` — the number of
288/// directories from the project dir up to and INCLUDING the repo root — so
289/// `start_dir.ancestors().take(max_depth)` stops AT the repository root and
290/// never touches the drive root, the user's home dir, or a sibling checkout.
291/// An out-of-repo `gradlew` must never be discovered (and then executed):
292/// project discovery is git-scoped, so a stray wrapper ABOVE the repo root
293/// must not be picked up and run. Mirrors the git-scoped bounds the sibling
294/// C# finder applies in `is_workspace` and the Rust finder applies in its
295/// version-inheritance walk.
296///
297/// Returns `(gradlew_path, gradlew_dir)`, or `None` if not found within the bound.
298async fn find_gradlew_named(
299    start_dir: &Path,
300    max_depth: usize,
301    gradlew_name: &str,
302) -> Result<Option<(PathBuf, PathBuf)>> {
303    // `Path::ancestors()` yields `[start_dir, parent, …, root]`; `take(max_depth)`
304    // caps the climb at the repository root so the walk never leaves the repo
305    // and can never adopt an out-of-repo wrapper.
306    for current in start_dir.ancestors().take(max_depth) {
307        let gradlew = current.join(gradlew_name);
308        // Reject directories while continuing the bounded search; propagate
309        // metadata failures other than a missing wrapper candidate.
310        if changepacks_core::is_regular_file(&gradlew).await? {
311            return Ok(Some((gradlew, current.to_path_buf())));
312        }
313    }
314    Ok(None)
315}
316
317/// Run a built-in Gradle publish task through the repository-bounded wrapper.
318///
319/// The wrapper and task are passed as OS arguments rather than interpolated
320/// into a shell command, so paths containing spaces or shell metacharacters
321/// remain intact. Configured publish commands do not use this path; their
322/// existing shell semantics are preserved by the package/workspace callers.
323pub(crate) async fn run_gradle_publish(
324    manifest_path: &Path,
325    relative_path: &Path,
326    project_path: Option<&str>,
327    task: &str,
328    additional_args: &[OsString],
329    missing_dir_ctx: &'static str,
330) -> Result<changepacks_core::publish::PublishOutput> {
331    let project_dir = manifest_path.parent().context(missing_dir_ctx)?;
332    let max_depth = relative_path.components().count();
333    let (gradlew, gradlew_dir) = find_gradlew(project_dir, max_depth)
334        .await?
335        .with_context(|| gradlew_not_found(manifest_path))?;
336    let mut args = Vec::with_capacity(additional_args.len() + 1);
337    args.push(match project_path {
338        Some(project_path) => gradle_task_arg_from_project_path(project_path, task),
339        None => gradle_task_arg_from_project_dir(project_dir, &gradlew_dir, task)?,
340    });
341    args.extend_from_slice(additional_args);
342    let output = GradleCommandSpec::new(&gradlew, &gradlew_dir, args)
343        .command()
344        .output()
345        .await
346        .with_context(|| format!("Failed to execute Gradle wrapper '{}'", gradlew.display()))?;
347
348    Ok(output.into())
349}
350
351fn gradle_subproject_path(relative: &Path) -> Result<String> {
352    // Preallocate against the source path's byte length: each `:` separator we
353    // push is 1 byte and maps 1:1 to a path-separator byte already counted in
354    // `as_os_str().len()`, so that length is a safe upper bound for the joined
355    // `:`-separated output — removing the geometric-doubling reallocations for
356    // deep subprojects. Matches the preallocation policy used elsewhere in the
357    // finders.
358    let mut path = String::with_capacity(relative.as_os_str().len());
359    for component in relative.components() {
360        let value = component.as_os_str().to_str().with_context(|| {
361            format!(
362                "Gradle subproject path contains a non-Unicode component: {}",
363                relative.display()
364            )
365        })?;
366        if !path.is_empty() {
367            path.push(':');
368        }
369        path.push_str(value);
370    }
371    Ok(path)
372}
373
374/// Returns true when a Java runtime is reachable from the supplied `JAVA_HOME`
375/// and `PATH` values.
376///
377/// The environment read lives in [`java_is_available`] so that this decision —
378/// "`JAVA_HOME` wins, otherwise fall back to a `PATH` scan" — can be exercised
379/// for both outcomes without mutating process environment, which edition 2024
380/// makes `unsafe` and `[workspace.lints.rust] unsafe_code = "deny"` forbids.
381/// Same split as `run_publish_command`'s env shim in `changepacks-core`.
382async fn java_is_available_in(java_home: Option<&OsStr>, path: Option<&OsStr>) -> Result<bool> {
383    if java_home_has_java(java_home).await? {
384        return Ok(true);
385    }
386    Ok(which_java_in(path).await?.is_some())
387}
388
389/// Returns true when a Java runtime is available via `JAVA_HOME` or PATH.
390async fn java_is_available() -> Result<bool> {
391    let java_home = std::env::var_os("JAVA_HOME");
392    let path = std::env::var_os("PATH");
393    java_is_available_in(java_home.as_deref(), path.as_deref()).await
394}
395
396fn gradle_task_arg_from_project_path(project_path: &str, task: &str) -> OsString {
397    if project_path == ":" {
398        OsString::from(task)
399    } else {
400        OsString::from(format!("{project_path}:{task}"))
401    }
402}
403
404fn gradle_task_arg_from_project_dir(
405    project_dir: &Path,
406    gradlew_dir: &Path,
407    task: &str,
408) -> Result<OsString> {
409    if gradlew_dir == project_dir {
410        return Ok(OsString::from(task));
411    }
412
413    let relative = project_dir
414        .strip_prefix(gradlew_dir)
415        .context("Failed to compute subproject path")?;
416    let gradle_path = gradle_subproject_path(relative)?;
417    Ok(OsString::from(format!(":{gradle_path}:{task}")))
418}
419
420/// Argument/working-directory bundle for one Gradle wrapper invocation.
421///
422/// Shared by `finder.rs` (publish tasks) and `gradle_metadata.rs` (batched
423/// metadata discovery), so it stays here as `pub(crate)`.
424#[derive(Debug)]
425pub(crate) struct GradleCommandSpec {
426    program: OsString,
427    args: Vec<OsString>,
428    current_dir: PathBuf,
429}
430
431impl GradleCommandSpec {
432    pub(crate) fn new(gradlew: &Path, gradlew_dir: &Path, gradle_args: Vec<OsString>) -> Self {
433        Self::for_platform(gradlew, gradlew_dir, gradle_args, cfg!(windows))
434    }
435
436    /// Build the spec for an explicitly named platform.
437    ///
438    /// `windows` is a parameter rather than a `cfg!(windows)` read so both
439    /// layouts — the wrapper as the program on Windows, `sh <wrapper>`
440    /// elsewhere — stay reachable from a single host, mirroring
441    /// [`gradle_wrapper_name`]. A `cfg!` read here would leave whichever arm
442    /// does not match the build target permanently unexecuted.
443    fn for_platform(
444        gradlew: &Path,
445        gradlew_dir: &Path,
446        gradle_args: Vec<OsString>,
447        windows: bool,
448    ) -> Self {
449        let mut args = Vec::with_capacity(gradle_args.len() + usize::from(!windows));
450        let program = if windows {
451            gradlew.as_os_str().to_owned()
452        } else {
453            args.push(gradlew.as_os_str().to_owned());
454            OsString::from("sh")
455        };
456        args.extend(gradle_args);
457
458        Self {
459            program,
460            args,
461            current_dir: gradlew_dir.to_path_buf(),
462        }
463    }
464
465    pub(crate) fn command(&self) -> Command {
466        let mut command = Command::new(&self.program);
467        command
468            .args(&self.args)
469            .current_dir(&self.current_dir)
470            .kill_on_drop(true);
471        command
472    }
473}
474
475#[async_trait]
476impl ProjectFinder for GradleProjectFinder {
477    changepacks_core::impl_projects_hashmap_accessors!();
478
479    fn project_files(&self) -> &[&str] {
480        PROJECT_FILES
481    }
482
483    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
484        // Parse this manifest if it is a recognized project file not already
485        // visited. Both guards live in `ProjectFinder::should_visit_manifest`
486        // (name/stat gate first, already-discovered map probe second) so the
487        // prelude is written once for every file-name-based finder.
488        if !self.should_visit_manifest(path).await? {
489            return Ok(());
490        }
491
492        let project_dir = manifest_parent_dir(path)?;
493
494        let java_available = if let Some(value) = self.java_available {
495            value
496        } else {
497            let value = java_is_available().await?;
498            self.java_available = Some(value);
499            value
500        };
501
502        // Read Gradle build file first (fail fast if unreadable)
503        let content = read_gradle_build_file(path).await?;
504        let dependencies = extract_gradle_project_dependencies(&content, gradle_dialect_for(path));
505
506        let (normalized_wrapper_dir, gradlew) = self
507            .resolve_wrapper_metadata(path, project_dir, relative_path, java_available)
508            .await?;
509
510        let normalized_project_dir =
511            tokio::fs::canonicalize(project_dir)
512                .await
513                .with_context(|| {
514                    format!(
515                        "Failed to normalize Gradle project directory '{}' for '{}'",
516                        project_dir.display(),
517                        path.display()
518                    )
519                })?;
520        let (wrapper_metadata, project_path, properties) = self.gradle_project_metadata(
521            &normalized_wrapper_dir,
522            project_dir,
523            &normalized_project_dir,
524            &gradlew,
525        )?;
526        let GradleProperties {
527            name,
528            version,
529            has_subprojects,
530            has_publish_task,
531            has_publish_to_maven_local_task,
532        } = properties;
533
534        // Use directory name as fallback for project name
535        let name = name.or_else(|| {
536            project_dir
537                .file_name()
538                .and_then(|n| n.to_str())
539                .map(std::string::ToString::to_string)
540        });
541
542        let dependency_names = Self::dependency_project_names(
543            &wrapper_metadata.project_names_by_path,
544            &dependencies,
545            name.as_deref(),
546            &project_path,
547            path,
548            &gradlew,
549        )?;
550
551        // Workspace detection: gradlew reports non-empty subprojects list.
552        // Previous approach (checking for settings.gradle.kts existence) caused
553        // false positives in composite builds and subprojects with IDE-generated files.
554        let is_workspace = has_subprojects;
555
556        // Hoist the map key allocation out of both arms: the old shape
557        // built a `(PathBuf, Project)` tuple, which forced each branch
558        // to call `path.to_path_buf()` TWICE (once for the tuple slot,
559        // once again for `*::new`). One shared `path_key` + one
560        // `.clone()` into the constructor cuts 4 `PathBuf` allocs to 2.
561        let path_key = path.to_path_buf();
562        let relative_path_key = relative_path.to_path_buf();
563        let mut project = changepacks_core::discovered_project!(
564            is_workspace,
565            GradleWorkspace::new_with_project_path_and_publish_tasks,
566            GradlePackage::new_with_project_path_and_publish_tasks,
567            name,
568            version,
569            path_key.clone(),
570            relative_path_key,
571            Some(project_path),
572            has_publish_task,
573            has_publish_to_maven_local_task,
574        );
575
576        for dependency in dependency_names {
577            project.add_dependency(dependency);
578        }
579
580        self.projects.insert(path_key, project);
581        Ok(())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::gradle_metadata::GRADLE_METADATA_PREFIX;
589    use changepacks_core::{Project, UpdateType};
590    use changepacks_utils::{apply_reverse_dependencies, sort_by_dependencies};
591    use rstest::rstest;
592    use std::collections::HashSet;
593    use std::fs;
594    use tempfile::TempDir;
595
596    fn finder_with_java_available() -> GradleProjectFinder {
597        GradleProjectFinder {
598            java_available: Some(true),
599            ..GradleProjectFinder::default()
600        }
601    }
602
603    async fn dependencies_for_manifest(manifest_name: &str, content: &str) -> HashSet<String> {
604        let temp_dir = TempDir::new().unwrap();
605        let project_dir = temp_dir.path().join("project");
606        tokio::fs::create_dir_all(&project_dir).await.unwrap();
607        let manifest = project_dir.join(manifest_name);
608        tokio::fs::write(&manifest, content).await.unwrap();
609        let dependency_paths =
610            super::extract_gradle_project_dependencies(content, gradle_dialect_for(&manifest));
611        let mut records = vec![metadata_record(&project_dir, ":", "project", false)];
612        for (index, dependency_path) in dependency_paths.iter().enumerate() {
613            let dependency_dir = project_dir.join(format!("dependency-{index}"));
614            tokio::fs::create_dir_all(&dependency_dir).await.unwrap();
615            let dependency_name = dependency_path.rsplit(':').next().unwrap();
616            records.push(metadata_record(
617                &dependency_dir,
618                dependency_path,
619                dependency_name,
620                false,
621            ));
622        }
623        create_metadata_gradlew(&project_dir, &records).await;
624
625        let mut finder = finder_with_java_available();
626        finder
627            .visit(&manifest, &PathBuf::from("project").join(manifest_name))
628            .await
629            .unwrap();
630        let dependencies = finder.projects()[0].dependencies().clone();
631
632        temp_dir.close().unwrap();
633        dependencies
634    }
635
636    #[test]
637    fn test_gradle_wrapper_name_selects_platform_variant() {
638        assert_eq!(gradle_wrapper_name(false), "gradlew");
639        assert_eq!(gradle_wrapper_name(true), "gradlew.bat");
640    }
641
642    #[tokio::test]
643    async fn test_find_gradlew_accepts_both_wrapper_filenames_and_respects_bound() {
644        for wrapper_name in ["gradlew", "gradlew.bat"] {
645            let temp_dir = TempDir::new().unwrap();
646            let repo = temp_dir.path().join("repo");
647            let project = repo.join("nested");
648            fs::create_dir_all(&project).unwrap();
649            fs::write(repo.join(wrapper_name), "wrapper").unwrap();
650            fs::write(
651                temp_dir.path().join(if wrapper_name == "gradlew" {
652                    "gradlew.bat"
653                } else {
654                    "gradlew"
655                }),
656                "out-of-repo decoy",
657            )
658            .unwrap();
659
660            let found = find_gradlew_named(&project, 2, wrapper_name)
661                .await
662                .unwrap()
663                .unwrap();
664
665            assert_eq!(found.0, repo.join(wrapper_name));
666            assert_eq!(found.1, repo);
667        }
668    }
669
670    // Both `GradleProjectFinder::new()` and `GradleProjectFinder::default()`
671    // must yield the same empty finder that recognizes both Kotlin and
672    // Groovy Gradle manifests.
673    #[rstest]
674    #[case(GradleProjectFinder::new())]
675    #[case(GradleProjectFinder::default())]
676    fn test_gradle_project_finder_construction(#[case] finder: GradleProjectFinder) {
677        assert_eq!(
678            finder.project_files(),
679            &["build.gradle.kts", "build.gradle"]
680        );
681        assert_eq!(finder.projects().len(), 0);
682    }
683
684    #[derive(Clone, Copy)]
685    struct MockGradlew<'a> {
686        name: &'a str,
687        version: &'a str,
688        subprojects: &'a str,
689        has_publish_task: bool,
690        has_publish_to_maven_local_task: bool,
691    }
692
693    impl<'a> MockGradlew<'a> {
694        fn package(name: &'a str, version: &'a str) -> Self {
695            Self {
696                name,
697                version,
698                subprojects: "[]",
699                has_publish_task: true,
700                has_publish_to_maven_local_task: true,
701            }
702        }
703
704        fn workspace(name: &'a str, version: &'a str, subprojects: &'a str) -> Self {
705            Self {
706                name,
707                version,
708                subprojects,
709                has_publish_task: true,
710                has_publish_to_maven_local_task: true,
711            }
712        }
713
714        fn with_publish_tasks(
715            mut self,
716            has_publish_task: bool,
717            has_publish_to_maven_local_task: bool,
718        ) -> Self {
719            self.has_publish_task = has_publish_task;
720            self.has_publish_to_maven_local_task = has_publish_to_maven_local_task;
721            self
722        }
723    }
724
725    /// Create a mock gradlew in the given directory that emits batched metadata.
726    fn create_mock_gradlew(dir: &Path, mock: MockGradlew<'_>) {
727        let record = format!(
728            "{GRADLE_METADATA_PREFIX}{{\"projectDir\":{},\"projectPath\":\":\",\"name\":{},\"version\":{},\"aggregate\":{},\"hasPublishTask\":{},\"hasPublishToMavenLocalTask\":{}}}",
729            json_string(dir.to_string_lossy().as_ref()),
730            json_string(mock.name),
731            json_string(mock.version),
732            mock.subprojects != "[]",
733            mock.has_publish_task,
734            mock.has_publish_to_maven_local_task,
735        );
736        if cfg!(windows) {
737            fs::write(
738                dir.join("gradlew.bat"),
739                format!("@echo off\r\necho {record}\r\n"),
740            )
741            .unwrap();
742        } else {
743            let gradlew_path = dir.join("gradlew");
744            fs::write(
745                &gradlew_path,
746                format!("#!/bin/sh\nprintf '%s\\n' '{record}'\n"),
747            )
748            .unwrap();
749            #[cfg(unix)]
750            make_executable(&gradlew_path);
751        }
752    }
753
754    /// Text the failing wrapper writes to its stderr. Asserting on this exact
755    /// marker is what proves the `; stderr: ...` suffix of the metadata-failure
756    /// message carries the WRAPPER's own diagnostics, rather than some
757    /// incidental text that would also satisfy a bare "`is_err`" check.
758    const FAILING_GRADLEW_STDERR_MARKER: &str = "changepacks-gradle-failure-marker";
759
760    /// Create a wrapper that runs successfully but exits non-zero after writing
761    /// to stderr — the shape of an ordinary failing Gradle build, as opposed to
762    /// a wrapper that cannot be spawned at all.
763    fn create_failing_gradlew(dir: &Path) {
764        if cfg!(windows) {
765            fs::write(
766                dir.join("gradlew.bat"),
767                format!("@echo off\r\n(echo {FAILING_GRADLEW_STDERR_MARKER})>&2\r\nexit /b 1\r\n"),
768            )
769            .unwrap();
770        } else {
771            let gradlew_path = dir.join("gradlew");
772            fs::write(
773                &gradlew_path,
774                format!("#!/bin/sh\necho '{FAILING_GRADLEW_STDERR_MARKER}' >&2\nexit 1\n"),
775            )
776            .unwrap();
777            #[cfg(unix)]
778            make_executable(&gradlew_path);
779        }
780    }
781
782    /// Render `value` as a JSON string literal (quotes included), escaping every
783    /// character JSON requires instead of the handful a hand-rolled escaper covers.
784    fn json_string(value: &str) -> String {
785        serde_json::Value::String(value.to_owned()).to_string()
786    }
787
788    fn create_counting_multi_project_gradlew(
789        dir: &Path,
790        root_project_dir: &Path,
791        child_project_dir: &Path,
792        child_project_path: &str,
793        emit_child_record: bool,
794    ) -> PathBuf {
795        let invocation_count = dir.join("wrapper-invocations.txt");
796        let prefix = GRADLE_METADATA_PREFIX;
797        let root_record = format!(
798            "{prefix}{{\"projectDir\":{},\"projectPath\":\":\",\"name\":\"root project\",\"version\":\"1.2.3\",\"aggregate\":true,\"hasPublishTask\":true,\"hasPublishToMavenLocalTask\":true}}",
799            json_string(root_project_dir.to_string_lossy().as_ref())
800        );
801        let child_record = format!(
802            "{prefix}{{\"projectDir\":{},\"projectPath\":{},\"name\":\"child project\",\"version\":\"2.3.4\",\"aggregate\":false,\"hasPublishTask\":true,\"hasPublishToMavenLocalTask\":true}}",
803            json_string(child_project_dir.to_string_lossy().as_ref()),
804            json_string(child_project_path),
805        );
806        let batch_records = if emit_child_record {
807            format!(
808                "echo {root_record}\r\necho unrelated __CHANGEPACKS_GRADLE_METADATA text\r\necho {child_record}\r\n"
809            )
810        } else {
811            format!("echo {root_record}\r\n")
812        };
813        let unix_batch_records = if emit_child_record {
814            format!(
815                "printf '%s\\n' '{root_record}' 'unrelated __CHANGEPACKS_GRADLE_METADATA text' '{child_record}'"
816            )
817        } else {
818            format!("printf '%s\\n' '{root_record}'")
819        };
820
821        if cfg!(windows) {
822            fs::write(
823                dir.join("gradlew.bat"),
824                format!(
825                    "@echo off\r\n\
826                     type nul >\"metadata-command-args.txt\"\r\n\
827                     for %%A in (%*) do echo %%~A>>\"metadata-command-args.txt\"\r\n\
828                     set count=0\r\n\
829                     if exist \"wrapper-invocations.txt\" set /p count=<\"wrapper-invocations.txt\"\r\n\
830                     set /a count+=1\r\n\
831                     >\"wrapper-invocations.txt\" echo %count%\r\n\
832                     {batch_records}\
833                     exit /b 0\r\n"
834                ),
835            )
836            .unwrap();
837        } else {
838            let gradlew_path = dir.join("gradlew");
839            fs::write(
840                &gradlew_path,
841                format!(
842                    "#!/bin/sh\n\
843                     : > metadata-command-args.txt\n\
844                     for arg in \"$@\"; do printf '%s\\n' \"$arg\" >> metadata-command-args.txt; done\n\
845                     count=$(cat wrapper-invocations.txt 2>/dev/null || printf 0)\n\
846                     count=$((count + 1))\n\
847                     printf '%s\\n' \"$count\" > wrapper-invocations.txt\n\
848                     {unix_batch_records}\n"
849                ),
850            )
851            .unwrap();
852            #[cfg(unix)]
853            make_executable(&gradlew_path);
854        }
855
856        invocation_count
857    }
858
859    fn metadata_record(
860        project_dir: &Path,
861        project_path: &str,
862        name: &str,
863        aggregate: bool,
864    ) -> String {
865        format!(
866            "{GRADLE_METADATA_PREFIX}{{\"projectDir\":{},\"projectPath\":{},\"name\":{},\"version\":\"1.0.0\",\"aggregate\":{aggregate},\"hasPublishTask\":true,\"hasPublishToMavenLocalTask\":true}}",
867            json_string(project_dir.to_string_lossy().as_ref()),
868            json_string(project_path),
869            json_string(name),
870        )
871    }
872
873    async fn create_metadata_gradlew(dir: &Path, records: &[String]) {
874        if cfg!(windows) {
875            let mut output = String::new();
876            for record in records {
877                output.push_str("echo ");
878                output.push_str(record);
879                output.push_str("\r\n");
880            }
881            tokio::fs::write(
882                dir.join("gradlew.bat"),
883                format!("@echo off\r\n{output}exit /b 0\r\n"),
884            )
885            .await
886            .unwrap();
887        } else {
888            let mut output = String::new();
889            for record in records {
890                output.push_str("printf '%s\\n' '");
891                output.push_str(record);
892                output.push_str("'\n");
893            }
894            let gradlew = dir.join("gradlew");
895            tokio::fs::write(&gradlew, format!("#!/bin/sh\n{output}"))
896                .await
897                .unwrap();
898            #[cfg(unix)]
899            {
900                use std::os::unix::fs::PermissionsExt;
901
902                tokio::fs::set_permissions(&gradlew, fs::Permissions::from_mode(0o755))
903                    .await
904                    .unwrap();
905            }
906        }
907    }
908
909    #[tokio::test]
910    async fn test_gradle_metadata_command_disables_lazy_and_cached_configuration() {
911        let temp_dir = TempDir::new().unwrap();
912        let repo = temp_dir.path().join("repo");
913        let child_dir = repo.join("child");
914        fs::create_dir_all(&child_dir).unwrap();
915        let root_manifest = repo.join("build.gradle.kts");
916        fs::write(&root_manifest, "plugins { java }\n").unwrap();
917        create_counting_multi_project_gradlew(&repo, &repo, &child_dir, ":module one", true);
918
919        let mut finder = finder_with_java_available();
920        finder
921            .visit(&root_manifest, Path::new("build.gradle.kts"))
922            .await
923            .unwrap();
924
925        let actual = fs::read_to_string(repo.join("metadata-command-args.txt"))
926            .unwrap()
927            .lines()
928            .map(std::string::ToString::to_string)
929            .collect::<Vec<_>>();
930        let init_script = actual
931            .iter()
932            .find(|argument| argument.ends_with(".gradle"))
933            .unwrap()
934            .clone();
935        assert_eq!(
936            actual,
937            vec![
938                "-Dorg.gradle.configureondemand=false".to_string(),
939                "-Dorg.gradle.configuration-cache=false".to_string(),
940                "--init-script".to_string(),
941                init_script,
942                "--quiet".to_string(),
943                "help".to_string(),
944            ]
945        );
946
947        temp_dir.close().unwrap();
948    }
949
950    #[tokio::test]
951    async fn test_gradle_finder_batches_metadata_per_wrapper_root() {
952        let temp_dir = TempDir::new().unwrap();
953        let repo = temp_dir.path().join("repo with spaces");
954        let child_dir = repo.join("module one");
955        fs::create_dir_all(&child_dir).unwrap();
956        let root_manifest = repo.join("build.gradle.kts");
957        let child_manifest = child_dir.join("build.gradle.kts");
958        fs::write(&root_manifest, "plugins { java }\n").unwrap();
959        fs::write(&child_manifest, "plugins { java }\n").unwrap();
960        let invocation_count =
961            create_counting_multi_project_gradlew(&repo, &repo, &child_dir, ":module one", true);
962
963        let mut finder = finder_with_java_available();
964        finder
965            .visit(&root_manifest, Path::new("build.gradle.kts"))
966            .await
967            .unwrap();
968        finder
969            .visit(
970                &child_manifest,
971                Path::new("module one").join("build.gradle.kts").as_path(),
972            )
973            .await
974            .unwrap();
975
976        let projects = finder.projects();
977        assert_eq!(projects.len(), 2);
978        let root = projects
979            .iter()
980            .copied()
981            .find(|project| project.name() == Some("root project"))
982            .unwrap();
983        let child = projects
984            .iter()
985            .copied()
986            .find(|project| project.name() == Some("child project"))
987            .unwrap();
988        assert!(matches!(root, Project::Workspace(_)));
989        assert_eq!(root.version(), Some("1.2.3"));
990        assert!(matches!(child, Project::Package(_)));
991        assert_eq!(child.version(), Some("2.3.4"));
992        assert_eq!(fs::read_to_string(invocation_count).unwrap().trim(), "1");
993
994        temp_dir.close().unwrap();
995    }
996
997    #[tokio::test]
998    async fn test_gradle_finder_canonicalizes_shared_wrapper_root_once() {
999        let temp_dir = TempDir::new().unwrap();
1000        let repo = temp_dir.path().join("repo with spaces");
1001        let alpha_dir = repo.join("alpha");
1002        let beta_dir = repo.join("beta");
1003        fs::create_dir_all(&alpha_dir).unwrap();
1004        fs::create_dir_all(&beta_dir).unwrap();
1005        let root_manifest = repo.join("build.gradle.kts");
1006        let alpha_manifest = alpha_dir.join("build.gradle.kts");
1007        let beta_manifest = beta_dir.join("build.gradle.kts");
1008        for manifest in [&root_manifest, &alpha_manifest, &beta_manifest] {
1009            fs::write(manifest, "plugins { java }\n").unwrap();
1010        }
1011        create_metadata_gradlew(
1012            &repo,
1013            &[
1014                metadata_record(&repo, ":", "root project", true),
1015                metadata_record(&alpha_dir, ":alpha", "alpha", false),
1016                metadata_record(&beta_dir, ":beta", "beta", false),
1017            ],
1018        )
1019        .await;
1020
1021        let mut finder = finder_with_java_available();
1022        for (manifest, relative) in [
1023            (&root_manifest, PathBuf::from("build.gradle.kts")),
1024            (&alpha_manifest, Path::new("alpha").join("build.gradle.kts")),
1025            (&beta_manifest, Path::new("beta").join("build.gradle.kts")),
1026        ] {
1027            finder.visit(manifest, &relative).await.unwrap();
1028        }
1029
1030        // One wrapper root shared by three manifests: exactly one canonicalize
1031        // result is cached, and it is the canonical repository root.
1032        let normalized_repo = tokio::fs::canonicalize(&repo).await.unwrap();
1033        assert_eq!(
1034            finder.wrapper_dir_canonical.values().collect::<Vec<_>>(),
1035            vec![&normalized_repo]
1036        );
1037        assert_eq!(
1038            finder.metadata_by_wrapper.keys().collect::<Vec<_>>(),
1039            vec![&normalized_repo]
1040        );
1041
1042        // Both siblings still resolve their own metadata record through that
1043        // single shared wrapper root.
1044        let mut names = finder
1045            .projects()
1046            .iter()
1047            .map(|project| project.name().unwrap().to_string())
1048            .collect::<Vec<_>>();
1049        names.sort();
1050        assert_eq!(names, vec!["alpha", "beta", "root project"]);
1051        for name in ["alpha", "beta"] {
1052            let project = finder
1053                .projects()
1054                .iter()
1055                .copied()
1056                .find(|project| project.name() == Some(name))
1057                .unwrap();
1058            assert!(matches!(project, Project::Package(_)));
1059            assert_eq!(project.version(), Some("1.0.0"));
1060        }
1061
1062        temp_dir.close().unwrap();
1063    }
1064
1065    #[tokio::test]
1066    async fn test_gradle_finder_publish_uses_metadata_project_path_for_exact_argv() {
1067        let temp_dir = TempDir::new().unwrap();
1068        let repo = temp_dir.path().join("repo with spaces");
1069        let child_dir = repo.join("generated-backend");
1070        fs::create_dir_all(&child_dir).unwrap();
1071        let child_manifest = child_dir.join("build.gradle.kts");
1072        fs::write(&child_manifest, "plugins { java }\n").unwrap();
1073        create_counting_multi_project_gradlew(&repo, &repo, &child_dir, ":api", true);
1074
1075        let mut finder = finder_with_java_available();
1076        finder
1077            .visit(
1078                &child_manifest,
1079                Path::new("generated-backend/build.gradle.kts"),
1080            )
1081            .await
1082            .unwrap();
1083        let project = finder.projects()[0];
1084
1085        let output = project
1086            .publish(&changepacks_core::Config::default())
1087            .await
1088            .unwrap();
1089        assert!(output.success, "stderr: {}", output.stderr);
1090        let publish_args = fs::read_to_string(repo.join("metadata-command-args.txt"))
1091            .unwrap()
1092            .lines()
1093            .map(std::string::ToString::to_string)
1094            .collect::<Vec<_>>();
1095        assert_eq!(publish_args, [":api:publish"]);
1096
1097        let dry_run = project
1098            .dry_run_publish(&changepacks_core::Config::default())
1099            .await
1100            .unwrap()
1101            .unwrap();
1102        assert!(dry_run.success, "stderr: {}", dry_run.stderr);
1103        let dry_run_args = fs::read_to_string(repo.join("metadata-command-args.txt"))
1104            .unwrap()
1105            .lines()
1106            .map(std::string::ToString::to_string)
1107            .collect::<Vec<_>>();
1108        assert_eq!(dry_run_args.len(), 2, "args: {dry_run_args:?}");
1109        assert_eq!(dry_run_args[0], ":api:publishToMavenLocal");
1110        assert!(dry_run_args[1].starts_with("-Dmaven.repo.local="));
1111
1112        temp_dir.close().unwrap();
1113    }
1114
1115    #[tokio::test]
1116    async fn test_gradle_finder_carries_package_publish_task_availability() {
1117        let temp_dir = TempDir::new().unwrap();
1118        let manifest = temp_dir.path().join("build.gradle.kts");
1119        fs::write(&manifest, "plugins { java }\n").unwrap();
1120        create_mock_gradlew(
1121            temp_dir.path(),
1122            MockGradlew::package("remote-only", "1.0.0").with_publish_tasks(true, false),
1123        );
1124
1125        let mut finder = finder_with_java_available();
1126        finder
1127            .visit(&manifest, Path::new("build.gradle.kts"))
1128            .await
1129            .unwrap();
1130
1131        let project = finder.projects()[0];
1132        assert!(matches!(project, Project::Package(_)));
1133        assert!(project.is_publishable_by_default());
1134        assert!(!project.is_dry_run_publishable_by_default());
1135    }
1136
1137    #[tokio::test]
1138    async fn test_gradle_finder_carries_workspace_publish_task_availability() {
1139        let temp_dir = TempDir::new().unwrap();
1140        let manifest = temp_dir.path().join("build.gradle.kts");
1141        fs::write(&manifest, "plugins { java }\n").unwrap();
1142        create_mock_gradlew(
1143            temp_dir.path(),
1144            MockGradlew::workspace("local-only", "1.0.0", "[project ':child']")
1145                .with_publish_tasks(false, true),
1146        );
1147
1148        let mut finder = finder_with_java_available();
1149        finder
1150            .visit(&manifest, Path::new("build.gradle.kts"))
1151            .await
1152            .unwrap();
1153
1154        let project = finder.projects()[0];
1155        assert!(matches!(project, Project::Workspace(_)));
1156        assert!(!project.is_publishable_by_default());
1157        assert!(project.is_dry_run_publishable_by_default());
1158    }
1159
1160    #[tokio::test]
1161    async fn test_gradle_finder_errors_when_batch_metadata_record_is_missing() {
1162        let temp_dir = TempDir::new().unwrap();
1163        let repo = temp_dir.path().join("repo");
1164        let child_dir = repo.join("module one");
1165        fs::create_dir_all(&child_dir).unwrap();
1166        let root_manifest = repo.join("build.gradle.kts");
1167        let child_manifest = child_dir.join("build.gradle.kts");
1168        fs::write(&root_manifest, "plugins { java }\n").unwrap();
1169        fs::write(&child_manifest, "plugins { java }\n").unwrap();
1170        create_counting_multi_project_gradlew(&repo, &repo, &child_dir, ":module one", false);
1171
1172        let mut finder = finder_with_java_available();
1173        finder
1174            .visit(&root_manifest, Path::new("build.gradle.kts"))
1175            .await
1176            .unwrap();
1177        let error = finder
1178            .visit(
1179                &child_manifest,
1180                Path::new("module one").join("build.gradle.kts").as_path(),
1181            )
1182            .await
1183            .unwrap_err();
1184
1185        assert!(error.to_string().contains("missing Gradle metadata record"));
1186        assert!(
1187            error
1188                .to_string()
1189                .contains(child_dir.to_string_lossy().as_ref())
1190        );
1191        assert!(
1192            error.to_string().contains(
1193                repo.join(gradle_wrapper_name(cfg!(windows)))
1194                    .to_string_lossy()
1195                    .as_ref()
1196            )
1197        );
1198
1199        temp_dir.close().unwrap();
1200    }
1201
1202    /// The wrapper-record miss and the project-record miss are structurally
1203    /// different failures, so they must not collapse into one indistinguishable
1204    /// message.
1205    #[tokio::test]
1206    async fn test_gradle_project_metadata_distinguishes_wrapper_and_project_misses() {
1207        let wrapper_dir = PathBuf::from("repo");
1208        let gradlew = wrapper_dir.join(gradle_wrapper_name(cfg!(windows)));
1209        let project_dir = wrapper_dir.join("module one");
1210
1211        // Wrapper batch present, but it holds no record for the queried project.
1212        let mut finder = finder_with_java_available();
1213        finder.metadata_by_wrapper.insert(
1214            wrapper_dir.clone(),
1215            GradleWrapperMetadata {
1216                by_project_dir: HashMap::new(),
1217                project_names_by_path: HashMap::new(),
1218            },
1219        );
1220
1221        let project_miss = finder
1222            .gradle_project_metadata(&wrapper_dir, &project_dir, &project_dir, &gradlew)
1223            .unwrap_err()
1224            .to_string();
1225        assert!(
1226            project_miss.contains("missing Gradle metadata record for project directory"),
1227            "unexpected error: {project_miss}"
1228        );
1229        assert!(project_miss.contains("in the batch emitted by wrapper"));
1230        assert!(project_miss.contains(project_dir.to_string_lossy().as_ref()));
1231        assert!(project_miss.contains(gradlew.to_string_lossy().as_ref()));
1232
1233        // No batch at all was recorded for this wrapper root.
1234        let unknown_wrapper_dir = PathBuf::from("other-repo");
1235        let wrapper_miss = finder
1236            .gradle_project_metadata(&unknown_wrapper_dir, &project_dir, &project_dir, &gradlew)
1237            .unwrap_err()
1238            .to_string();
1239        assert!(
1240            wrapper_miss.contains("missing Gradle metadata batch for wrapper root"),
1241            "unexpected error: {wrapper_miss}"
1242        );
1243        assert!(wrapper_miss.contains(unknown_wrapper_dir.to_string_lossy().as_ref()));
1244        assert!(wrapper_miss.contains(gradlew.to_string_lossy().as_ref()));
1245
1246        assert_ne!(project_miss, wrapper_miss);
1247    }
1248
1249    #[test]
1250    fn test_gradle_publish_task_args_for_root_project() {
1251        let args = [
1252            gradle_task_arg_from_project_path(":", "publish"),
1253            gradle_task_arg_from_project_path(":", "publishToMavenLocal"),
1254        ];
1255
1256        assert_eq!(
1257            args,
1258            [
1259                OsString::from("publish"),
1260                OsString::from("publishToMavenLocal")
1261            ]
1262        );
1263    }
1264
1265    #[test]
1266    fn test_gradle_publish_task_args_for_ordinary_nested_project() {
1267        let args = [
1268            gradle_task_arg_from_project_path(":libs:core", "publish"),
1269            gradle_task_arg_from_project_path(":libs:core", "publishToMavenLocal"),
1270        ];
1271
1272        assert_eq!(
1273            args,
1274            [
1275                OsString::from(":libs:core:publish"),
1276                OsString::from(":libs:core:publishToMavenLocal")
1277            ]
1278        );
1279    }
1280
1281    #[test]
1282    fn test_gradle_publish_task_args_for_filesystem_remapped_project() {
1283        let filesystem_path = gradle_subproject_path(Path::new("generated/backend")).unwrap();
1284        assert_eq!(filesystem_path, "generated:backend");
1285        assert_ne!(format!(":{filesystem_path}"), ":api");
1286
1287        let args = [
1288            gradle_task_arg_from_project_path(":api", "publish"),
1289            gradle_task_arg_from_project_path(":api", "publishToMavenLocal"),
1290        ];
1291
1292        assert_eq!(
1293            args,
1294            [
1295                OsString::from(":api:publish"),
1296                OsString::from(":api:publishToMavenLocal")
1297            ]
1298        );
1299    }
1300
1301    #[test]
1302    fn test_gradle_task_arg_from_project_dir_for_wrapper_root_project() {
1303        let root = Path::new("/repo");
1304
1305        let arg = gradle_task_arg_from_project_dir(root, root, "publish").unwrap();
1306
1307        assert_eq!(arg, OsString::from("publish"));
1308    }
1309
1310    #[test]
1311    fn test_gradle_task_arg_from_project_dir_for_nested_project() {
1312        let arg =
1313            gradle_task_arg_from_project_dir(Path::new("/repo/sub"), Path::new("/repo"), "publish")
1314                .unwrap();
1315
1316        assert_eq!(arg, OsString::from(":sub:publish"));
1317    }
1318
1319    #[test]
1320    fn test_gradle_task_arg_from_project_dir_for_deeply_nested_project() {
1321        let arg = gradle_task_arg_from_project_dir(
1322            Path::new("/repo/libs/core"),
1323            Path::new("/repo"),
1324            "publishToMavenLocal",
1325        )
1326        .unwrap();
1327
1328        assert_eq!(arg, OsString::from(":libs:core:publishToMavenLocal"));
1329    }
1330
1331    #[test]
1332    fn test_gradle_task_arg_from_project_dir_rejects_non_descendant_project() {
1333        let error = gradle_task_arg_from_project_dir(
1334            Path::new("/elsewhere/sub"),
1335            Path::new("/repo"),
1336            "publish",
1337        )
1338        .unwrap_err();
1339
1340        assert!(
1341            error
1342                .to_string()
1343                .contains("Failed to compute subproject path"),
1344            "unexpected error: {error}"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_gradle_command_spec_matches_active_platform_layout() {
1350        let gradlew = Path::new("repo").join(if cfg!(windows) {
1351            "gradlew.bat"
1352        } else {
1353            "gradlew"
1354        });
1355        let args = vec![OsString::from("--quiet"), OsString::from("help")];
1356
1357        let spec = GradleCommandSpec::new(&gradlew, Path::new("repo"), args);
1358
1359        if cfg!(windows) {
1360            assert_eq!(spec.program, gradlew.as_os_str());
1361            assert_eq!(
1362                spec.args,
1363                vec![OsString::from("--quiet"), OsString::from("help")]
1364            );
1365        } else {
1366            assert_eq!(spec.program, OsString::from("sh"));
1367            assert_eq!(spec.args[0], gradlew.as_os_str());
1368            assert_eq!(
1369                spec.args[1..],
1370                [OsString::from("--quiet"), OsString::from("help")]
1371            );
1372        }
1373        assert_eq!(spec.current_dir, PathBuf::from("repo"));
1374    }
1375
1376    #[tokio::test]
1377    async fn test_gradle_command_stops_wrapper_when_wait_future_is_dropped() {
1378        let temp_dir = TempDir::new().unwrap();
1379        let started = temp_dir.path().join("started.marker");
1380        let completed = temp_dir.path().join("completed.marker");
1381        let gradlew = temp_dir.path().join(gradle_wrapper_name(cfg!(windows)));
1382
1383        if cfg!(windows) {
1384            fs::write(
1385                &gradlew,
1386                "@echo off\r\necho started>started.marker\r\npowershell -NoProfile -Command \"Start-Sleep -Milliseconds 400\"\r\necho completed>completed.marker\r\n",
1387            )
1388            .unwrap();
1389        } else {
1390            fs::write(
1391                &gradlew,
1392                "#!/bin/sh\nprintf started > started.marker\nsleep 0.4\nprintf completed > completed.marker\n",
1393            )
1394            .unwrap();
1395            #[cfg(unix)]
1396            make_executable(&gradlew);
1397        }
1398
1399        let spec = GradleCommandSpec::new(&gradlew, temp_dir.path(), Vec::new());
1400        let mut command = spec.command();
1401        command.stdout(Stdio::null()).stderr(Stdio::null());
1402        let mut child = command.spawn().unwrap();
1403        let wait_task = tokio::spawn(async move { child.wait().await });
1404
1405        tokio::time::timeout(std::time::Duration::from_secs(2), async {
1406            while !started.exists() {
1407                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1408            }
1409        })
1410        .await
1411        .expect("fake Gradle wrapper did not start");
1412
1413        wait_task.abort();
1414        let _ = wait_task.await;
1415        tokio::time::sleep(std::time::Duration::from_millis(700)).await;
1416
1417        assert!(
1418            !completed.exists(),
1419            "dropping a Gradle wait future left its wrapper running"
1420        );
1421    }
1422
1423    #[cfg(unix)]
1424    fn make_executable(path: &Path) {
1425        use std::os::unix::fs::PermissionsExt;
1426
1427        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
1428    }
1429
1430    #[tokio::test]
1431    async fn test_gradle_project_finder_visit_kts_package() {
1432        let temp_dir = TempDir::new().unwrap();
1433        let project_dir = temp_dir.path().join("myproject");
1434        fs::create_dir_all(&project_dir).unwrap();
1435
1436        let build_gradle = project_dir.join("build.gradle.kts");
1437        fs::write(
1438            &build_gradle,
1439            r#"
1440plugins {
1441    id("java")
1442}
1443
1444group = "com.example"
1445version = "1.0.0"
1446"#,
1447        )
1448        .unwrap();
1449
1450        create_mock_gradlew(&project_dir, MockGradlew::package("myproject", "1.0.0"));
1451
1452        let mut finder = finder_with_java_available();
1453        finder
1454            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1455            .await
1456            .unwrap();
1457
1458        let projects = finder.projects();
1459        assert_eq!(projects.len(), 1);
1460        match projects[0] {
1461            Project::Package(pkg) => {
1462                assert_eq!(pkg.name(), Some("myproject"));
1463                assert_eq!(pkg.version(), Some("1.0.0"));
1464            }
1465            Project::Workspace(_) => panic!("Expected Package"),
1466        }
1467
1468        temp_dir.close().unwrap();
1469    }
1470
1471    #[tokio::test]
1472    async fn test_gradle_project_finder_visit_groovy_package() {
1473        let temp_dir = TempDir::new().unwrap();
1474        let project_dir = temp_dir.path().join("groovyproject");
1475        fs::create_dir_all(&project_dir).unwrap();
1476
1477        let build_gradle = project_dir.join("build.gradle");
1478        fs::write(
1479            &build_gradle,
1480            r"
1481plugins {
1482    id 'java'
1483}
1484
1485group = 'com.example'
1486version = '2.0.0'
1487",
1488        )
1489        .unwrap();
1490
1491        create_mock_gradlew(&project_dir, MockGradlew::package("groovyproject", "2.0.0"));
1492
1493        let mut finder = finder_with_java_available();
1494        finder
1495            .visit(&build_gradle, &PathBuf::from("groovyproject/build.gradle"))
1496            .await
1497            .unwrap();
1498
1499        let projects = finder.projects();
1500        assert_eq!(projects.len(), 1);
1501        match projects[0] {
1502            Project::Package(pkg) => {
1503                assert_eq!(pkg.name(), Some("groovyproject"));
1504                assert_eq!(pkg.version(), Some("2.0.0"));
1505            }
1506            Project::Workspace(_) => panic!("Expected Package"),
1507        }
1508
1509        temp_dir.close().unwrap();
1510    }
1511
1512    #[tokio::test]
1513    async fn test_gradle_project_finder_visit_workspace() {
1514        let temp_dir = TempDir::new().unwrap();
1515        let project_dir = temp_dir.path().join("multiproject");
1516        fs::create_dir_all(&project_dir).unwrap();
1517
1518        let build_gradle = project_dir.join("build.gradle.kts");
1519        fs::write(
1520            &build_gradle,
1521            r#"
1522plugins {
1523    id("java")
1524}
1525
1526group = "com.example"
1527version = "1.0.0"
1528"#,
1529        )
1530        .unwrap();
1531
1532        // Mock Gradle metadata reports subprojects (this is what makes it a workspace).
1533        create_mock_gradlew(
1534            &project_dir,
1535            MockGradlew::workspace(
1536                "multiproject",
1537                "1.0.0",
1538                "[project ':subproject1', project ':subproject2']",
1539            ),
1540        );
1541
1542        let mut finder = finder_with_java_available();
1543        finder
1544            .visit(
1545                &build_gradle,
1546                &PathBuf::from("multiproject/build.gradle.kts"),
1547            )
1548            .await
1549            .unwrap();
1550
1551        let projects = finder.projects();
1552        assert_eq!(projects.len(), 1);
1553        match projects[0] {
1554            Project::Workspace(ws) => {
1555                assert_eq!(ws.name(), Some("multiproject"));
1556                assert_eq!(ws.version(), Some("1.0.0"));
1557            }
1558            Project::Package(_) => panic!("Expected Workspace"),
1559        }
1560
1561        temp_dir.close().unwrap();
1562    }
1563
1564    #[tokio::test]
1565    async fn test_gradle_project_finder_settings_file_does_not_make_workspace() {
1566        // Regression: settings.gradle.kts presence alone must NOT classify as Workspace.
1567        // Only evaluated Gradle metadata determines workspace status.
1568        let temp_dir = TempDir::new().unwrap();
1569        let project_dir = temp_dir.path().join("myproject");
1570        fs::create_dir_all(&project_dir).unwrap();
1571
1572        let build_gradle = project_dir.join("build.gradle.kts");
1573        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
1574
1575        // settings.gradle.kts exists and metadata reports no subprojects, so this is a package.
1576        fs::write(
1577            project_dir.join("settings.gradle.kts"),
1578            "rootProject.name = \"myproject\"\n",
1579        )
1580        .unwrap();
1581
1582        create_mock_gradlew(&project_dir, MockGradlew::package("myproject", "1.0.0"));
1583
1584        let mut finder = finder_with_java_available();
1585        finder
1586            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1587            .await
1588            .unwrap();
1589
1590        let projects = finder.projects();
1591        assert_eq!(projects.len(), 1);
1592        match projects[0] {
1593            Project::Package(_) => {} // correct: subprojects: [] → Package
1594            Project::Workspace(_) => panic!("Expected Package, not Workspace"),
1595        }
1596
1597        temp_dir.close().unwrap();
1598    }
1599
1600    #[tokio::test]
1601    async fn test_gradle_project_finder_empty_subprojects_is_package() {
1602        // A project with gradlew but subprojects: [] is a Package, not Workspace
1603        let temp_dir = TempDir::new().unwrap();
1604        let project_dir = temp_dir.path().join("standalone");
1605        fs::create_dir_all(&project_dir).unwrap();
1606
1607        let build_gradle = project_dir.join("build.gradle.kts");
1608        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
1609
1610        create_mock_gradlew(&project_dir, MockGradlew::package("standalone", "1.0.0"));
1611
1612        let mut finder = finder_with_java_available();
1613        finder
1614            .visit(&build_gradle, &PathBuf::from("standalone/build.gradle.kts"))
1615            .await
1616            .unwrap();
1617
1618        let projects = finder.projects();
1619        assert_eq!(projects.len(), 1);
1620        match projects[0] {
1621            Project::Package(pkg) => {
1622                assert_eq!(pkg.name(), Some("standalone"));
1623            }
1624            Project::Workspace(_) => panic!("Expected Package, not Workspace"),
1625        }
1626
1627        temp_dir.close().unwrap();
1628    }
1629
1630    #[tokio::test]
1631    async fn test_gradle_project_finder_visit_non_gradle_file() {
1632        let temp_dir = TempDir::new().unwrap();
1633        let other_file = temp_dir.path().join("other.txt");
1634        fs::write(&other_file, "some content").unwrap();
1635
1636        let mut finder = finder_with_java_available();
1637        finder
1638            .visit(&other_file, &PathBuf::from("other.txt"))
1639            .await
1640            .unwrap();
1641
1642        assert_eq!(finder.projects().len(), 0);
1643
1644        temp_dir.close().unwrap();
1645    }
1646
1647    #[tokio::test]
1648    async fn test_gradle_project_finder_visit_duplicate() {
1649        let temp_dir = TempDir::new().unwrap();
1650        let project_dir = temp_dir.path().join("myproject");
1651        fs::create_dir_all(&project_dir).unwrap();
1652
1653        let build_gradle = project_dir.join("build.gradle.kts");
1654        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
1655
1656        create_mock_gradlew(&project_dir, MockGradlew::package("myproject", "1.0.0"));
1657
1658        let mut finder = finder_with_java_available();
1659        finder
1660            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1661            .await
1662            .unwrap();
1663
1664        assert_eq!(finder.projects().len(), 1);
1665
1666        // Visit again - should not add duplicate
1667        finder
1668            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1669            .await
1670            .unwrap();
1671
1672        assert_eq!(finder.projects().len(), 1);
1673
1674        temp_dir.close().unwrap();
1675    }
1676
1677    #[tokio::test]
1678    async fn test_gradle_project_finder_projects_mut() {
1679        let temp_dir = TempDir::new().unwrap();
1680        let project_dir = temp_dir.path().join("myproject");
1681        fs::create_dir_all(&project_dir).unwrap();
1682
1683        let build_gradle = project_dir.join("build.gradle.kts");
1684        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
1685
1686        create_mock_gradlew(&project_dir, MockGradlew::package("myproject", "1.0.0"));
1687
1688        let mut finder = finder_with_java_available();
1689        finder
1690            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1691            .await
1692            .unwrap();
1693
1694        let mut_projects = finder.projects_mut();
1695        assert_eq!(mut_projects.len(), 1);
1696
1697        temp_dir.close().unwrap();
1698    }
1699
1700    #[tokio::test]
1701    async fn test_find_gradlew_in_same_dir() {
1702        let temp_dir = TempDir::new().unwrap();
1703
1704        if cfg!(windows) {
1705            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off").unwrap();
1706        } else {
1707            fs::write(temp_dir.path().join("gradlew"), "#!/bin/sh").unwrap();
1708        }
1709
1710        // Root project: the build file sits AT the repo root, so `visit`
1711        // computes `max_depth = 1` and the walk scans only `temp_dir`.
1712        let result = find_gradlew(temp_dir.path(), 1).await.unwrap();
1713        assert!(result.is_some());
1714        let (_, gradlew_dir) = result.unwrap();
1715        assert_eq!(gradlew_dir, temp_dir.path());
1716
1717        temp_dir.close().unwrap();
1718    }
1719
1720    #[tokio::test]
1721    async fn test_find_gradlew_in_parent_dir() {
1722        let temp_dir = TempDir::new().unwrap();
1723        let subproject = temp_dir.path().join("libs").join("core");
1724        fs::create_dir_all(&subproject).unwrap();
1725
1726        // gradlew at root, not in subproject
1727        if cfg!(windows) {
1728            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off").unwrap();
1729        } else {
1730            fs::write(temp_dir.path().join("gradlew"), "#!/bin/sh").unwrap();
1731        }
1732
1733        // Subproject `libs/core` is two directories below the repo root, so
1734        // its build file is `libs/core/build.gradle.kts` (3 components) →
1735        // `max_depth = 3`. The walk scans `libs/core`, `libs`, then `temp_dir`
1736        // (the repo root), where the wrapper lives.
1737        let result = find_gradlew(&subproject, 3).await.unwrap();
1738        assert!(result.is_some());
1739        let (_, gradlew_dir) = result.unwrap();
1740        assert_eq!(gradlew_dir, temp_dir.path().to_path_buf());
1741
1742        temp_dir.close().unwrap();
1743    }
1744
1745    #[tokio::test]
1746    async fn test_find_gradlew_not_found() {
1747        let temp_dir = TempDir::new().unwrap();
1748        let subdir = temp_dir.path().join("no_gradlew_here");
1749        fs::create_dir_all(&subdir).unwrap();
1750
1751        // No gradlew in `subdir` or its parent. The walk is now BOUNDED to
1752        // `max_depth`, so with depth 2 it scans only `subdir` and `temp_dir`
1753        // and stops — it can no longer climb to the filesystem root and pick
1754        // up an out-of-repo wrapper, so it reliably returns `None`.
1755        let result = find_gradlew(&subdir, 2).await.unwrap();
1756        assert!(result.is_none());
1757
1758        temp_dir.close().unwrap();
1759    }
1760
1761    /// Regression: a decoy `gradlew` ABOVE the repository root must NOT be
1762    /// discovered (and later executed) when resolving a subproject's wrapper.
1763    /// The ancestor walk is bounded by `max_depth` (the caller passes
1764    /// `relative_path.components().count()`), so it scans only the manifest's
1765    /// in-repo ancestors — down to the repo root — and never reaches the
1766    /// out-of-repo directory holding the stray wrapper. Project discovery is
1767    /// git-scoped; a `gradlew` in the user's home dir, the drive root, or a
1768    /// sibling checkout must not be picked up and run. Against the old
1769    /// unbounded walk (`loop { current.pop() }` to the filesystem root) this
1770    /// decoy WAS found, so this test fails there and passes only once the walk
1771    /// is bounded. Complements `test_find_gradlew_in_parent_dir`, which pins
1772    /// that an IN-repo ancestor `gradlew` is still found.
1773    #[tokio::test]
1774    async fn test_find_gradlew_ignores_gradlew_above_repo_root() {
1775        let temp_dir = TempDir::new().unwrap();
1776        // The simulated repo root is a nested subdir; the decoy wrapper lives
1777        // one level ABOVE it (outside the repo).
1778        let repo_root = temp_dir.path().join("repo");
1779        let sub = repo_root.join("sub");
1780        fs::create_dir_all(&sub).unwrap();
1781
1782        // Decoy gradlew ABOVE the repo root — must be ignored. (`gradlew.bat`
1783        // on Windows, `gradlew` elsewhere, matching `create_mock_gradlew`.)
1784        if cfg!(windows) {
1785            fs::write(temp_dir.path().join("gradlew.bat"), "@echo off").unwrap();
1786        } else {
1787            fs::write(temp_dir.path().join("gradlew"), "#!/bin/sh").unwrap();
1788        }
1789
1790        // `relative_path` is repo-root-relative with 2 components
1791        // (`sub/build.gradle.kts`), so the walk scans `<repo_root>/sub` and
1792        // `<repo_root>` — never `temp_dir`, where the decoy wrapper lives.
1793        let result = find_gradlew(&sub, 2).await.unwrap();
1794        assert!(
1795            result.is_none(),
1796            "expected a decoy gradlew above the repo root to be ignored, got {result:?}"
1797        );
1798
1799        temp_dir.close().unwrap();
1800    }
1801
1802    /// Regression: the DISCOVERY-side wrapper lookup must name the offending
1803    /// manifest. `find_gradlew` returning `Ok(None)` is covered by
1804    /// `test_find_gradlew_not_found`, but `GradleProjectFinder::visit` turning
1805    /// that `None` into a user-facing error was untested, so a message that
1806    /// dropped the manifest path (as the old hard-coded literal did) went
1807    /// unnoticed. Asserts BOTH halves of the contract: the stable leading
1808    /// sentence that `lib.rs` / `package.rs` match with `.contains(..)`, and
1809    /// the interpolated manifest path.
1810    #[tokio::test]
1811    async fn test_gradle_project_finder_visit_missing_wrapper_names_manifest() {
1812        let temp_dir = TempDir::new().unwrap();
1813        let project_dir = temp_dir.path().join("myproject");
1814        fs::create_dir_all(&project_dir).unwrap();
1815
1816        let build_gradle = project_dir.join("build.gradle.kts");
1817        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
1818
1819        // Deliberately NO gradlew/gradlew.bat: the bounded ancestor walk
1820        // (`max_depth = 2` from `myproject/build.gradle.kts`) scans only
1821        // `project_dir` and `temp_dir`, both freshly created and wrapper-free.
1822
1823        let mut finder = finder_with_java_available();
1824        let error = finder
1825            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
1826            .await
1827            .unwrap_err();
1828
1829        let flattened = error
1830            .chain()
1831            .map(ToString::to_string)
1832            .collect::<Vec<_>>()
1833            .join(": ");
1834        assert!(
1835            flattened.contains("Gradle wrapper (gradlew) not found"),
1836            "{flattened}"
1837        );
1838        assert!(
1839            flattened.contains(&build_gradle.display().to_string()),
1840            "{flattened}"
1841        );
1842
1843        temp_dir.close().unwrap();
1844    }
1845
1846    #[test]
1847    fn test_gradle_subproject_path_root() {
1848        assert_eq!(gradle_subproject_path(Path::new("")).unwrap(), "");
1849    }
1850
1851    #[test]
1852    fn test_gradle_subproject_path_single_component() {
1853        assert_eq!(gradle_subproject_path(Path::new("app")).unwrap(), "app");
1854    }
1855
1856    #[test]
1857    fn test_gradle_subproject_path_nested_unicode() {
1858        let relative = Path::new("라이브러리").join("핵심");
1859
1860        assert_eq!(
1861            gradle_subproject_path(&relative).unwrap(),
1862            "라이브러리:핵심"
1863        );
1864    }
1865
1866    #[tokio::test]
1867    async fn test_gradle_finder_resolves_project_path_to_evaluated_name_for_graph_edges() {
1868        let temp_dir = TempDir::new().unwrap();
1869        let repo = temp_dir.path().join("repo");
1870        let dependency_dir = repo.join("generated-backend");
1871        tokio::fs::create_dir_all(&dependency_dir).await.unwrap();
1872        let dependent_manifest = repo.join("build.gradle.kts");
1873        let dependency_manifest = dependency_dir.join("build.gradle.kts");
1874        tokio::fs::write(
1875            &dependent_manifest,
1876            "dependencies { implementation(project(\":api\")) }\n",
1877        )
1878        .await
1879        .unwrap();
1880        tokio::fs::write(&dependency_manifest, "plugins { java }\n")
1881            .await
1882            .unwrap();
1883        create_metadata_gradlew(
1884            &repo,
1885            &[
1886                metadata_record(&repo, ":", "service-suite", true),
1887                metadata_record(&dependency_dir, ":api", "published-api", false),
1888            ],
1889        )
1890        .await;
1891
1892        let mut finder = finder_with_java_available();
1893        finder
1894            .visit(&dependent_manifest, Path::new("build.gradle.kts"))
1895            .await
1896            .unwrap();
1897        finder
1898            .visit(
1899                &dependency_manifest,
1900                Path::new("generated-backend/build.gradle.kts"),
1901            )
1902            .await
1903            .unwrap();
1904
1905        let projects = finder.projects();
1906        let dependent = projects
1907            .iter()
1908            .copied()
1909            .find(|project| project.name() == Some("service-suite"))
1910            .unwrap();
1911        let dependency = projects
1912            .iter()
1913            .copied()
1914            .find(|project| project.name() == Some("published-api"))
1915            .unwrap();
1916        assert_eq!(
1917            dependent.dependencies(),
1918            &HashSet::from(["published-api".to_string()])
1919        );
1920
1921        let sorted = sort_by_dependencies(vec![dependent, dependency]).unwrap();
1922        assert_eq!(
1923            sorted
1924                .iter()
1925                .map(|project| project.name().unwrap())
1926                .collect::<Vec<_>>(),
1927            vec!["published-api", "service-suite"]
1928        );
1929
1930        let mut update_map = HashMap::from([(
1931            PathBuf::from("generated-backend/build.gradle.kts"),
1932            (UpdateType::Minor, Vec::new()),
1933        )]);
1934        apply_reverse_dependencies(&mut update_map, &[dependency, dependent], &repo).unwrap();
1935        assert_eq!(
1936            update_map[&PathBuf::from("build.gradle.kts")].0,
1937            UpdateType::Patch
1938        );
1939    }
1940
1941    #[tokio::test]
1942    async fn test_gradle_finder_errors_when_dependency_path_is_missing_from_wrapper_metadata() {
1943        let temp_dir = TempDir::new().unwrap();
1944        let repo = temp_dir.path().join("repo");
1945        tokio::fs::create_dir_all(&repo).await.unwrap();
1946        let manifest = repo.join("build.gradle.kts");
1947        tokio::fs::write(
1948            &manifest,
1949            "dependencies { implementation(project(\":missing\")) }\n",
1950        )
1951        .await
1952        .unwrap();
1953        create_metadata_gradlew(
1954            &repo,
1955            &[metadata_record(&repo, ":", "service-suite", false)],
1956        )
1957        .await;
1958
1959        let error = finder_with_java_available()
1960            .visit(&manifest, Path::new("build.gradle.kts"))
1961            .await
1962            .unwrap_err();
1963        let message = error.to_string();
1964
1965        assert!(message.contains(":missing"), "{message}");
1966        assert!(message.contains("service-suite"), "{message}");
1967        assert!(message.contains("gradlew"), "{message}");
1968    }
1969
1970    #[tokio::test]
1971    async fn test_gradle_finder_errors_when_wrapper_metadata_duplicates_project_path() {
1972        let temp_dir = TempDir::new().unwrap();
1973        let repo = temp_dir.path().join("repo");
1974        let first_dir = repo.join("first");
1975        let second_dir = repo.join("second");
1976        tokio::fs::create_dir_all(&first_dir).await.unwrap();
1977        tokio::fs::create_dir_all(&second_dir).await.unwrap();
1978        let manifest = repo.join("build.gradle.kts");
1979        tokio::fs::write(&manifest, "plugins { java }\n")
1980            .await
1981            .unwrap();
1982        create_metadata_gradlew(
1983            &repo,
1984            &[
1985                metadata_record(&repo, ":", "service-suite", true),
1986                metadata_record(&first_dir, ":api", "first-api", false),
1987                metadata_record(&second_dir, ":api", "second-api", false),
1988            ],
1989        )
1990        .await;
1991
1992        let error = finder_with_java_available()
1993            .visit(&manifest, Path::new("build.gradle.kts"))
1994            .await
1995            .unwrap_err();
1996        let message = error.to_string();
1997
1998        assert!(message.contains("Duplicate Gradle metadata project path ':api'"));
1999        assert!(message.contains("first-api"));
2000        assert!(message.contains("second-api"));
2001        assert!(message.contains("gradlew"));
2002    }
2003
2004    #[tokio::test]
2005    async fn test_gradle_finder_errors_when_wrapper_metadata_directory_does_not_exist() {
2006        let temp_dir = TempDir::new().unwrap();
2007        let repo = temp_dir.path().join("repo");
2008        tokio::fs::create_dir_all(&repo).await.unwrap();
2009        let manifest = repo.join("build.gradle.kts");
2010        tokio::fs::write(&manifest, "plugins { java }\n")
2011            .await
2012            .unwrap();
2013        let missing_dir = repo.join("never-created");
2014        create_metadata_gradlew(
2015            &repo,
2016            &[
2017                metadata_record(&repo, ":", "service-suite", true),
2018                metadata_record(&missing_dir, ":ghost", "ghost-api", false),
2019            ],
2020        )
2021        .await;
2022
2023        let error = finder_with_java_available()
2024            .visit(&manifest, Path::new("build.gradle.kts"))
2025            .await
2026            .unwrap_err();
2027        let message = format!("{error:#}");
2028
2029        assert!(
2030            message.contains("Failed to normalize Gradle metadata directory"),
2031            "{message}"
2032        );
2033        assert!(message.contains(":ghost"), "{message}");
2034        assert!(message.contains("never-created"), "{message}");
2035    }
2036
2037    #[tokio::test]
2038    async fn test_gradle_finder_uses_manifest_dialect_for_slashes() {
2039        let kotlin = dependencies_for_manifest(
2040            "build.gradle.kts",
2041            r#"
2042val first = 12 / 3
2043dependencies { implementation(project(":real-kotlin")) }
2044val second = 20 / 4
2045"#,
2046        )
2047        .await;
2048        assert_eq!(kotlin, HashSet::from(["real-kotlin".to_string()]));
2049
2050        let groovy = dependencies_for_manifest(
2051            "build.gradle",
2052            r#"
2053def decoy = /project(":slashy-decoy")/
2054def first = 12 / 3
2055dependencies { implementation(project(":real-groovy")) }
2056def second = 20 / 4
2057"#,
2058        )
2059        .await;
2060        assert_eq!(groovy, HashSet::from(["real-groovy".to_string()]));
2061    }
2062
2063    #[tokio::test]
2064    async fn test_gradle_finder_dependencies_drive_topological_and_reverse_edges() {
2065        let temp_dir = TempDir::new().unwrap();
2066        let core_dir = temp_dir.path().join("core");
2067        let app_dir = temp_dir.path().join("app");
2068        tokio::fs::create_dir_all(&core_dir).await.unwrap();
2069        tokio::fs::create_dir_all(&app_dir).await.unwrap();
2070
2071        let core_manifest = core_dir.join("build.gradle.kts");
2072        let app_manifest = app_dir.join("build.gradle.kts");
2073        tokio::fs::write(&core_manifest, "plugins { java }\n")
2074            .await
2075            .unwrap();
2076        tokio::fs::write(
2077            &app_manifest,
2078            r#"dependencies {
2079    implementation(project(configuration = "default", path = ":modules:core"))
2080}
2081"#,
2082        )
2083        .await
2084        .unwrap();
2085        create_metadata_gradlew(
2086            temp_dir.path(),
2087            &[
2088                metadata_record(&core_dir, ":modules:core", "core", false),
2089                metadata_record(&app_dir, ":app", "app", false),
2090            ],
2091        )
2092        .await;
2093
2094        let mut finder = finder_with_java_available();
2095        finder
2096            .visit(&core_manifest, Path::new("core/build.gradle.kts"))
2097            .await
2098            .unwrap();
2099        finder
2100            .visit(&app_manifest, Path::new("app/build.gradle.kts"))
2101            .await
2102            .unwrap();
2103
2104        let projects = finder.projects();
2105        let core = projects
2106            .iter()
2107            .copied()
2108            .find(|project| project.name() == Some("core"))
2109            .unwrap();
2110        let app = projects
2111            .iter()
2112            .copied()
2113            .find(|project| project.name() == Some("app"))
2114            .unwrap();
2115        assert_eq!(app.dependencies().len(), 1);
2116        assert!(app.dependencies().contains("core"));
2117
2118        let sorted = sort_by_dependencies(vec![app, core]).unwrap();
2119        assert_eq!(
2120            sorted
2121                .iter()
2122                .map(|project| project.name().unwrap())
2123                .collect::<Vec<_>>(),
2124            vec!["core", "app"]
2125        );
2126
2127        let mut update_map = HashMap::from([(
2128            PathBuf::from("core/build.gradle.kts"),
2129            (UpdateType::Minor, Vec::new()),
2130        )]);
2131        apply_reverse_dependencies(&mut update_map, &[core, app], temp_dir.path()).unwrap();
2132        assert_eq!(
2133            update_map[&PathBuf::from("app/build.gradle.kts")].0,
2134            UpdateType::Patch
2135        );
2136
2137        temp_dir.close().unwrap();
2138    }
2139
2140    #[tokio::test]
2141    async fn test_gradle_finder_ignores_project_configuration_edge_that_would_form_cycle() {
2142        let temp_dir = TempDir::new().unwrap();
2143        let core_dir = temp_dir.path().join("core");
2144        let app_dir = temp_dir.path().join("app");
2145        tokio::fs::create_dir_all(&core_dir).await.unwrap();
2146        tokio::fs::create_dir_all(&app_dir).await.unwrap();
2147
2148        let core_manifest = core_dir.join("build.gradle.kts");
2149        let app_manifest = app_dir.join("build.gradle.kts");
2150        tokio::fs::write(
2151            &core_manifest,
2152            r#"project(":app") {
2153    description = "configuration only"
2154}
2155"#,
2156        )
2157        .await
2158        .unwrap();
2159        tokio::fs::write(
2160            &app_manifest,
2161            r#"dependencies {
2162    implementation(project(":core"))
2163}
2164"#,
2165        )
2166        .await
2167        .unwrap();
2168        create_metadata_gradlew(
2169            temp_dir.path(),
2170            &[
2171                metadata_record(&core_dir, ":core", "core", false),
2172                metadata_record(&app_dir, ":app", "app", false),
2173            ],
2174        )
2175        .await;
2176
2177        let mut finder = finder_with_java_available();
2178        finder
2179            .visit(&core_manifest, Path::new("core/build.gradle.kts"))
2180            .await
2181            .unwrap();
2182        finder
2183            .visit(&app_manifest, Path::new("app/build.gradle.kts"))
2184            .await
2185            .unwrap();
2186
2187        let projects = finder.projects();
2188        let core = projects
2189            .iter()
2190            .copied()
2191            .find(|project| project.name() == Some("core"))
2192            .unwrap();
2193        let app = projects
2194            .iter()
2195            .copied()
2196            .find(|project| project.name() == Some("app"))
2197            .unwrap();
2198        assert!(core.dependencies().is_empty());
2199        assert_eq!(app.dependencies(), &HashSet::from(["core".to_string()]));
2200
2201        let sorted = sort_by_dependencies(vec![app, core]).unwrap();
2202        assert_eq!(
2203            sorted
2204                .iter()
2205                .map(|project| project.name().unwrap())
2206                .collect::<Vec<_>>(),
2207            vec!["core", "app"]
2208        );
2209
2210        temp_dir.close().unwrap();
2211    }
2212
2213    #[cfg(unix)]
2214    #[test]
2215    fn test_gradle_subproject_path_rejects_non_unicode_component() {
2216        use std::ffi::OsString;
2217        use std::os::unix::ffi::OsStringExt;
2218
2219        let invalid = PathBuf::from(OsString::from_vec(vec![0x66, 0x80, 0x6f]));
2220
2221        assert!(gradle_subproject_path(&invalid).is_err());
2222    }
2223
2224    #[tokio::test]
2225    async fn test_which_java_in_none() {
2226        let result = which_java_in(None).await.unwrap();
2227        assert!(result.is_none());
2228    }
2229
2230    #[tokio::test]
2231    async fn test_which_java_in_empty() {
2232        let empty = std::ffi::OsStr::new("");
2233        let result = which_java_in(Some(empty)).await.unwrap();
2234        assert!(result.is_none());
2235    }
2236
2237    #[tokio::test]
2238    async fn test_which_java_in_with_java_executable() {
2239        let temp_dir = TempDir::new().unwrap();
2240        let java_name = if cfg!(windows) { "java.exe" } else { "java" };
2241        let java_path = temp_dir.path().join(java_name);
2242        fs::write(&java_path, "").unwrap();
2243
2244        #[cfg(unix)]
2245        {
2246            use std::os::unix::fs::PermissionsExt;
2247            fs::set_permissions(&java_path, fs::Permissions::from_mode(0o755)).unwrap();
2248        }
2249
2250        let path_var = temp_dir.path().as_os_str();
2251        let result = which_java_in(Some(path_var)).await.unwrap();
2252        assert!(result.is_some());
2253        assert_eq!(result.unwrap().file_name().unwrap(), java_name);
2254
2255        temp_dir.close().unwrap();
2256    }
2257
2258    #[cfg(unix)]
2259    #[tokio::test]
2260    async fn test_which_java_in_rejects_non_executable_file() {
2261        use std::os::unix::fs::PermissionsExt;
2262
2263        let temp_dir = TempDir::new().unwrap();
2264        let java_path = temp_dir.path().join("java");
2265        fs::write(&java_path, "").unwrap();
2266
2267        fs::set_permissions(&java_path, fs::Permissions::from_mode(0o644)).unwrap();
2268
2269        let result = which_java_in(Some(temp_dir.path().as_os_str()))
2270            .await
2271            .unwrap();
2272        assert!(result.is_none());
2273
2274        temp_dir.close().unwrap();
2275    }
2276
2277    #[tokio::test]
2278    async fn test_which_java_in_without_java() {
2279        let temp_dir = TempDir::new().unwrap();
2280        // Create a directory but no java executable
2281        fs::create_dir_all(temp_dir.path().join("subdir")).unwrap();
2282
2283        let path_var = temp_dir.path().as_os_str();
2284        let result = which_java_in(Some(path_var)).await.unwrap();
2285        assert!(result.is_none());
2286
2287        temp_dir.close().unwrap();
2288    }
2289
2290    #[tokio::test]
2291    async fn test_java_home_has_java_rejects_empty_value() {
2292        assert!(!java_home_has_java(None).await.unwrap());
2293        assert!(
2294            !java_home_has_java(Some(std::ffi::OsStr::new("")))
2295                .await
2296                .unwrap()
2297        );
2298    }
2299
2300    #[tokio::test]
2301    async fn test_java_home_has_java_rejects_invalid_home() {
2302        let temp_dir = TempDir::new().unwrap();
2303        let invalid_home = temp_dir.path().join("missing-java");
2304        fs::create_dir_all(&invalid_home).unwrap();
2305
2306        assert!(
2307            !java_home_has_java(Some(invalid_home.as_os_str()))
2308                .await
2309                .unwrap()
2310        );
2311
2312        temp_dir.close().unwrap();
2313    }
2314
2315    #[tokio::test]
2316    async fn test_java_home_has_java_accepts_bin_java() {
2317        let temp_dir = TempDir::new().unwrap();
2318        let java_name = if cfg!(windows) { "java.exe" } else { "java" };
2319        let java_path = temp_dir.path().join("bin").join(java_name);
2320        fs::create_dir_all(java_path.parent().unwrap()).unwrap();
2321        fs::write(&java_path, "").unwrap();
2322
2323        #[cfg(unix)]
2324        {
2325            use std::os::unix::fs::PermissionsExt;
2326            fs::set_permissions(&java_path, fs::Permissions::from_mode(0o755)).unwrap();
2327        }
2328
2329        assert!(
2330            java_home_has_java(Some(temp_dir.path().as_os_str()))
2331                .await
2332                .unwrap()
2333        );
2334
2335        temp_dir.close().unwrap();
2336    }
2337
2338    #[cfg(unix)]
2339    #[tokio::test]
2340    async fn test_java_home_has_java_rejects_non_executable_file() {
2341        use std::os::unix::fs::PermissionsExt;
2342
2343        let temp_dir = TempDir::new().unwrap();
2344        let java_path = temp_dir.path().join("bin").join("java");
2345        fs::create_dir_all(java_path.parent().unwrap()).unwrap();
2346        fs::write(&java_path, "").unwrap();
2347
2348        fs::set_permissions(&java_path, fs::Permissions::from_mode(0o644)).unwrap();
2349
2350        assert!(
2351            !java_home_has_java(Some(temp_dir.path().as_os_str()))
2352                .await
2353                .unwrap()
2354        );
2355
2356        temp_dir.close().unwrap();
2357    }
2358
2359    #[tokio::test]
2360    async fn test_gradle_project_finder_visit_name_fallback_to_dir() {
2361        // When gradlew returns name: unspecified, visit() falls back to directory name (line 173).
2362        let temp_dir = TempDir::new().unwrap();
2363        let project_dir = temp_dir.path().join("my-fallback-project");
2364        fs::create_dir_all(&project_dir).unwrap();
2365
2366        let build_gradle = project_dir.join("build.gradle.kts");
2367        fs::write(&build_gradle, "version = \"1.0.0\"\n").unwrap();
2368
2369        // Mock gradlew that returns unspecified name (filtered to None)
2370        create_mock_gradlew(&project_dir, MockGradlew::package("unspecified", "1.0.0"));
2371
2372        let mut finder = finder_with_java_available();
2373        finder
2374            .visit(
2375                &build_gradle,
2376                &PathBuf::from("my-fallback-project/build.gradle.kts"),
2377            )
2378            .await
2379            .unwrap();
2380
2381        let projects = finder.projects();
2382        assert_eq!(projects.len(), 1);
2383        match projects[0] {
2384            Project::Package(pkg) => {
2385                // name fell back to directory name
2386                assert_eq!(pkg.name(), Some("my-fallback-project"));
2387                assert_eq!(pkg.version(), Some("1.0.0"));
2388            }
2389            Project::Workspace(_) => panic!("Expected Package"),
2390        }
2391
2392        temp_dir.close().unwrap();
2393    }
2394
2395    #[tokio::test]
2396    async fn test_gradle_project_finder_visit_fails_when_gradlew_fails() {
2397        let temp_dir = TempDir::new().unwrap();
2398        let project_dir = temp_dir.path().join("my-project");
2399        fs::create_dir_all(&project_dir).unwrap();
2400
2401        let build_gradle = project_dir.join("build.gradle.kts");
2402        fs::write(&build_gradle, "plugins { id 'java' }").unwrap();
2403
2404        create_failing_gradlew(&project_dir);
2405
2406        let mut finder = finder_with_java_available();
2407        let error = finder
2408            .visit(&build_gradle, &PathBuf::from("my-project/build.gradle.kts"))
2409            .await
2410            .unwrap_err();
2411
2412        // Visit propagates the batched metadata discovery failure verbatim, so
2413        // pin all three parts of that message: the stable headline, the wrapper
2414        // root it names, and the `; stderr: ...` suffix that only appears when
2415        // the wrapper actually wrote diagnostics (the empty-stderr branch emits
2416        // no suffix at all).
2417        let flattened = error
2418            .chain()
2419            .map(ToString::to_string)
2420            .collect::<Vec<_>>()
2421            .join(": ");
2422        assert!(
2423            flattened.contains("Gradle metadata discovery failed"),
2424            "{flattened}"
2425        );
2426        assert!(
2427            flattened.contains(&project_dir.display().to_string()),
2428            "{flattened}"
2429        );
2430        assert!(
2431            flattened.contains(&format!("; stderr: {FAILING_GRADLEW_STDERR_MARKER}")),
2432            "{flattened}"
2433        );
2434        // No projects should be added when gradlew fails
2435        assert_eq!(finder.project_count(), 0);
2436
2437        temp_dir.close().unwrap();
2438    }
2439
2440    /// Write a wrapper that emits `record` and then deletes `victim_dir`
2441    /// (relative to the wrapper root it is executed in), reproducing a project
2442    /// directory that disappears while Gradle is still evaluating the build.
2443    fn create_self_destructing_gradlew(dir: &Path, victim_dir: &str, record: &str) {
2444        if cfg!(windows) {
2445            fs::write(
2446                dir.join("gradlew.bat"),
2447                format!(
2448                    "@echo off\r\necho {record}\r\nrmdir /s /q \"{victim_dir}\"\r\nexit /b 0\r\n"
2449                ),
2450            )
2451            .unwrap();
2452        } else {
2453            let gradlew_path = dir.join("gradlew");
2454            fs::write(
2455                &gradlew_path,
2456                format!("#!/bin/sh\nprintf '%s\\n' '{record}'\nrm -rf '{victim_dir}'\n"),
2457            )
2458            .unwrap();
2459            #[cfg(unix)]
2460            make_executable(&gradlew_path);
2461        }
2462    }
2463
2464    /// `java_is_available` is the only caller that reads the ambient
2465    /// `JAVA_HOME` / `PATH`; everything below it takes those values as
2466    /// parameters. Pin that it is exactly the disjunction of its two probes
2467    /// over the very same ambient values, so swapping the operator, dropping
2468    /// the `PATH` fallback, or reading the wrong variable is caught.
2469    ///
2470    /// Only the arm matching this machine's environment executes: reaching the
2471    /// other one would require mutating the process environment, which is
2472    /// `unsafe` under edition 2024 and denied by `[workspace.lints.rust]`.
2473    #[tokio::test]
2474    async fn test_java_is_available_matches_its_java_home_and_path_probes() {
2475        let java_home = std::env::var_os("JAVA_HOME");
2476        let path = std::env::var_os("PATH");
2477        let via_java_home = java_home_has_java(java_home.as_deref()).await.unwrap();
2478        let via_path = which_java_in(path.as_deref()).await.unwrap().is_some();
2479
2480        let available = java_is_available().await.unwrap();
2481
2482        assert_eq!(available, via_java_home || via_path);
2483    }
2484
2485    /// The wrapper must reach the OS exactly once: Windows spawns
2486    /// `gradlew.bat` as the program, every other platform spawns `sh` with the
2487    /// wrapper as its leading argument. Dropping it would run bare `sh`, and
2488    /// duplicating it would make Gradle treat the wrapper path as a task name,
2489    /// so the count is the invariant rather than either platform's layout.
2490    #[test]
2491    fn test_gradle_command_spec_passes_wrapper_exactly_once() {
2492        let gradlew_dir = Path::new("repo with spaces");
2493        let gradlew = gradlew_dir.join(gradle_wrapper_name(cfg!(windows)));
2494
2495        let spec = GradleCommandSpec::new(&gradlew, gradlew_dir, vec![OsString::from("help")]);
2496
2497        let mentions = std::iter::once(&spec.program)
2498            .chain(spec.args.iter())
2499            .filter(|value| value.as_os_str() == gradlew.as_os_str())
2500            .count();
2501        assert_eq!(mentions, 1);
2502        assert_eq!(spec.args.last(), Some(&OsString::from("help")));
2503        if cfg!(windows) {
2504            assert_eq!(spec.program, gradlew.as_os_str());
2505        } else {
2506            assert_eq!(spec.program, OsString::from("sh"));
2507        }
2508    }
2509
2510    /// Both wrapper layouts, from either host. `new` reads `cfg!(windows)`, so
2511    /// on any single machine one of these arms would never run; taking the
2512    /// platform as a parameter is what keeps the Windows layout exercised on
2513    /// the Linux coverage runner and vice versa.
2514    #[rstest]
2515    #[case(true, "gradlew.bat")]
2516    #[case(false, "gradlew")]
2517    fn test_gradle_command_spec_for_platform_builds_both_layouts(
2518        #[case] windows: bool,
2519        #[case] expected_wrapper: &str,
2520    ) {
2521        let gradlew_dir = Path::new("repo");
2522        let gradlew = gradlew_dir.join(gradle_wrapper_name(windows));
2523        assert_eq!(gradlew.file_name().unwrap(), expected_wrapper);
2524
2525        let spec = GradleCommandSpec::for_platform(
2526            &gradlew,
2527            gradlew_dir,
2528            vec![OsString::from("help")],
2529            windows,
2530        );
2531
2532        if windows {
2533            assert_eq!(spec.program, gradlew.as_os_str());
2534            assert_eq!(spec.args, vec![OsString::from("help")]);
2535        } else {
2536            assert_eq!(spec.program, OsString::from("sh"));
2537            assert_eq!(
2538                spec.args,
2539                vec![gradlew.as_os_str().to_owned(), OsString::from("help")]
2540            );
2541        }
2542        assert_eq!(spec.current_dir, gradlew_dir);
2543    }
2544
2545    /// Create an executable `java` (`java.exe` on Windows) inside `dir`, the
2546    /// shape both `java_home_has_java` (as `<home>/bin/java`) and
2547    /// `which_java_in` (as `<path entry>/java`) accept.
2548    fn create_java_executable(dir: &Path) {
2549        #[cfg(unix)]
2550        use std::os::unix::fs::PermissionsExt;
2551
2552        fs::create_dir_all(dir).unwrap();
2553        let java_path = dir.join(if cfg!(windows) { "java.exe" } else { "java" });
2554        fs::write(&java_path, "").unwrap();
2555        #[cfg(unix)]
2556        fs::set_permissions(&java_path, fs::Permissions::from_mode(0o755)).unwrap();
2557    }
2558
2559    /// `JAVA_HOME` short-circuits the search: with a usable `bin/java` there,
2560    /// an empty PATH must still report Java as available.
2561    #[tokio::test]
2562    async fn test_java_is_available_in_short_circuits_on_java_home() {
2563        let temp_dir = TempDir::new().unwrap();
2564        create_java_executable(&temp_dir.path().join("bin"));
2565
2566        assert!(
2567            java_is_available_in(Some(temp_dir.path().as_os_str()), Some(OsStr::new("")))
2568                .await
2569                .unwrap()
2570        );
2571    }
2572
2573    /// A `JAVA_HOME` without `bin/java` must not end the search — the PATH
2574    /// fallback still finds a runtime.
2575    #[tokio::test]
2576    async fn test_java_is_available_in_falls_back_to_path() {
2577        let temp_dir = TempDir::new().unwrap();
2578        let java_home = temp_dir.path().join("home-without-java");
2579        let path_entry = temp_dir.path().join("path-entry");
2580        fs::create_dir_all(&java_home).unwrap();
2581        create_java_executable(&path_entry);
2582
2583        assert!(
2584            java_is_available_in(Some(java_home.as_os_str()), Some(path_entry.as_os_str()))
2585                .await
2586                .unwrap()
2587        );
2588    }
2589
2590    /// Neither probe finds a runtime, so the disjunction is false.
2591    #[tokio::test]
2592    async fn test_java_is_available_in_reports_absent_runtime() {
2593        let temp_dir = TempDir::new().unwrap();
2594        let java_home = temp_dir.path().join("home-without-java");
2595        let path_entry = temp_dir.path().join("path-without-java");
2596        fs::create_dir_all(&java_home).unwrap();
2597        fs::create_dir_all(&path_entry).unwrap();
2598
2599        assert!(
2600            !java_is_available_in(Some(java_home.as_os_str()), Some(path_entry.as_os_str()))
2601                .await
2602                .unwrap()
2603        );
2604    }
2605
2606    /// `visit` normalizes the project directory with its OWN context, separate
2607    /// from the wrapper-root and metadata-directory normalizations that run
2608    /// before it. The wrapper deletes the project directory as it runs — after
2609    /// the manifest has been read and the wrapper located, and while still
2610    /// emitting a valid record for the (surviving) repository root — so the
2611    /// project-directory normalization is the only step left that can fail.
2612    #[tokio::test]
2613    async fn test_gradle_finder_errors_when_project_directory_disappears_before_normalization() {
2614        let temp_dir = TempDir::new().unwrap();
2615        let repo = temp_dir.path().join("repo");
2616        let module_dir = repo.join("module");
2617        tokio::fs::create_dir_all(&module_dir).await.unwrap();
2618        let manifest = module_dir.join("build.gradle.kts");
2619        tokio::fs::write(&manifest, "plugins { java }\n")
2620            .await
2621            .unwrap();
2622        create_self_destructing_gradlew(
2623            &repo,
2624            "module",
2625            &metadata_record(&repo, ":", "root project", true),
2626        );
2627
2628        let error = finder_with_java_available()
2629            .visit(
2630                &manifest,
2631                Path::new("module").join("build.gradle.kts").as_path(),
2632            )
2633            .await
2634            .unwrap_err();
2635        let message = format!("{error:#}");
2636
2637        assert!(
2638            message.contains("Failed to normalize Gradle project directory"),
2639            "{message}"
2640        );
2641        assert!(
2642            message.contains(&module_dir.display().to_string()),
2643            "{message}"
2644        );
2645        assert!(
2646            message.contains(&manifest.display().to_string()),
2647            "{message}"
2648        );
2649
2650        temp_dir.close().unwrap();
2651    }
2652
2653    /// Two records that carry DIFFERENT Gradle paths but the SAME project
2654    /// directory collide on the `by_project_dir` key. That is a distinct
2655    /// failure from the duplicate-project-path collision pinned above — it
2656    /// survives the `project_names_by_path` guard entirely — so it must report
2657    /// the directory and both offending Gradle paths.
2658    #[tokio::test]
2659    async fn test_gradle_finder_errors_when_wrapper_metadata_duplicates_normalized_directory() {
2660        let temp_dir = TempDir::new().unwrap();
2661        let repo = temp_dir.path().join("repo");
2662        let shared_dir = repo.join("shared");
2663        tokio::fs::create_dir_all(&shared_dir).await.unwrap();
2664        let manifest = repo.join("build.gradle.kts");
2665        tokio::fs::write(&manifest, "plugins { java }\n")
2666            .await
2667            .unwrap();
2668        create_metadata_gradlew(
2669            &repo,
2670            &[
2671                metadata_record(&repo, ":", "root project", true),
2672                metadata_record(&shared_dir, ":alpha", "alpha", false),
2673                metadata_record(&shared_dir, ":beta", "beta", false),
2674            ],
2675        )
2676        .await;
2677
2678        let error = finder_with_java_available()
2679            .visit(&manifest, Path::new("build.gradle.kts"))
2680            .await
2681            .unwrap_err();
2682        let message = format!("{error:#}");
2683
2684        assert!(
2685            message.contains("Duplicate Gradle metadata records for normalized directory"),
2686            "{message}"
2687        );
2688        assert!(message.contains("shared"), "{message}");
2689        assert!(message.contains(":alpha"), "{message}");
2690        assert!(message.contains(":beta"), "{message}");
2691        assert!(message.contains("gradlew"), "{message}");
2692
2693        temp_dir.close().unwrap();
2694    }
2695}