Skip to main content

changepacks_java/
lib.rs

1//! # changepacks-java
2//!
3//! Java/Gradle project support for changepacks.
4//!
5//! Implements project discovery and version management for Gradle build files (build.gradle,
6//! build.gradle.kts). Handles both Groovy and Kotlin DSL syntax for version declarations.
7//! Requires the Gradle wrapper (gradlew) for dynamic version detection.
8
9pub mod finder;
10mod gradle_dependency_lexer;
11mod gradle_metadata;
12pub mod package;
13mod properties_version;
14#[cfg(test)]
15pub(crate) mod test_support;
16mod version_lexer;
17pub mod version_updater;
18pub mod workspace;
19
20pub use finder::GradleProjectFinder;
21pub use version_updater::write_gradle_version;
22
23use anyhow::{Context, Result};
24use changepacks_core::{Config, UpdateType};
25use std::collections::BTreeMap;
26use std::ffi::OsString;
27use std::future::Future;
28use std::path::{Path, PathBuf};
29
30// Per-OS Gradle wrapper commands. Windows uses `gradlew.bat` and backslash;
31// every other target uses the POSIX `./gradlew` shell script. These consts
32// are shared by `GradlePackage` and `GradleWorkspace` so a single edit
33// updates both trait impls without drift.
34//
35// Gradle's built-in `--dry-run` only previews the task graph, so we run the
36// full publish pipeline against an isolated temporary Maven local repository
37// via `publishToMavenLocal` instead for dry-runs.
38#[cfg(windows)]
39pub(crate) const PUBLISH_COMMAND: &str = ".\\gradlew.bat publish";
40#[cfg(not(windows))]
41pub(crate) const PUBLISH_COMMAND: &str = "./gradlew publish";
42
43#[cfg(windows)]
44pub(crate) const DRY_RUN_PUBLISH_COMMAND: &str = ".\\gradlew.bat publishToMavenLocal";
45#[cfg(not(windows))]
46pub(crate) const DRY_RUN_PUBLISH_COMMAND: &str = "./gradlew publishToMavenLocal";
47
48/// Expand to the three inherent constructors shared verbatim by
49/// `GradlePackage` and `GradleWorkspace`.
50///
51/// Both structs carry exactly the same nine fields and built the same
52/// three-step constructor chain (`new` -> `new_with_publish_tasks` ->
53/// `new_with_project_path_and_publish_tasks`) from byte-identical bodies;
54/// only the order the fields happened to be declared in differed. The
55/// final struct literal therefore uses field-init shorthand, which is
56/// order-insensitive, so one expansion serves both declaration orders.
57///
58/// Invoked from inside an `impl GradlePackage` or `impl GradleWorkspace`
59/// block. Fully-qualified `::std::option::Option`,
60/// `::std::string::String`, `::std::path::PathBuf` and
61/// `::std::collections::HashSet` keep the macro hygienic — callers do not
62/// need those types in scope at the invocation site.
63///
64/// Consumer requirement: the struct must have `name`, `version`, `path`,
65/// `relative_path`, `project_path`, `is_changed`, `dependencies`,
66/// `has_publish_task` and `has_publish_to_maven_local_task` fields.
67/// `GradlePackage` and `GradleWorkspace` are the only two intended callers.
68macro_rules! impl_gradle_constructors {
69    () => {
70        #[must_use]
71        pub fn new(
72            name: ::std::option::Option<::std::string::String>,
73            version: ::std::option::Option<::std::string::String>,
74            path: ::std::path::PathBuf,
75            relative_path: ::std::path::PathBuf,
76        ) -> Self {
77            Self::new_with_publish_tasks(name, version, path, relative_path, true, true)
78        }
79
80        #[must_use]
81        pub fn new_with_publish_tasks(
82            name: ::std::option::Option<::std::string::String>,
83            version: ::std::option::Option<::std::string::String>,
84            path: ::std::path::PathBuf,
85            relative_path: ::std::path::PathBuf,
86            has_publish_task: bool,
87            has_publish_to_maven_local_task: bool,
88        ) -> Self {
89            Self::new_with_project_path_and_publish_tasks(
90                name,
91                version,
92                path,
93                relative_path,
94                ::std::option::Option::None,
95                has_publish_task,
96                has_publish_to_maven_local_task,
97            )
98        }
99
100        #[must_use]
101        pub(crate) fn new_with_project_path_and_publish_tasks(
102            name: ::std::option::Option<::std::string::String>,
103            version: ::std::option::Option<::std::string::String>,
104            path: ::std::path::PathBuf,
105            relative_path: ::std::path::PathBuf,
106            project_path: ::std::option::Option<::std::string::String>,
107            has_publish_task: bool,
108            has_publish_to_maven_local_task: bool,
109        ) -> Self {
110            Self {
111                name,
112                version,
113                path,
114                relative_path,
115                project_path,
116                is_changed: false,
117                dependencies: ::std::collections::HashSet::new(),
118                has_publish_task,
119                has_publish_to_maven_local_task,
120            }
121        }
122    };
123}
124
125pub(crate) use impl_gradle_constructors;
126
127/// Expand to the two publish-task flag accessors shared verbatim by the
128/// `Package` impl for `GradlePackage` and the `Workspace` impl for
129/// `GradleWorkspace`.
130///
131/// Both traits declare `is_publishable_by_default` and
132/// `is_dry_run_publishable_by_default` with the same signature, and Java
133/// answers both from the task flags the finder probed off the Gradle wrapper
134/// rather than from a single `publishable_by_default` field — so
135/// [`changepacks_core::impl_publishable_by_default!`], which reads that field,
136/// cannot be reused here.
137///
138/// These are plain sync methods, so unlike `update_version` / `publish` /
139/// `dry_run_publish` they are unaffected by `#[async_trait]`'s rewrite of the
140/// `impl` block (the E0195 signature mismatch only bites methods that
141/// `async_trait` desugars). Fully-qualified `::std::primitive::bool` keeps the
142/// macro hygienic.
143///
144/// Consumer requirement: the struct must own `has_publish_task` and
145/// `has_publish_to_maven_local_task` `bool` fields, both supplied by
146/// [`declare_gradle_project!`]. Invoked from inside the `#[async_trait]`
147/// `impl Package for GradlePackage` / `impl Workspace for GradleWorkspace`
148/// blocks, the only two intended callers.
149macro_rules! impl_gradle_publish_task_flags {
150    () => {
151        fn is_publishable_by_default(&self) -> ::std::primitive::bool {
152            self.has_publish_task
153        }
154
155        fn is_dry_run_publishable_by_default(&self) -> ::std::primitive::bool {
156            self.has_publish_to_maven_local_task
157        }
158    };
159}
160
161pub(crate) use impl_gradle_publish_task_flags;
162
163/// Declare a Gradle project struct plus its shared inherent constructors.
164///
165/// `GradlePackage` and `GradleWorkspace` carry exactly the same nine fields
166/// — previously declared in two different orders — and each followed the
167/// declaration with an inherent impl containing
168/// [`impl_gradle_constructors!`], which already hard-codes those field
169/// names. Canonicalizing the layout here keeps the two in lockstep; the
170/// expansion's struct literal uses field-init shorthand, so the former
171/// order difference was cosmetic. Java cannot use
172/// `changepacks_core::declare_discovered_project!`: it carries
173/// `project_path` plus the two publish-task flags and has no
174/// `publishable_by_default` field. Outer attributes (including doc
175/// comments) pass through; other inherent methods and every trait impl stay
176/// in separate blocks beside the invocation.
177macro_rules! declare_gradle_project {
178    ($(#[$meta:meta])* pub struct $name:ident) => {
179        $(#[$meta])*
180        #[derive(::std::fmt::Debug)]
181        pub struct $name {
182            name: ::std::option::Option<::std::string::String>,
183            version: ::std::option::Option<::std::string::String>,
184            path: ::std::path::PathBuf,
185            relative_path: ::std::path::PathBuf,
186            project_path: ::std::option::Option<::std::string::String>,
187            is_changed: ::std::primitive::bool,
188            dependencies: ::std::collections::HashSet<::std::string::String>,
189            has_publish_task: ::std::primitive::bool,
190            has_publish_to_maven_local_task: ::std::primitive::bool,
191        }
192
193        impl $name {
194            crate::impl_gradle_constructors!();
195        }
196    };
197}
198
199pub(crate) use declare_gradle_project;
200
201/// Read a Gradle build script (`build.gradle` or `build.gradle.kts`) and
202/// attach the build-file read context to any I/O failure.
203///
204/// [`finder::GradleProjectFinder::visit`] and [`write_gradle_version`] both
205/// open with this exact read, and both must attribute a failure to the build
206/// script's own path instead of surfacing a bare `os error`. Owning the read
207/// and its `Failed to read Gradle build file <path>` context here keeps the
208/// two messages from drifting apart; every other language crate already
209/// funnels its manifest read through a single head.
210///
211/// # Errors
212/// Returns the underlying [`std::io::Error`] as the cause, wrapped with the
213/// build file path, when the script cannot be read.
214pub(crate) async fn read_gradle_build_file(path: &Path) -> Result<String> {
215    tokio::fs::read_to_string(path)
216        .await
217        .with_context(|| format!("Failed to read Gradle build file {}", path.display()))
218}
219
220/// Compute the next semver version and write it into the Gradle build file.
221///
222/// `GradlePackage::update_version` and `GradleWorkspace::update_version` had
223/// byte-identical bodies apart from the [`GradleVersionScope`] they select
224/// (`ScriptOnly` for a package, `ScriptAndAllProjects` for a workspace root),
225/// so the shared body lives here and each trait method is a single delegating
226/// call that supplies its own scope.
227///
228/// This is a plain free function rather than a `macro_rules!`: `#[async_trait]`
229/// rewrites the `impl` block before macro bodies expand, so a macro invocation
230/// inside the impl would emit an `async fn` that no longer matches the
231/// desugared trait signature (E0195) — see the matching note in `package.rs`.
232///
233/// [`GradleVersionScope`]: version_updater::GradleVersionScope
234///
235/// # Errors
236/// Returns an error when the next version cannot be computed, or when the
237/// Gradle build file (or its sibling `gradle.properties`) cannot be rewritten.
238/// A failed write leaves `version` untouched.
239pub(crate) async fn bump_gradle_version(
240    version: &mut Option<String>,
241    path: &Path,
242    update_type: UpdateType,
243    scope: version_updater::GradleVersionScope,
244) -> Result<()> {
245    changepacks_utils::bump_version_with(version, path, update_type, async |new| {
246        write_gradle_version(path, new, scope).await
247    })
248    .await
249}
250
251fn finish_isolated_gradle_dry_run(
252    publish_result: Result<changepacks_core::publish::PublishOutput>,
253    cleanup_result: Result<()>,
254) -> Result<changepacks_core::publish::PublishOutput> {
255    match (publish_result, cleanup_result) {
256        (Ok(output), Ok(())) => Ok(output),
257        (Err(publish_error), Ok(())) => Err(publish_error),
258        (Ok(output), Err(cleanup_error)) => {
259            let outcome = if output.success {
260                "succeeded"
261            } else {
262                "reported failure"
263            };
264            Err(anyhow::anyhow!(
265                "Gradle dry run {outcome}; stdout: {}; stderr: {}; failed to remove isolated temporary Maven local repository: {cleanup_error:#}",
266                output.stdout,
267                output.stderr,
268            ))
269        }
270        (Err(publish_error), Err(cleanup_error)) => Err(anyhow::anyhow!(
271            "Gradle dry run failed: {publish_error:#}; failed to remove isolated temporary Maven local repository: {cleanup_error:#}"
272        )),
273    }
274}
275
276async fn run_built_in_gradle_dry_run_with<Run, RunFuture>(
277    run_gradle: Run,
278) -> Result<changepacks_core::publish::PublishOutput>
279where
280    Run: FnOnce(PathBuf) -> RunFuture,
281    RunFuture: Future<Output = Result<changepacks_core::publish::PublishOutput>>,
282{
283    let maven_local = tempfile::Builder::new()
284        .prefix("changepacks-maven-local-")
285        .tempdir()
286        .context("Failed to create isolated temporary Maven local repository")?;
287    let repository = maven_local.path().to_path_buf();
288    let publish_result = run_gradle(repository).await;
289    let cleanup_result = maven_local
290        .close()
291        .context("Failed to remove isolated temporary Maven local repository");
292
293    finish_isolated_gradle_dry_run(publish_result, cleanup_result)
294}
295
296pub(crate) async fn run_publish_for_path(
297    path: &Path,
298    relative_path: &Path,
299    project_path: Option<&str>,
300    config: &Config,
301    missing_dir_message: &'static str,
302) -> Result<changepacks_core::publish::PublishOutput> {
303    if let Some(command) = resolve_publish_override(&config.publish, relative_path) {
304        return changepacks_core::publish::run_publish_flow(
305            &command,
306            path,
307            &[],
308            missing_dir_message,
309        )
310        .await;
311    }
312
313    finder::run_gradle_publish(
314        path,
315        relative_path,
316        project_path,
317        "publish",
318        &[],
319        missing_dir_message,
320    )
321    .await
322}
323
324pub(crate) async fn run_dry_run_publish_for_path(
325    path: &Path,
326    relative_path: &Path,
327    project_path: Option<&str>,
328    config: &Config,
329    missing_dir_message: &'static str,
330) -> Result<Option<changepacks_core::publish::PublishOutput>> {
331    if let Some(command) = resolve_publish_override(&config.publish_dry_run, relative_path) {
332        return changepacks_core::publish::run_dry_run_publish_flow(
333            Some(&command),
334            path,
335            &[],
336            missing_dir_message,
337        )
338        .await;
339    }
340
341    run_built_in_gradle_dry_run_with(|maven_local| async move {
342        let mut repository_argument = OsString::from("-Dmaven.repo.local=");
343        repository_argument.push(maven_local);
344        finder::run_gradle_publish(
345            path,
346            relative_path,
347            project_path,
348            "publishToMavenLocal",
349            &[repository_argument],
350            missing_dir_message,
351        )
352        .await
353    })
354    .await
355    .map(Some)
356}
357
358fn resolve_publish_override(
359    commands: &BTreeMap<String, String>,
360    relative_path: &Path,
361) -> Option<String> {
362    changepacks_core::publish::lookup_by_path_or_language(
363        commands,
364        relative_path,
365        changepacks_core::Language::Java,
366    )
367    .cloned()
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::collections::BTreeMap;
374    use std::fs;
375    use std::path::PathBuf;
376    use tempfile::TempDir;
377
378    fn create_isolation_asserting_wrapper(root: &Path, exit_code: i32) {
379        #[cfg(windows)]
380        fs::write(
381            root.join("gradlew.bat"),
382            format!(
383                "@echo off\n\
384                 if not \"%~1\"==\":libs:core:publishToMavenLocal\" (\n\
385                   echo unexpected task: %~1 1>&2\n\
386                   exit /b 41\n\
387                 )\n\
388                 if not \"%~3\"==\"\" (\n\
389                   echo unexpected additional argument: %~3 1>&2\n\
390                   exit /b 44\n\
391                 )\n\
392                 set \"repo_argument=%~2\"\n\
393                 if not \"%repo_argument:~0,19%\"==\"-Dmaven.repo.local=\" (\n\
394                   echo missing isolated Maven repository argument 1>&2\n\
395                   exit /b 42\n\
396                 )\n\
397                 set \"repo=%repo_argument:~19%\"\n\
398                 if not exist \"%repo%\" (\n\
399                   echo isolated Maven repository did not exist during execution 1>&2\n\
400                   exit /b 43\n\
401                 )\n\
402                 echo isolated_repo=%repo%\n\
403                 exit /b {exit_code}\n"
404            ),
405        )
406        .unwrap();
407
408        #[cfg(not(windows))]
409        {
410            use std::os::unix::fs::PermissionsExt;
411
412            let wrapper = root.join("gradlew");
413            fs::write(
414                &wrapper,
415                format!(
416                    "#!/bin/sh\n\
417                     if [ \"$#\" -ne 2 ]; then\n\
418                       printf 'expected exactly two arguments, received %s\\n' \"$#\" >&2\n\
419                       exit 44\n\
420                     fi\n\
421                     if [ \"$1\" != ':libs:core:publishToMavenLocal' ]; then\n\
422                       printf 'unexpected task: %s\\n' \"$1\" >&2\n\
423                       exit 41\n\
424                     fi\n\
425                     repo_argument=$2\n\
426                     case $repo_argument in\n\
427                       -Dmaven.repo.local=*) repo=${{repo_argument#-Dmaven.repo.local=}} ;;\n\
428                       *) printf 'missing isolated Maven repository argument\\n' >&2; exit 42 ;;\n\
429                     esac\n\
430                     if [ ! -d \"$repo\" ]; then\n\
431                       printf 'isolated Maven repository did not exist during execution\\n' >&2\n\
432                       exit 43\n\
433                     fi\n\
434                     printf 'isolated_repo=%s\\n' \"$repo\"\n\
435                     exit {exit_code}\n"
436                ),
437            )
438            .unwrap();
439            fs::set_permissions(&wrapper, fs::Permissions::from_mode(0o755)).unwrap();
440        }
441    }
442
443    fn nested_gradle_manifest(temp_dir: &TempDir) -> (PathBuf, PathBuf) {
444        let root = temp_dir.path().join("repo with spaces");
445        let project_dir = root.join("libs").join("core");
446        fs::create_dir_all(&project_dir).unwrap();
447        let manifest = project_dir.join("build.gradle.kts");
448        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
449        (root, manifest)
450    }
451
452    fn isolated_repository_from(output: &changepacks_core::publish::PublishOutput) -> PathBuf {
453        output
454            .stdout
455            .lines()
456            .find_map(|line| line.strip_prefix("isolated_repo="))
457            .map(PathBuf::from)
458            .expect("fake Gradle wrapper did not report its isolated Maven repository")
459    }
460
461    fn captured_publish_output(success: bool) -> changepacks_core::publish::PublishOutput {
462        changepacks_core::publish::PublishOutput {
463            success,
464            stdout: "captured Gradle stdout".to_string(),
465            stderr: "captured Gradle stderr".to_string(),
466        }
467    }
468
469    fn create_no_args_override(project_dir: &Path) -> String {
470        #[cfg(windows)]
471        {
472            fs::write(
473                project_dir.join("override-check.bat"),
474                "@echo off\n\
475                 if not \"%~1\"==\"\" (\n\
476                   echo unexpected argument: %~1 1>&2\n\
477                   exit /b 51\n\
478                 )\n\
479                 echo override-without-injected-args\n",
480            )
481            .unwrap();
482            "call override-check.bat".to_string()
483        }
484
485        #[cfg(not(windows))]
486        {
487            use std::os::unix::fs::PermissionsExt;
488
489            let script = project_dir.join("override-check.sh");
490            fs::write(
491                &script,
492                "#!/bin/sh\n\
493                 if [ \"$#\" -ne 0 ]; then\n\
494                   printf 'unexpected argument: %s\\n' \"$1\" >&2\n\
495                   exit 51\n\
496                 fi\n\
497                 printf 'override-without-injected-args\\n'\n",
498            )
499            .unwrap();
500            fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
501            "./override-check.sh".to_string()
502        }
503    }
504
505    #[test]
506    fn test_resolve_publish_override_prefers_path_then_language() {
507        let relative_path = Path::new("libs/core/build.gradle.kts");
508        let mut commands = BTreeMap::new();
509        commands.insert("java".to_string(), "language-command".to_string());
510        commands.insert(
511            relative_path.to_string_lossy().into_owned(),
512            "path-command".to_string(),
513        );
514
515        assert_eq!(
516            resolve_publish_override(&commands, relative_path).as_deref(),
517            Some("path-command")
518        );
519        commands.remove(relative_path.to_string_lossy().as_ref());
520        assert_eq!(
521            resolve_publish_override(&commands, relative_path).as_deref(),
522            Some("language-command")
523        );
524        commands.clear();
525        assert_eq!(resolve_publish_override(&commands, relative_path), None);
526    }
527
528    #[test]
529    fn finish_isolated_dry_run_returns_output_when_cleanup_succeeds() {
530        let result =
531            finish_isolated_gradle_dry_run(Ok(captured_publish_output(true)), Ok(())).unwrap();
532
533        assert!(result.success);
534        assert_eq!(result.stdout, "captured Gradle stdout");
535        assert_eq!(result.stderr, "captured Gradle stderr");
536    }
537
538    #[test]
539    fn finish_isolated_dry_run_returns_execution_error_when_cleanup_succeeds() {
540        let error = finish_isolated_gradle_dry_run(
541            Err(anyhow::anyhow!("wrapper execution failed")),
542            Ok(()),
543        )
544        .unwrap_err();
545
546        assert_eq!(error.to_string(), "wrapper execution failed");
547    }
548
549    #[test]
550    fn finish_isolated_dry_run_reports_cleanup_error_after_successful_output() {
551        let error = finish_isolated_gradle_dry_run(
552            Ok(captured_publish_output(true)),
553            Err(anyhow::anyhow!("injected cleanup failure")),
554        )
555        .unwrap_err()
556        .to_string();
557
558        assert!(error.contains("Gradle dry run succeeded"), "{error}");
559        assert!(error.contains("captured Gradle stdout"), "{error}");
560        assert!(error.contains("captured Gradle stderr"), "{error}");
561        assert!(error.contains("injected cleanup failure"), "{error}");
562    }
563
564    #[test]
565    fn finish_isolated_dry_run_retains_nonzero_output_when_cleanup_fails() {
566        let error = finish_isolated_gradle_dry_run(
567            Ok(captured_publish_output(false)),
568            Err(anyhow::anyhow!("injected cleanup failure")),
569        )
570        .unwrap_err()
571        .to_string();
572
573        assert!(error.contains("Gradle dry run reported failure"), "{error}");
574        assert!(error.contains("captured Gradle stdout"), "{error}");
575        assert!(error.contains("captured Gradle stderr"), "{error}");
576        assert!(error.contains("injected cleanup failure"), "{error}");
577    }
578
579    #[test]
580    fn finish_isolated_dry_run_reports_execution_and_cleanup_errors() {
581        let error = finish_isolated_gradle_dry_run(
582            Err(anyhow::anyhow!("wrapper execution failed")),
583            Err(anyhow::anyhow!("injected cleanup failure")),
584        )
585        .unwrap_err()
586        .to_string();
587
588        assert!(error.contains("wrapper execution failed"), "{error}");
589        assert!(error.contains("injected cleanup failure"), "{error}");
590    }
591
592    #[tokio::test]
593    async fn built_in_dry_run_cleans_isolated_repository_when_wrapper_is_missing() {
594        let temp_dir = TempDir::new().unwrap();
595        let manifest = temp_dir.path().join("build.gradle.kts");
596        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
597        let mut observed_repository = None;
598
599        let result = run_built_in_gradle_dry_run_with(|maven_local| {
600            observed_repository = Some(maven_local.clone());
601            async move {
602                let mut repository_argument = OsString::from("-Dmaven.repo.local=");
603                repository_argument.push(maven_local);
604                finder::run_gradle_publish(
605                    &manifest,
606                    Path::new("build.gradle.kts"),
607                    Some(":"),
608                    "publishToMavenLocal",
609                    &[repository_argument],
610                    "Package directory not found",
611                )
612                .await
613            }
614        })
615        .await;
616
617        let error = result.unwrap_err().to_string();
618        assert!(
619            error.contains("Gradle wrapper (gradlew) not found"),
620            "{error}"
621        );
622        let observed_repository = observed_repository.unwrap();
623        assert!(
624            !observed_repository.exists(),
625            "temporary Maven repository survived wrapper lookup failure: {}",
626            observed_repository.display()
627        );
628        temp_dir.close().unwrap();
629    }
630
631    #[tokio::test]
632    async fn built_in_dry_run_uses_exact_subproject_task_and_removes_isolated_repository() {
633        let temp_dir = TempDir::new().unwrap();
634        let (root, manifest) = nested_gradle_manifest(&temp_dir);
635        create_isolation_asserting_wrapper(&root, 0);
636        let relative_path = Path::new("libs/core/build.gradle.kts");
637
638        let output = run_dry_run_publish_for_path(
639            &manifest,
640            relative_path,
641            Some(":libs:core"),
642            &Config::default(),
643            "Package directory not found",
644        )
645        .await
646        .unwrap()
647        .unwrap();
648
649        assert!(output.success, "stderr: {}", output.stderr);
650        let isolated_repository = isolated_repository_from(&output);
651        assert!(
652            isolated_repository.file_name().is_some_and(|name| name
653                .to_string_lossy()
654                .starts_with("changepacks-maven-local-")),
655            "Gradle did not receive a changepacks-owned temporary repository: {}",
656            isolated_repository.display()
657        );
658        assert!(
659            !isolated_repository.exists(),
660            "temporary Maven repository was not cleaned up: {}",
661            isolated_repository.display()
662        );
663        temp_dir.close().unwrap();
664    }
665
666    #[tokio::test]
667    async fn built_in_dry_run_removes_isolated_repository_after_gradle_failure() {
668        let temp_dir = TempDir::new().unwrap();
669        let (root, manifest) = nested_gradle_manifest(&temp_dir);
670        create_isolation_asserting_wrapper(&root, 29);
671        let relative_path = Path::new("libs/core/build.gradle.kts");
672
673        let output = run_dry_run_publish_for_path(
674            &manifest,
675            relative_path,
676            Some(":libs:core"),
677            &Config::default(),
678            "Package directory not found",
679        )
680        .await
681        .unwrap()
682        .unwrap();
683
684        assert!(!output.success);
685        let isolated_repository = isolated_repository_from(&output);
686        assert!(
687            !isolated_repository.exists(),
688            "temporary Maven repository was not cleaned up after failure: {}",
689            isolated_repository.display()
690        );
691        temp_dir.close().unwrap();
692    }
693
694    #[tokio::test]
695    async fn configured_dry_run_override_receives_no_injected_argument() {
696        let temp_dir = TempDir::new().unwrap();
697        let (_root, manifest) = nested_gradle_manifest(&temp_dir);
698        let project_dir = manifest.parent().unwrap();
699        let mut publish_dry_run = BTreeMap::new();
700        publish_dry_run.insert("java".to_string(), create_no_args_override(project_dir));
701        let config = Config {
702            publish_dry_run,
703            ..Default::default()
704        };
705
706        let output = run_dry_run_publish_for_path(
707            &manifest,
708            Path::new("libs/core/build.gradle.kts"),
709            Some(":ignored:for:override"),
710            &config,
711            "Package directory not found",
712        )
713        .await
714        .unwrap()
715        .unwrap();
716
717        assert!(output.success, "stderr: {}", output.stderr);
718        assert_eq!(output.stdout.trim(), "override-without-injected-args");
719        temp_dir.close().unwrap();
720    }
721}