changepacks_java/
workspace.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use changepacks_core::{Language, UpdateType, Workspace};
4use changepacks_utils::next_version;
5use regex::Regex;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use tokio::fs::{read_to_string, write};
9
10#[derive(Debug)]
11pub struct GradleWorkspace {
12    path: PathBuf,
13    relative_path: PathBuf,
14    version: Option<String>,
15    name: Option<String>,
16    is_changed: bool,
17    dependencies: HashSet<String>,
18}
19
20impl GradleWorkspace {
21    pub fn new(
22        name: Option<String>,
23        version: Option<String>,
24        path: PathBuf,
25        relative_path: PathBuf,
26    ) -> Self {
27        Self {
28            path,
29            relative_path,
30            name,
31            version,
32            is_changed: false,
33            dependencies: HashSet::new(),
34        }
35    }
36}
37
38/// Update version in build.gradle.kts content
39fn update_version_kts(content: &str, new_version: &str) -> String {
40    // Pattern 1: version = "1.0.0"
41    let simple_pattern = Regex::new(r#"(?m)^(version\s*=\s*)"[^"]+""#).unwrap();
42    if simple_pattern.is_match(content) {
43        return simple_pattern
44            .replace(content, format!(r#"${{1}}"{}""#, new_version))
45            .to_string();
46    }
47
48    // Pattern 2: version = project.findProperty("...") ?: "1.0.0"
49    let fallback_pattern =
50        Regex::new(r#"(?m)^(version\s*=\s*project\.findProperty\([^)]+\)\s*\?:\s*)"[^"]+""#)
51            .unwrap();
52    if fallback_pattern.is_match(content) {
53        return fallback_pattern
54            .replace(content, format!(r#"${{1}}"{}""#, new_version))
55            .to_string();
56    }
57
58    content.to_string()
59}
60
61/// Update version in build.gradle (Groovy) content
62fn update_version_groovy(content: &str, new_version: &str) -> String {
63    // Pattern 1: version = '1.0.0' or version = "1.0.0"
64    let assign_pattern = Regex::new(r#"(?m)^(version\s*=\s*)['"][^'"]+['"]"#).unwrap();
65    if assign_pattern.is_match(content) {
66        return assign_pattern
67            .replace(content, format!(r#"${{1}}'{}'"#, new_version))
68            .to_string();
69    }
70
71    // Pattern 2: version '1.0.0' or version "1.0.0"
72    let space_pattern = Regex::new(r#"(?m)^(version\s+)['"][^'"]+['"]"#).unwrap();
73    if space_pattern.is_match(content) {
74        return space_pattern
75            .replace(content, format!(r#"${{1}}'{}'"#, new_version))
76            .to_string();
77    }
78
79    content.to_string()
80}
81
82#[async_trait]
83impl Workspace for GradleWorkspace {
84    fn name(&self) -> Option<&str> {
85        self.name.as_deref()
86    }
87
88    fn path(&self) -> &Path {
89        &self.path
90    }
91
92    fn version(&self) -> Option<&str> {
93        self.version.as_deref()
94    }
95
96    async fn update_version(&mut self, update_type: UpdateType) -> Result<()> {
97        let current_version = self.version.as_deref().unwrap_or("0.0.0");
98        let new_version = next_version(current_version, update_type)?;
99
100        let content = read_to_string(&self.path).await?;
101        let file_name = self
102            .path
103            .file_name()
104            .and_then(|f| f.to_str())
105            .unwrap_or_default();
106        let is_kts = file_name.ends_with(".kts");
107
108        let updated_content = if is_kts {
109            update_version_kts(&content, &new_version)
110        } else {
111            update_version_groovy(&content, &new_version)
112        };
113
114        write(&self.path, updated_content).await?;
115        self.version = Some(new_version);
116        Ok(())
117    }
118
119    fn language(&self) -> Language {
120        Language::Java
121    }
122
123    fn is_changed(&self) -> bool {
124        self.is_changed
125    }
126
127    fn set_changed(&mut self, changed: bool) {
128        self.is_changed = changed;
129    }
130
131    fn relative_path(&self) -> &Path {
132        &self.relative_path
133    }
134
135    fn default_publish_command(&self) -> String {
136        "./gradlew publish".to_string()
137    }
138
139    fn dependencies(&self) -> &HashSet<String> {
140        &self.dependencies
141    }
142
143    fn add_dependency(&mut self, dependency: &str) {
144        self.dependencies.insert(dependency.to_string());
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use changepacks_core::UpdateType;
152    use std::fs;
153    use tempfile::TempDir;
154    use tokio::fs::read_to_string;
155
156    #[tokio::test]
157    async fn test_gradle_workspace_new() {
158        let workspace = GradleWorkspace::new(
159            Some("test-workspace".to_string()),
160            Some("1.0.0".to_string()),
161            PathBuf::from("/test/build.gradle.kts"),
162            PathBuf::from("test/build.gradle.kts"),
163        );
164
165        assert_eq!(workspace.name(), Some("test-workspace"));
166        assert_eq!(workspace.version(), Some("1.0.0"));
167        assert_eq!(workspace.path(), PathBuf::from("/test/build.gradle.kts"));
168        assert_eq!(
169            workspace.relative_path(),
170            PathBuf::from("test/build.gradle.kts")
171        );
172        assert_eq!(workspace.language(), Language::Java);
173        assert!(!workspace.is_changed());
174        assert_eq!(workspace.default_publish_command(), "./gradlew publish");
175    }
176
177    #[tokio::test]
178    async fn test_gradle_workspace_new_without_name_and_version() {
179        let workspace = GradleWorkspace::new(
180            None,
181            None,
182            PathBuf::from("/test/build.gradle.kts"),
183            PathBuf::from("test/build.gradle.kts"),
184        );
185
186        assert_eq!(workspace.name(), None);
187        assert_eq!(workspace.version(), None);
188    }
189
190    #[tokio::test]
191    async fn test_gradle_workspace_set_changed() {
192        let mut workspace = GradleWorkspace::new(
193            Some("test-workspace".to_string()),
194            Some("1.0.0".to_string()),
195            PathBuf::from("/test/build.gradle.kts"),
196            PathBuf::from("test/build.gradle.kts"),
197        );
198
199        assert!(!workspace.is_changed());
200        workspace.set_changed(true);
201        assert!(workspace.is_changed());
202        workspace.set_changed(false);
203        assert!(!workspace.is_changed());
204    }
205
206    #[tokio::test]
207    async fn test_gradle_workspace_update_version_kts_patch() {
208        let temp_dir = TempDir::new().unwrap();
209        let project_dir = temp_dir.path().join("multiproject");
210        fs::create_dir_all(&project_dir).unwrap();
211
212        let build_gradle = project_dir.join("build.gradle.kts");
213        fs::write(
214            &build_gradle,
215            r#"
216plugins {
217    id("java")
218}
219
220group = "com.example"
221version = "1.0.0"
222"#,
223        )
224        .unwrap();
225
226        let mut workspace = GradleWorkspace::new(
227            Some("multiproject".to_string()),
228            Some("1.0.0".to_string()),
229            build_gradle.clone(),
230            PathBuf::from("multiproject/build.gradle.kts"),
231        );
232
233        workspace.update_version(UpdateType::Patch).await.unwrap();
234
235        let content = read_to_string(&build_gradle).await.unwrap();
236        assert!(content.contains(r#"version = "1.0.1""#));
237
238        temp_dir.close().unwrap();
239    }
240
241    #[tokio::test]
242    async fn test_gradle_workspace_update_version_kts_minor() {
243        let temp_dir = TempDir::new().unwrap();
244        let project_dir = temp_dir.path().join("multiproject");
245        fs::create_dir_all(&project_dir).unwrap();
246
247        let build_gradle = project_dir.join("build.gradle.kts");
248        fs::write(
249            &build_gradle,
250            r#"
251plugins {
252    id("java")
253}
254
255group = "com.example"
256version = "1.0.0"
257"#,
258        )
259        .unwrap();
260
261        let mut workspace = GradleWorkspace::new(
262            Some("multiproject".to_string()),
263            Some("1.0.0".to_string()),
264            build_gradle.clone(),
265            PathBuf::from("multiproject/build.gradle.kts"),
266        );
267
268        workspace.update_version(UpdateType::Minor).await.unwrap();
269
270        let content = read_to_string(&build_gradle).await.unwrap();
271        assert!(content.contains(r#"version = "1.1.0""#));
272
273        temp_dir.close().unwrap();
274    }
275
276    #[tokio::test]
277    async fn test_gradle_workspace_update_version_kts_major() {
278        let temp_dir = TempDir::new().unwrap();
279        let project_dir = temp_dir.path().join("multiproject");
280        fs::create_dir_all(&project_dir).unwrap();
281
282        let build_gradle = project_dir.join("build.gradle.kts");
283        fs::write(
284            &build_gradle,
285            r#"
286plugins {
287    id("java")
288}
289
290group = "com.example"
291version = "1.0.0"
292"#,
293        )
294        .unwrap();
295
296        let mut workspace = GradleWorkspace::new(
297            Some("multiproject".to_string()),
298            Some("1.0.0".to_string()),
299            build_gradle.clone(),
300            PathBuf::from("multiproject/build.gradle.kts"),
301        );
302
303        workspace.update_version(UpdateType::Major).await.unwrap();
304
305        let content = read_to_string(&build_gradle).await.unwrap();
306        assert!(content.contains(r#"version = "2.0.0""#));
307
308        temp_dir.close().unwrap();
309    }
310
311    #[tokio::test]
312    async fn test_gradle_workspace_update_version_groovy() {
313        let temp_dir = TempDir::new().unwrap();
314        let project_dir = temp_dir.path().join("multiproject");
315        fs::create_dir_all(&project_dir).unwrap();
316
317        let build_gradle = project_dir.join("build.gradle");
318        fs::write(
319            &build_gradle,
320            r#"
321plugins {
322    id 'java'
323}
324
325group = 'com.example'
326version = '1.0.0'
327"#,
328        )
329        .unwrap();
330
331        let mut workspace = GradleWorkspace::new(
332            Some("multiproject".to_string()),
333            Some("1.0.0".to_string()),
334            build_gradle.clone(),
335            PathBuf::from("multiproject/build.gradle"),
336        );
337
338        workspace.update_version(UpdateType::Patch).await.unwrap();
339
340        let content = read_to_string(&build_gradle).await.unwrap();
341        assert!(content.contains("version = '1.0.1'"));
342
343        temp_dir.close().unwrap();
344    }
345
346    #[tokio::test]
347    async fn test_gradle_workspace_update_version_without_version() {
348        let temp_dir = TempDir::new().unwrap();
349        let project_dir = temp_dir.path().join("multiproject");
350        fs::create_dir_all(&project_dir).unwrap();
351
352        let build_gradle = project_dir.join("build.gradle.kts");
353        fs::write(
354            &build_gradle,
355            r#"
356plugins {
357    id("java")
358}
359
360group = "com.example"
361version = "0.0.0"
362"#,
363        )
364        .unwrap();
365
366        let mut workspace = GradleWorkspace::new(
367            Some("multiproject".to_string()),
368            None,
369            build_gradle.clone(),
370            PathBuf::from("multiproject/build.gradle.kts"),
371        );
372
373        workspace.update_version(UpdateType::Patch).await.unwrap();
374
375        let content = read_to_string(&build_gradle).await.unwrap();
376        assert!(content.contains(r#"version = "0.0.1""#));
377
378        temp_dir.close().unwrap();
379    }
380
381    #[test]
382    fn test_gradle_workspace_dependencies() {
383        let mut workspace = GradleWorkspace::new(
384            Some("test-workspace".to_string()),
385            Some("1.0.0".to_string()),
386            PathBuf::from("/test/build.gradle.kts"),
387            PathBuf::from("test/build.gradle.kts"),
388        );
389
390        // Initially empty
391        assert!(workspace.dependencies().is_empty());
392
393        // Add dependencies
394        workspace.add_dependency("core");
395        workspace.add_dependency("utils");
396
397        let deps = workspace.dependencies();
398        assert_eq!(deps.len(), 2);
399        assert!(deps.contains("core"));
400        assert!(deps.contains("utils"));
401
402        // Adding duplicate should not increase count
403        workspace.add_dependency("core");
404        assert_eq!(workspace.dependencies().len(), 2);
405    }
406}