1pub 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#[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
48macro_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
127macro_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
163macro_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
201pub(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
220pub(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}