1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::Config;
4use changepacks_core::{Language, Package, UpdateType};
5
6crate::declare_gradle_project!(pub struct GradlePackage);
9
10#[async_trait]
11impl Package for GradlePackage {
12 changepacks_core::impl_basic_accessors!();
14
15 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 changepacks_core::impl_language!(Language::Java);
30
31 changepacks_core::impl_const_publish_commands!(
39 crate::PUBLISH_COMMAND,
40 crate::DRY_RUN_PUBLISH_COMMAND
41 );
42
43 crate::impl_gradle_publish_task_flags!();
47
48 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 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}