Skip to main content

changepacks_java/
workspace.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::Config;
4use changepacks_core::{Language, UpdateType, Workspace};
5
6// Nine-field declaration plus the three-step constructor chain, shared
7// verbatim with `GradlePackage` (see `declare_gradle_project!` in `lib.rs`).
8crate::declare_gradle_project!(pub struct GradleWorkspace);
9
10#[async_trait]
11impl Workspace for GradleWorkspace {
12    // Seven basic accessors (`name`, `version`, `path`, `relative_path`,
13    // `is_changed`, `set_changed`, `set_name`) share their byte-identical
14    // bodies with every other language crate's `Package` / `Workspace`
15    // impl. Consolidated via `impl_basic_accessors!()` in `changepacks-core`
16    // — expansion is byte-identical to the previous hand-rolled bodies.
17    changepacks_core::impl_basic_accessors!();
18
19    // Body shared verbatim with `GradlePackage::update_version` via
20    // `crate::bump_gradle_version`; only the scope differs. A workspace root
21    // may also declare the version inside a top-level `allprojects { ... }`
22    // block, which fans the bump out to every subproject.
23    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
24        crate::bump_gradle_version(
25            &mut self.version,
26            &self.path,
27            update_type,
28            crate::version_updater::GradleVersionScope::ScriptAndAllProjects,
29        )
30        .await
31    }
32
33    // Byte-identical `fn language(&self) -> Language { Language::Java }`
34    // one-liner shared with every other language crate's `Package` /
35    // `Workspace` impl. Consolidated via `impl_language!()` in
36    // `changepacks-core` alongside the other accessor macros.
37    changepacks_core::impl_language!(Language::Java);
38
39    // Per-OS command lives on the const in `crate` (see `lib.rs`). See the
40    // Java package impl for the `publishToMavenLocal` dry-run rationale:
41    // Gradle's `--dry-run` only previews the task graph, so we run the
42    // full publish pipeline against the local Maven cache
43    // (`~/.m2/repository`) instead.
44    //
45    // Consolidated via `impl_const_publish_commands!()` in
46    // `changepacks-core` — expansion is byte-identical to the previous
47    // hand-rolled bodies.
48    changepacks_core::impl_const_publish_commands!(
49        crate::PUBLISH_COMMAND,
50        crate::DRY_RUN_PUBLISH_COMMAND
51    );
52
53    // Publish-task flag accessors shared verbatim with `GradlePackage`.
54    // Both are plain sync methods, so a crate-local macro works here (see
55    // `impl_gradle_publish_task_flags!` in `lib.rs`).
56    crate::impl_gradle_publish_task_flags!();
57
58    // See the matching note on the Java package impl: the two methods below
59    // stay hand-written because `#[async_trait]` rewrites the `impl` block
60    // before `macro_rules!` bodies expand, so a macro-emitted `async fn`
61    // no longer matches the desugared trait signature (E0195).
62    async fn publish(&self, config: &Config) -> Result<changepacks_core::publish::PublishOutput> {
63        crate::run_publish_for_path(
64            self.path(),
65            self.relative_path(),
66            self.project_path.as_deref(),
67            config,
68            changepacks_core::publish::WORKSPACE_DIR_NOT_FOUND,
69        )
70        .await
71    }
72
73    async fn dry_run_publish(
74        &self,
75        config: &Config,
76    ) -> Result<Option<changepacks_core::publish::PublishOutput>> {
77        crate::run_dry_run_publish_for_path(
78            self.path(),
79            self.relative_path(),
80            self.project_path.as_deref(),
81            config,
82            changepacks_core::publish::WORKSPACE_DIR_NOT_FOUND,
83        )
84        .await
85    }
86
87    // `dependencies()` / `add_dependency()` share their byte-identical
88    // body with every other language crate's `Package` and `Workspace`
89    // impl (all use `dependencies: HashSet<String>` as their backing
90    // store). Consolidated via the `impl_dependencies_accessors!()`
91    // macro in `changepacks-core` so future accessor tweaks land in
92    // one place — expansion is byte-identical to the previous
93    // hand-rolled bodies.
94    changepacks_core::impl_dependencies_accessors!();
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::test_support::{assert_reported_cwd, captured_argv, create_publish_wrapper};
101    use changepacks_core::UpdateType;
102    use rstest::rstest;
103    use std::fs;
104    use std::path::PathBuf;
105    use tempfile::TempDir;
106    use tokio::fs::read_to_string;
107
108    fn assert_gradle_workspace_defaults(workspace: &GradleWorkspace) {
109        assert_eq!(workspace.name(), Some("test-workspace"));
110        assert_eq!(workspace.version(), Some("1.0.0"));
111        assert_eq!(workspace.path(), PathBuf::from("/test/build.gradle.kts"));
112        assert_eq!(
113            workspace.relative_path(),
114            PathBuf::from("test/build.gradle.kts")
115        );
116        assert_eq!(workspace.language(), Language::Java);
117        assert!(!workspace.is_changed());
118        assert!(workspace.is_publishable_by_default());
119        assert!(workspace.is_dry_run_publishable_by_default());
120        #[cfg(windows)]
121        {
122            assert_eq!(
123                workspace.default_publish_command(),
124                ".\\gradlew.bat publish"
125            );
126            assert_eq!(
127                workspace.default_dry_run_publish_command().as_deref(),
128                Some(".\\gradlew.bat publishToMavenLocal")
129            );
130        }
131        #[cfg(not(windows))]
132        {
133            assert_eq!(workspace.default_publish_command(), "./gradlew publish");
134            assert_eq!(
135                workspace.default_dry_run_publish_command().as_deref(),
136                Some("./gradlew publishToMavenLocal")
137            );
138        }
139    }
140
141    #[tokio::test]
142    async fn test_gradle_workspace_new() {
143        let workspace = GradleWorkspace::new(
144            Some("test-workspace".to_string()),
145            Some("1.0.0".to_string()),
146            PathBuf::from("/test/build.gradle.kts"),
147            PathBuf::from("test/build.gradle.kts"),
148        );
149
150        assert_gradle_workspace_defaults(&workspace);
151    }
152
153    #[test]
154    fn test_gradle_workspace_publishability_tracks_available_tasks() {
155        let remote_only = GradleWorkspace::new_with_publish_tasks(
156            Some("remote-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            true,
161            false,
162        );
163        let local_only = GradleWorkspace::new_with_publish_tasks(
164            Some("local-only".to_string()),
165            Some("1.0.0".to_string()),
166            PathBuf::from("/test/build.gradle.kts"),
167            PathBuf::from("test/build.gradle.kts"),
168            false,
169            true,
170        );
171
172        assert!(remote_only.is_publishable_by_default());
173        assert!(!remote_only.is_dry_run_publishable_by_default());
174        assert!(!local_only.is_publishable_by_default());
175        assert!(local_only.is_dry_run_publishable_by_default());
176    }
177
178    #[tokio::test]
179    async fn test_gradle_workspace_new_without_name_and_version() {
180        let workspace = GradleWorkspace::new(
181            None,
182            None,
183            PathBuf::from("/test/build.gradle.kts"),
184            PathBuf::from("test/build.gradle.kts"),
185        );
186
187        assert_eq!(workspace.name(), None);
188        assert_eq!(workspace.version(), None);
189    }
190
191    #[test]
192    fn test_gradle_workspace_set_changed() {
193        changepacks_core::assert_set_changed_roundtrip!(GradleWorkspace::new(
194            Some("test-workspace".to_string()),
195            Some("1.0.0".to_string()),
196            PathBuf::from("/test/build.gradle.kts"),
197            PathBuf::from("test/build.gradle.kts"),
198        ));
199    }
200
201    #[tokio::test]
202    async fn test_publish_root_project_uses_platform_wrapper_publish_task() {
203        let temp_dir = TempDir::new().unwrap();
204        let root = temp_dir.path().join("repo with spaces");
205        fs::create_dir_all(&root).unwrap();
206        create_publish_wrapper(&root);
207        let manifest = root.join("build.gradle.kts");
208        fs::write(&manifest, "version = \"1.0.0\"\n").unwrap();
209        let workspace = GradleWorkspace::new(
210            Some("root".to_string()),
211            Some("1.0.0".to_string()),
212            manifest,
213            PathBuf::from("build.gradle.kts"),
214        );
215
216        let output = workspace
217            .publish(&changepacks_core::Config::default())
218            .await
219            .unwrap();
220        let dry_run = workspace
221            .dry_run_publish(&changepacks_core::Config::default())
222            .await
223            .unwrap()
224            .unwrap();
225
226        assert!(output.success, "stderr: {}", output.stderr);
227        assert_reported_cwd(&output.stdout, &root);
228        assert_eq!(captured_argv(&output.stdout), ["publish"]);
229        assert!(dry_run.success, "stderr: {}", dry_run.stderr);
230        let dry_run_argv = captured_argv(&dry_run.stdout);
231        assert_eq!(dry_run_argv.len(), 2, "stdout: {}", dry_run.stdout);
232        assert_eq!(dry_run_argv[0], "publishToMavenLocal");
233        assert!(dry_run_argv[1].starts_with("-Dmaven.repo.local="));
234    }
235
236    #[rstest]
237    #[case(UpdateType::Patch, "1.0.1")]
238    #[case(UpdateType::Minor, "1.1.0")]
239    #[case(UpdateType::Major, "2.0.0")]
240    #[tokio::test]
241    async fn test_gradle_workspace_update_version_kts(
242        #[case] update_type: UpdateType,
243        #[case] expected: &str,
244    ) {
245        let temp_dir = TempDir::new().unwrap();
246        let project_dir = temp_dir.path().join("multiproject");
247        fs::create_dir_all(&project_dir).unwrap();
248
249        let build_gradle = project_dir.join("build.gradle.kts");
250        fs::write(
251            &build_gradle,
252            r#"
253plugins {
254    id("java")
255}
256
257group = "com.example"
258version = "1.0.0"
259"#,
260        )
261        .unwrap();
262
263        let mut workspace = GradleWorkspace::new(
264            Some("multiproject".to_string()),
265            Some("1.0.0".to_string()),
266            build_gradle.clone(),
267            PathBuf::from("multiproject/build.gradle.kts"),
268        );
269
270        workspace.update_version(update_type).await.unwrap();
271
272        let content = read_to_string(&build_gradle).await.unwrap();
273        assert!(content.contains(&format!(r#"version = "{expected}""#)));
274        assert_eq!(workspace.version(), Some(expected));
275
276        temp_dir.close().unwrap();
277    }
278
279    #[tokio::test]
280    async fn test_gradle_workspace_update_version_groovy() {
281        let temp_dir = TempDir::new().unwrap();
282        let project_dir = temp_dir.path().join("multiproject");
283        fs::create_dir_all(&project_dir).unwrap();
284
285        let build_gradle = project_dir.join("build.gradle");
286        fs::write(
287            &build_gradle,
288            r"
289plugins {
290    id 'java'
291}
292
293group = 'com.example'
294version = '1.0.0'
295",
296        )
297        .unwrap();
298
299        let mut workspace = GradleWorkspace::new(
300            Some("multiproject".to_string()),
301            Some("1.0.0".to_string()),
302            build_gradle.clone(),
303            PathBuf::from("multiproject/build.gradle"),
304        );
305
306        workspace.update_version(UpdateType::Patch).await.unwrap();
307
308        let content = read_to_string(&build_gradle).await.unwrap();
309        assert!(content.contains("version = '1.0.1'"));
310        assert_eq!(workspace.version(), Some("1.0.1"));
311
312        temp_dir.close().unwrap();
313    }
314
315    #[tokio::test]
316    async fn gradle_workspace_updates_groovy_version_from_sibling_properties() {
317        let temp_dir = TempDir::new().unwrap();
318        let build_gradle = temp_dir.path().join("build.gradle");
319        let properties_path = temp_dir.path().join("gradle.properties");
320        let build_content = b"plugins { id 'java' }\n";
321        let properties_content = b"version : 2.0.0 ! workspace\n";
322        tokio::fs::write(&build_gradle, build_content)
323            .await
324            .unwrap();
325        tokio::fs::write(&properties_path, properties_content)
326            .await
327            .unwrap();
328        let mut workspace = GradleWorkspace::new(
329            Some("multiproject".to_string()),
330            Some("2.0.0".to_string()),
331            build_gradle.clone(),
332            PathBuf::from("build.gradle"),
333        );
334
335        workspace.update_version(UpdateType::Patch).await.unwrap();
336
337        assert_eq!(workspace.version(), Some("2.0.1"));
338        assert_eq!(tokio::fs::read(&build_gradle).await.unwrap(), build_content);
339        assert_eq!(
340            tokio::fs::read(&properties_path).await.unwrap(),
341            b"version : 2.0.1 ! workspace\n"
342        );
343    }
344
345    #[tokio::test]
346    async fn test_gradle_workspace_update_version_without_version() {
347        let temp_dir = TempDir::new().unwrap();
348        let project_dir = temp_dir.path().join("multiproject");
349        fs::create_dir_all(&project_dir).unwrap();
350
351        let build_gradle = project_dir.join("build.gradle.kts");
352        fs::write(
353            &build_gradle,
354            r#"
355plugins {
356    id("java")
357}
358
359group = "com.example"
360version = "0.0.0"
361"#,
362        )
363        .unwrap();
364
365        let mut workspace = GradleWorkspace::new(
366            Some("multiproject".to_string()),
367            None,
368            build_gradle.clone(),
369            PathBuf::from("multiproject/build.gradle.kts"),
370        );
371
372        workspace.update_version(UpdateType::Patch).await.unwrap();
373
374        let content = read_to_string(&build_gradle).await.unwrap();
375        assert!(content.contains(r#"version = "0.0.1""#));
376
377        temp_dir.close().unwrap();
378    }
379
380    #[tokio::test]
381    async fn test_gradle_workspace_update_version_errors_for_unsupported_declaration() {
382        let temp_dir = TempDir::new().unwrap();
383        let build_gradle = temp_dir.path().join("build.gradle.kts");
384        let content = "version = providers.gradleProperty(\"releaseVersion\").get()\n";
385        fs::write(&build_gradle, content).unwrap();
386        let bytes_before = fs::read(&build_gradle).unwrap();
387        let mut workspace = GradleWorkspace::new(
388            Some("multiproject".to_string()),
389            Some("1.0.0".to_string()),
390            build_gradle.clone(),
391            PathBuf::from("build.gradle.kts"),
392        );
393
394        let result = workspace.update_version(UpdateType::Patch).await;
395
396        assert!(result.is_err());
397        assert_eq!(fs::read(&build_gradle).unwrap(), bytes_before);
398        assert_eq!(workspace.version(), Some("1.0.0"));
399    }
400
401    #[tokio::test]
402    async fn test_gradle_workspace_ambiguous_version_keeps_file_and_state_unchanged() {
403        let temp_dir = TempDir::new().unwrap();
404        let build_gradle = temp_dir.path().join("build.gradle");
405        let content = "version = '1.0.0'\nallprojects {\n    version = 'duplicate'\n}\n";
406        fs::write(&build_gradle, content).unwrap();
407        let bytes_before = fs::read(&build_gradle).unwrap();
408        let mut workspace = GradleWorkspace::new(
409            Some("multiproject".to_string()),
410            Some("1.0.0".to_string()),
411            build_gradle.clone(),
412            PathBuf::from("build.gradle"),
413        );
414
415        let result = workspace.update_version(UpdateType::Patch).await;
416
417        assert!(result.is_err());
418        assert_eq!(fs::read(&build_gradle).unwrap(), bytes_before);
419        assert_eq!(workspace.version(), Some("1.0.0"));
420    }
421
422    #[tokio::test]
423    async fn test_gradle_workspace_updates_allprojects_version_with_exact_formatting() {
424        let temp_dir = TempDir::new().unwrap();
425        let build_gradle = temp_dir.path().join("build.gradle");
426        let content = "plugins {\r\n    version = 'plugin-version'\r\n}\r\nallprojects {\r\n\tversion  =  \"1.0.0\" // project-wide\r\n}\r\n";
427        fs::write(&build_gradle, content).unwrap();
428        let mut workspace = GradleWorkspace::new(
429            Some("multiproject".to_string()),
430            Some("1.0.0".to_string()),
431            build_gradle.clone(),
432            PathBuf::from("build.gradle"),
433        );
434
435        workspace.update_version(UpdateType::Patch).await.unwrap();
436
437        assert_eq!(
438            fs::read_to_string(&build_gradle).unwrap(),
439            "plugins {\r\n    version = 'plugin-version'\r\n}\r\nallprojects {\r\n\tversion  =  \"1.0.1\" // project-wide\r\n}\r\n"
440        );
441        assert_eq!(workspace.version(), Some("1.0.1"));
442    }
443
444    #[test]
445    fn test_gradle_workspace_dependencies() {
446        changepacks_core::assert_dependencies_roundtrip!(
447            GradleWorkspace::new(
448                Some("test-workspace".to_string()),
449                Some("1.0.0".to_string()),
450                PathBuf::from("/test/build.gradle.kts"),
451                PathBuf::from("test/build.gradle.kts"),
452            ),
453            "core",
454            "utils"
455        );
456    }
457
458    #[test]
459    fn test_set_name() {
460        changepacks_core::assert_set_name_roundtrip!(GradleWorkspace::new(
461            None,
462            Some("1.0.0".to_string()),
463            PathBuf::from("/test/build.gradle.kts"),
464            PathBuf::from("build.gradle.kts"),
465        ));
466    }
467}