Skip to main content

changepacks_java/
package.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::Config;
4use changepacks_core::{Language, Package, UpdateType};
5
6// Nine-field declaration plus the three-step constructor chain, shared
7// verbatim with `GradleWorkspace` (see `declare_gradle_project!` in `lib.rs`).
8crate::declare_gradle_project!(pub struct GradlePackage);
9
10#[async_trait]
11impl Package for GradlePackage {
12    // Standard package/workspace accessors.
13    changepacks_core::impl_basic_accessors!();
14
15    // Body shared verbatim with `GradleWorkspace::update_version` via
16    // `crate::bump_gradle_version`; only the scope differs. A package owns
17    // exactly one project, so only an outermost declaration is editable.
18    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
19        crate::bump_gradle_version(
20            &mut self.version,
21            &self.path,
22            update_type,
23            crate::version_updater::GradleVersionScope::ScriptOnly,
24        )
25        .await
26    }
27
28    // Fixed language accessor.
29    changepacks_core::impl_language!(Language::Java);
30
31    // Per-OS command lives on the const in `crate` (see `lib.rs`). Gradle's
32    // `--dry-run` flag only previews the task graph without executing
33    // tasks, so it cannot validate the publishing pipeline;
34    // `publishToMavenLocal` is the closest functional equivalent: it runs
35    // the entire publish flow (configuration, artifact generation, POM
36    // generation) but writes to `~/.m2/repository` instead of uploading
37    // to a remote registry.
38    changepacks_core::impl_const_publish_commands!(
39        crate::PUBLISH_COMMAND,
40        crate::DRY_RUN_PUBLISH_COMMAND
41    );
42
43    // Publish-task flag accessors shared verbatim with `GradleWorkspace`.
44    // Both are plain sync methods, so a crate-local macro works here (see
45    // `impl_gradle_publish_task_flags!` in `lib.rs`).
46    crate::impl_gradle_publish_task_flags!();
47
48    // The two methods below are byte-identical to `GradleWorkspace`'s apart
49    // from the directory-not-found message, but they are deliberately NOT
50    // folded into a crate-local macro: `publish` / `dry_run_publish` are
51    // `#[async_trait]` methods, and `async_trait` rewrites the `impl`
52    // block before `macro_rules!` bodies expand, so a macro invocation
53    // here would emit a plain `async fn` that no longer matches the
54    // desugared trait signature (E0195). Emitting the desugared
55    // `Pin<Box<dyn Future>>` form from a macro instead compiles, but costs
56    // more lines than it saves and hides two trivial delegations behind
57    // hand-written `async_trait` boilerplate.
58    async fn publish(&self, config: &Config) -> Result<changepacks_core::publish::PublishOutput> {
59        crate::run_publish_for_path(
60            self.path(),
61            self.relative_path(),
62            self.project_path.as_deref(),
63            config,
64            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND,
65        )
66        .await
67    }
68
69    async fn dry_run_publish(
70        &self,
71        config: &Config,
72    ) -> Result<Option<changepacks_core::publish::PublishOutput>> {
73        crate::run_dry_run_publish_for_path(
74            self.path(),
75            self.relative_path(),
76            self.project_path.as_deref(),
77            config,
78            changepacks_core::publish::PACKAGE_DIR_NOT_FOUND,
79        )
80        .await
81    }
82
83    // Dependency set accessors.
84    changepacks_core::impl_dependencies_accessors!();
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::test_support::{assert_reported_cwd, captured_argv, create_publish_wrapper};
91    use changepacks_core::{Config, UpdateType};
92    use rstest::rstest;
93    use std::collections::BTreeMap;
94    use std::fs;
95    use std::path::PathBuf;
96    use tempfile::TempDir;
97    use tokio::fs::read_to_string;
98
99    fn shell_echo_command(message: &str) -> String {
100        format!("echo {message} && echo shell-override")
101    }
102
103    fn assert_gradle_package_defaults(package: &GradlePackage) {
104        assert_eq!(package.name(), Some("test-package"));
105        assert_eq!(package.version(), Some("1.0.0"));
106        assert_eq!(package.path(), PathBuf::from("/test/build.gradle.kts"));
107        assert_eq!(
108            package.relative_path(),
109            PathBuf::from("test/build.gradle.kts")
110        );
111        assert_eq!(package.language(), Language::Java);
112        assert!(!package.is_changed());
113        assert!(package.is_publishable_by_default());
114        assert!(package.is_dry_run_publishable_by_default());
115        #[cfg(windows)]
116        {
117            assert_eq!(package.default_publish_command(), ".\\gradlew.bat publish");
118            assert_eq!(
119                package.default_dry_run_publish_command().as_deref(),
120                Some(".\\gradlew.bat publishToMavenLocal")
121            );
122        }
123        #[cfg(not(windows))]
124        {
125            assert_eq!(package.default_publish_command(), "./gradlew publish");
126            assert_eq!(
127                package.default_dry_run_publish_command().as_deref(),
128                Some("./gradlew publishToMavenLocal")
129            );
130        }
131    }
132
133    #[tokio::test]
134    async fn test_gradle_package_new() {
135        let package = GradlePackage::new(
136            Some("test-package".to_string()),
137            Some("1.0.0".to_string()),
138            PathBuf::from("/test/build.gradle.kts"),
139            PathBuf::from("test/build.gradle.kts"),
140        );
141
142        assert_gradle_package_defaults(&package);
143    }
144
145    #[test]
146    fn test_gradle_package_publishability_tracks_available_tasks() {
147        let remote_only = GradlePackage::new_with_publish_tasks(
148            Some("remote-only".to_string()),
149            Some("1.0.0".to_string()),
150            PathBuf::from("/test/build.gradle.kts"),
151            PathBuf::from("test/build.gradle.kts"),
152            true,
153            false,
154        );
155        let local_only = GradlePackage::new_with_publish_tasks(
156            Some("local-only".to_string()),
157            Some("1.0.0".to_string()),
158            PathBuf::from("/test/build.gradle.kts"),
159            PathBuf::from("test/build.gradle.kts"),
160            false,
161            true,
162        );
163
164        assert!(remote_only.is_publishable_by_default());
165        assert!(!remote_only.is_dry_run_publishable_by_default());
166        assert!(!local_only.is_publishable_by_default());
167        assert!(local_only.is_dry_run_publishable_by_default());
168    }
169
170    #[test]
171    fn test_gradle_package_set_changed() {
172        changepacks_core::assert_set_changed_roundtrip!(GradlePackage::new(
173            Some("test-package".to_string()),
174            Some("1.0.0".to_string()),
175            PathBuf::from("/test/build.gradle.kts"),
176            PathBuf::from("test/build.gradle.kts"),
177        ));
178    }
179
180    #[tokio::test]
181    async fn test_publish_nested_project_uses_wrapper_root_and_exact_task_path() {
182        let temp_dir = TempDir::new().unwrap();
183        let root = temp_dir.path().join("repo with spaces");
184        let project_dir = root.join("libs").join("core");
185        fs::create_dir_all(&project_dir).unwrap();
186        create_publish_wrapper(&root);
187        let manifest = project_dir.join("build.gradle.kts");
188        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
189        let package = GradlePackage::new(
190            Some("core".to_string()),
191            Some("1.0.0".to_string()),
192            manifest,
193            PathBuf::from("libs/core/build.gradle.kts"),
194        );
195
196        let output = package.publish(&Config::default()).await.unwrap();
197        let dry_run = package
198            .dry_run_publish(&Config::default())
199            .await
200            .unwrap()
201            .unwrap();
202
203        assert!(output.success, "stderr: {}", output.stderr);
204        assert_reported_cwd(&output.stdout, &root);
205        assert_eq!(captured_argv(&output.stdout), [":libs:core:publish"]);
206        assert!(dry_run.success, "stderr: {}", dry_run.stderr);
207        let dry_run_argv = captured_argv(&dry_run.stdout);
208        assert_eq!(dry_run_argv.len(), 2, "stdout: {}", dry_run.stdout);
209        assert_eq!(dry_run_argv[0], ":libs:core:publishToMavenLocal");
210        assert!(dry_run_argv[1].starts_with("-Dmaven.repo.local="));
211    }
212
213    #[tokio::test]
214    async fn test_public_constructor_uses_root_tasks_for_nested_project_owning_wrapper() {
215        let temp_dir = TempDir::new().unwrap();
216        let project_dir = temp_dir.path().join("tools").join("standalone");
217        fs::create_dir_all(&project_dir).unwrap();
218        create_publish_wrapper(&project_dir);
219        let manifest = project_dir.join("build.gradle.kts");
220        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
221        let package = GradlePackage::new(
222            Some("standalone".to_string()),
223            Some("1.0.0".to_string()),
224            manifest,
225            PathBuf::from("tools/standalone/build.gradle.kts"),
226        );
227
228        let output = package.publish(&Config::default()).await.unwrap();
229        let dry_run = package
230            .dry_run_publish(&Config::default())
231            .await
232            .unwrap()
233            .unwrap();
234
235        assert!(output.success, "stderr: {}", output.stderr);
236        assert_eq!(captured_argv(&output.stdout), ["publish"]);
237        assert!(dry_run.success, "stderr: {}", dry_run.stderr);
238        let dry_run_argv = captured_argv(&dry_run.stdout);
239        assert_eq!(dry_run_argv.len(), 2, "stdout: {}", dry_run.stdout);
240        assert_eq!(dry_run_argv[0], "publishToMavenLocal");
241        assert!(dry_run_argv[1].starts_with("-Dmaven.repo.local="));
242    }
243
244    #[tokio::test]
245    async fn test_publish_filesystem_remapped_project_uses_exact_gradle_project_path_argv() {
246        let temp_dir = TempDir::new().unwrap();
247        let root = temp_dir.path().join("repo with spaces");
248        let project_dir = root.join("generated-backend");
249        fs::create_dir_all(&project_dir).unwrap();
250        create_publish_wrapper(&root);
251        let manifest = project_dir.join("build.gradle.kts");
252        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
253        let package = GradlePackage::new_with_project_path_and_publish_tasks(
254            Some("api".to_string()),
255            Some("1.0.0".to_string()),
256            manifest,
257            PathBuf::from("generated-backend/build.gradle.kts"),
258            Some(":api".to_string()),
259            true,
260            true,
261        );
262
263        let output = package.publish(&Config::default()).await.unwrap();
264        let dry_run = package
265            .dry_run_publish(&Config::default())
266            .await
267            .unwrap()
268            .unwrap();
269
270        assert!(output.success, "stderr: {}", output.stderr);
271        assert_eq!(captured_argv(&output.stdout), [":api:publish"]);
272        assert!(dry_run.success, "stderr: {}", dry_run.stderr);
273        let dry_run_argv = captured_argv(&dry_run.stdout);
274        assert_eq!(dry_run_argv.len(), 2, "stdout: {}", dry_run.stdout);
275        assert_eq!(dry_run_argv[0], ":api:publishToMavenLocal");
276        assert!(dry_run_argv[1].starts_with("-Dmaven.repo.local="));
277    }
278
279    #[tokio::test]
280    async fn test_publish_default_errors_when_platform_wrapper_is_missing() {
281        let temp_dir = TempDir::new().unwrap();
282        let manifest = temp_dir.path().join("build.gradle.kts");
283        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
284        let package = GradlePackage::new(
285            Some("root".to_string()),
286            Some("1.0.0".to_string()),
287            manifest,
288            PathBuf::from("build.gradle.kts"),
289        );
290
291        let error = package.publish(&Config::default()).await.unwrap_err();
292
293        assert!(
294            error
295                .to_string()
296                .contains("Gradle wrapper (gradlew) not found")
297        );
298    }
299
300    #[tokio::test]
301    async fn test_publish_path_override_keeps_shell_execution_and_skips_wrapper_lookup() {
302        let temp_dir = TempDir::new().unwrap();
303        let manifest = temp_dir.path().join("build.gradle.kts");
304        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
305        let package = GradlePackage::new(
306            Some("root".to_string()),
307            Some("1.0.0".to_string()),
308            manifest,
309            PathBuf::from("build.gradle.kts"),
310        );
311        let mut publish = BTreeMap::new();
312        publish.insert(
313            "build.gradle.kts".to_string(),
314            shell_echo_command("path-override"),
315        );
316        publish.insert("java".to_string(), shell_echo_command("language-override"));
317        let config = Config {
318            publish,
319            ..Default::default()
320        };
321
322        let output = package.publish(&config).await.unwrap();
323
324        assert!(output.success, "stderr: {}", output.stderr);
325        assert!(output.stdout.contains("path-override"));
326        assert!(output.stdout.contains("shell-override"));
327        assert!(!output.stdout.contains("language-override"));
328    }
329
330    #[tokio::test]
331    async fn test_dry_run_publish_language_override_keeps_shell_execution() {
332        let temp_dir = TempDir::new().unwrap();
333        let manifest = temp_dir.path().join("build.gradle.kts");
334        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
335        let package = GradlePackage::new(
336            Some("root".to_string()),
337            Some("1.0.0".to_string()),
338            manifest,
339            PathBuf::from("build.gradle.kts"),
340        );
341        let mut publish_dry_run = BTreeMap::new();
342        publish_dry_run.insert("java".to_string(), shell_echo_command("language-dry-run"));
343        let config = Config {
344            publish_dry_run,
345            ..Default::default()
346        };
347
348        let output = package.dry_run_publish(&config).await.unwrap().unwrap();
349
350        assert!(output.success, "stderr: {}", output.stderr);
351        assert!(output.stdout.contains("language-dry-run"));
352        assert!(output.stdout.contains("shell-override"));
353    }
354
355    #[rstest]
356    #[case(UpdateType::Patch, "1.0.1")]
357    #[case(UpdateType::Minor, "1.1.0")]
358    #[case(UpdateType::Major, "2.0.0")]
359    #[tokio::test]
360    async fn test_gradle_package_update_version_kts(
361        #[case] update_type: UpdateType,
362        #[case] expected: &str,
363    ) {
364        let temp_dir = TempDir::new().unwrap();
365        let project_dir = temp_dir.path().join("myproject");
366        fs::create_dir_all(&project_dir).unwrap();
367
368        let build_gradle = project_dir.join("build.gradle.kts");
369        fs::write(
370            &build_gradle,
371            r#"
372plugins {
373    id("java")
374}
375
376group = "com.example"
377version = "1.0.0"
378"#,
379        )
380        .unwrap();
381
382        let mut package = GradlePackage::new(
383            Some("myproject".to_string()),
384            Some("1.0.0".to_string()),
385            build_gradle.clone(),
386            PathBuf::from("myproject/build.gradle.kts"),
387        );
388
389        package.update_version(update_type).await.unwrap();
390
391        let content = read_to_string(&build_gradle).await.unwrap();
392        assert!(content.contains(&format!(r#"version = "{expected}""#)));
393        assert_eq!(package.version(), Some(expected));
394
395        temp_dir.close().unwrap();
396    }
397
398    #[tokio::test]
399    async fn test_gradle_package_update_version_groovy() {
400        let temp_dir = TempDir::new().unwrap();
401        let project_dir = temp_dir.path().join("myproject");
402        fs::create_dir_all(&project_dir).unwrap();
403
404        let build_gradle = project_dir.join("build.gradle");
405        fs::write(
406            &build_gradle,
407            r"
408plugins {
409    id 'java'
410}
411
412group = 'com.example'
413version = '1.0.0'
414",
415        )
416        .unwrap();
417
418        let mut package = GradlePackage::new(
419            Some("myproject".to_string()),
420            Some("1.0.0".to_string()),
421            build_gradle.clone(),
422            PathBuf::from("myproject/build.gradle"),
423        );
424
425        package.update_version(UpdateType::Patch).await.unwrap();
426
427        let content = read_to_string(&build_gradle).await.unwrap();
428        assert!(content.contains("version = '1.0.1'"));
429        assert_eq!(package.version(), Some("1.0.1"));
430
431        temp_dir.close().unwrap();
432    }
433
434    #[tokio::test]
435    async fn test_gradle_package_update_version_with_fallback() {
436        let temp_dir = TempDir::new().unwrap();
437        let project_dir = temp_dir.path().join("myproject");
438        fs::create_dir_all(&project_dir).unwrap();
439
440        let build_gradle = project_dir.join("build.gradle.kts");
441        fs::write(
442            &build_gradle,
443            r#"
444group = "com.devfive"
445version = project.findProperty("releaseVersion") ?: "1.0.11"
446"#,
447        )
448        .unwrap();
449
450        let mut package = GradlePackage::new(
451            Some("myproject".to_string()),
452            Some("1.0.11".to_string()),
453            build_gradle.clone(),
454            PathBuf::from("myproject/build.gradle.kts"),
455        );
456
457        package.update_version(UpdateType::Patch).await.unwrap();
458
459        let content = read_to_string(&build_gradle).await.unwrap();
460        assert!(content.contains(r#"?: "1.0.12""#));
461
462        temp_dir.close().unwrap();
463    }
464
465    #[tokio::test]
466    async fn gradle_package_updates_kotlin_version_from_sibling_properties() {
467        let temp_dir = TempDir::new().unwrap();
468        let build_gradle = temp_dir.path().join("build.gradle.kts");
469        let properties_path = temp_dir.path().join("gradle.properties");
470        let build_content = b"plugins { id(\"java\") }\r\n";
471        let properties_content = b"version = 1.0.0 # package\r\n";
472        tokio::fs::write(&build_gradle, build_content)
473            .await
474            .unwrap();
475        tokio::fs::write(&properties_path, properties_content)
476            .await
477            .unwrap();
478        let mut package = GradlePackage::new(
479            Some("myproject".to_string()),
480            Some("1.0.0".to_string()),
481            build_gradle.clone(),
482            PathBuf::from("build.gradle.kts"),
483        );
484
485        package.update_version(UpdateType::Patch).await.unwrap();
486
487        assert_eq!(package.version(), Some("1.0.1"));
488        assert_eq!(tokio::fs::read(&build_gradle).await.unwrap(), build_content);
489        assert_eq!(
490            tokio::fs::read(&properties_path).await.unwrap(),
491            b"version = 1.0.1 # package\r\n"
492        );
493    }
494
495    #[tokio::test]
496    async fn test_gradle_package_update_version_errors_without_editable_version() {
497        let temp_dir = TempDir::new().unwrap();
498        let build_gradle = temp_dir.path().join("build.gradle.kts");
499        let content = "plugins { id(\"java\") }\ngroup = \"com.example\"\n";
500        fs::write(&build_gradle, content).unwrap();
501        let bytes_before = fs::read(&build_gradle).unwrap();
502        let mut package = GradlePackage::new(
503            Some("myproject".to_string()),
504            Some("1.0.0".to_string()),
505            build_gradle.clone(),
506            PathBuf::from("build.gradle.kts"),
507        );
508
509        let result = package.update_version(UpdateType::Patch).await;
510
511        assert!(result.is_err());
512        assert_eq!(fs::read(&build_gradle).unwrap(), bytes_before);
513        assert_eq!(package.version(), Some("1.0.0"));
514    }
515
516    #[tokio::test]
517    async fn test_gradle_package_ambiguous_version_keeps_file_and_state_unchanged() {
518        let temp_dir = TempDir::new().unwrap();
519        let build_gradle = temp_dir.path().join("build.gradle.kts");
520        let content = "version = \"1.0.0\"\nversion = \"duplicate\"\n";
521        fs::write(&build_gradle, content).unwrap();
522        let bytes_before = fs::read(&build_gradle).unwrap();
523        let mut package = GradlePackage::new(
524            Some("myproject".to_string()),
525            Some("1.0.0".to_string()),
526            build_gradle.clone(),
527            PathBuf::from("build.gradle.kts"),
528        );
529
530        let result = package.update_version(UpdateType::Patch).await;
531
532        assert!(result.is_err());
533        assert_eq!(fs::read(&build_gradle).unwrap(), bytes_before);
534        assert_eq!(package.version(), Some("1.0.0"));
535    }
536
537    #[tokio::test]
538    async fn test_gradle_package_rejects_allprojects_only_version() {
539        let temp_dir = TempDir::new().unwrap();
540        let build_gradle = temp_dir.path().join("build.gradle.kts");
541        let content = "allprojects {\n    version = \"1.0.0\"\n}\n";
542        fs::write(&build_gradle, content).unwrap();
543        let bytes_before = fs::read(&build_gradle).unwrap();
544        let mut package = GradlePackage::new(
545            Some("myproject".to_string()),
546            Some("1.0.0".to_string()),
547            build_gradle.clone(),
548            PathBuf::from("build.gradle.kts"),
549        );
550
551        let result = package.update_version(UpdateType::Patch).await;
552
553        assert!(result.is_err());
554        assert_eq!(fs::read(&build_gradle).unwrap(), bytes_before);
555        assert_eq!(package.version(), Some("1.0.0"));
556    }
557
558    #[test]
559    fn test_gradle_package_dependencies() {
560        changepacks_core::assert_dependencies_roundtrip!(
561            GradlePackage::new(
562                Some("test-package".to_string()),
563                Some("1.0.0".to_string()),
564                PathBuf::from("/test/build.gradle.kts"),
565                PathBuf::from("test/build.gradle.kts"),
566            ),
567            "core",
568            "utils"
569        );
570    }
571
572    #[test]
573    fn test_set_name() {
574        changepacks_core::assert_set_name_roundtrip!(GradlePackage::new(
575            None,
576            Some("1.0.0".to_string()),
577            PathBuf::from("/test/build.gradle.kts"),
578            PathBuf::from("build.gradle.kts"),
579        ));
580    }
581}