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
22const PROJECT_FILES: &[&str] = &["build.gradle.kts", "build.gradle"];
27
28#[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 wrapper_dir_canonical: HashMap<PathBuf, PathBuf>,
45}
46
47impl GradleProjectFinder {
48 #[must_use]
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 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 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 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 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 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
192async 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 changepacks_core::is_regular_file(path).await
215 }
216}
217
218async 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
254fn gradle_wrapper_name(windows: bool) -> &'static str {
256 if windows { "gradlew.bat" } else { "gradlew" }
257}
258
259fn 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
280async fn find_gradlew_named(
299 start_dir: &Path,
300 max_depth: usize,
301 gradlew_name: &str,
302) -> Result<Option<(PathBuf, PathBuf)>> {
303 for current in start_dir.ancestors().take(max_depth) {
307 let gradlew = current.join(gradlew_name);
308 if changepacks_core::is_regular_file(&gradlew).await? {
311 return Ok(Some((gradlew, current.to_path_buf())));
312 }
313 }
314 Ok(None)
315}
316
317pub(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 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
374async 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
389async 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#[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 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 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 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 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 let is_workspace = has_subprojects;
555
556 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 #[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 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 const FAILING_GRADLEW_STDERR_MARKER: &str = "changepacks-gradle-failure-marker";
759
760 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 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 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 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 #[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 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 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 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 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 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(_) => {} 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 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 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 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 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 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 let result = find_gradlew(&subdir, 2).await.unwrap();
1756 assert!(result.is_none());
1757
1758 temp_dir.close().unwrap();
1759 }
1760
1761 #[tokio::test]
1774 async fn test_find_gradlew_ignores_gradlew_above_repo_root() {
1775 let temp_dir = TempDir::new().unwrap();
1776 let repo_root = temp_dir.path().join("repo");
1779 let sub = repo_root.join("sub");
1780 fs::create_dir_all(&sub).unwrap();
1781
1782 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 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 #[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 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 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 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 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 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 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 assert_eq!(finder.project_count(), 0);
2436
2437 temp_dir.close().unwrap();
2438 }
2439
2440 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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}