changepacks_java/
finder.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use changepacks_core::{Project, ProjectFinder};
4use regex::Regex;
5use std::{
6    collections::HashMap,
7    path::{Path, PathBuf},
8    process::Stdio,
9};
10use tokio::process::Command;
11
12use crate::{package::GradlePackage, workspace::GradleWorkspace};
13
14#[derive(Debug)]
15pub struct GradleProjectFinder {
16    projects: HashMap<PathBuf, Project>,
17    project_files: Vec<&'static str>,
18}
19
20impl Default for GradleProjectFinder {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl GradleProjectFinder {
27    pub fn new() -> Self {
28        Self {
29            projects: HashMap::new(),
30            project_files: vec!["build.gradle.kts", "build.gradle"],
31        }
32    }
33}
34
35/// Project info obtained from gradlew properties
36#[derive(Debug, Default)]
37struct GradleProperties {
38    name: Option<String>,
39    version: Option<String>,
40}
41
42/// Get project properties using gradlew command
43async fn get_gradle_properties(project_dir: &Path) -> Option<GradleProperties> {
44    // Determine gradlew command based on OS
45    let gradlew = if cfg!(windows) {
46        project_dir.join("gradlew.bat")
47    } else {
48        project_dir.join("gradlew")
49    };
50
51    // Check if gradlew exists
52    if !gradlew.exists() {
53        return None;
54    }
55
56    // Run gradlew properties -q
57    let output = Command::new(&gradlew)
58        .args(["properties", "-q"])
59        .current_dir(project_dir)
60        .stdout(Stdio::piped())
61        .stderr(Stdio::null())
62        .output()
63        .await
64        .ok()?;
65
66    if !output.status.success() {
67        return None;
68    }
69
70    let stdout = String::from_utf8_lossy(&output.stdout);
71    let mut props = GradleProperties::default();
72
73    // Parse properties output
74    // Format: "propertyName: value"
75    let name_pattern = Regex::new(r"(?m)^name:\s*(.+)$").ok()?;
76    let version_pattern = Regex::new(r"(?m)^version:\s*(.+)$").ok()?;
77
78    if let Some(caps) = name_pattern.captures(&stdout) {
79        let name = caps.get(1).map(|m| m.as_str().trim().to_string());
80        if name.as_deref() != Some("unspecified") {
81            props.name = name;
82        }
83    }
84
85    if let Some(caps) = version_pattern.captures(&stdout) {
86        let version = caps.get(1).map(|m| m.as_str().trim().to_string());
87        if version.as_deref() != Some("unspecified") {
88            props.version = version;
89        }
90    }
91
92    Some(props)
93}
94
95#[async_trait]
96impl ProjectFinder for GradleProjectFinder {
97    fn projects(&self) -> Vec<&Project> {
98        self.projects.values().collect::<Vec<_>>()
99    }
100
101    fn projects_mut(&mut self) -> Vec<&mut Project> {
102        self.projects.values_mut().collect::<Vec<_>>()
103    }
104
105    fn project_files(&self) -> &[&str] {
106        &self.project_files
107    }
108
109    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
110        if path.is_file()
111            && self.project_files().contains(
112                &path
113                    .file_name()
114                    .context(format!("File name not found - {}", path.display()))?
115                    .to_str()
116                    .context(format!("File name not found - {}", path.display()))?,
117            )
118        {
119            if self.projects.contains_key(path) {
120                return Ok(());
121            }
122
123            let project_dir = path
124                .parent()
125                .context(format!("Parent not found - {}", path.display()))?;
126
127            // Get properties from gradlew command
128            let props = get_gradle_properties(project_dir).await.unwrap_or_default();
129
130            // Use directory name as fallback for project name
131            let name = props.name.or_else(|| {
132                project_dir
133                    .file_name()
134                    .and_then(|n| n.to_str())
135                    .map(|s| s.to_string())
136            });
137
138            let version = props.version;
139
140            // Check if this is a multi-project build (has settings.gradle or settings.gradle.kts)
141            let is_workspace = project_dir.join("settings.gradle.kts").is_file()
142                || project_dir.join("settings.gradle").is_file();
143
144            let (path, project) = if is_workspace {
145                (
146                    path.to_path_buf(),
147                    Project::Workspace(Box::new(GradleWorkspace::new(
148                        name,
149                        version,
150                        path.to_path_buf(),
151                        relative_path.to_path_buf(),
152                    ))),
153                )
154            } else {
155                (
156                    path.to_path_buf(),
157                    Project::Package(Box::new(GradlePackage::new(
158                        name,
159                        version,
160                        path.to_path_buf(),
161                        relative_path.to_path_buf(),
162                    ))),
163                )
164            };
165
166            self.projects.insert(path, project);
167        }
168        Ok(())
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use changepacks_core::Project;
176    use std::fs;
177    use tempfile::TempDir;
178
179    #[test]
180    fn test_gradle_project_finder_new() {
181        let finder = GradleProjectFinder::new();
182        assert_eq!(
183            finder.project_files(),
184            &["build.gradle.kts", "build.gradle"]
185        );
186        assert_eq!(finder.projects().len(), 0);
187    }
188
189    #[test]
190    fn test_gradle_project_finder_default() {
191        let finder = GradleProjectFinder::default();
192        assert_eq!(
193            finder.project_files(),
194            &["build.gradle.kts", "build.gradle"]
195        );
196        assert_eq!(finder.projects().len(), 0);
197    }
198
199    #[tokio::test]
200    async fn test_gradle_project_finder_visit_kts_package() {
201        let temp_dir = TempDir::new().unwrap();
202        let project_dir = temp_dir.path().join("myproject");
203        fs::create_dir_all(&project_dir).unwrap();
204
205        let build_gradle = project_dir.join("build.gradle.kts");
206        fs::write(
207            &build_gradle,
208            r#"
209plugins {
210    id("java")
211}
212
213group = "com.example"
214version = "1.0.0"
215"#,
216        )
217        .unwrap();
218
219        let mut finder = GradleProjectFinder::new();
220        finder
221            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
222            .await
223            .unwrap();
224
225        let projects = finder.projects();
226        assert_eq!(projects.len(), 1);
227        match projects[0] {
228            Project::Package(pkg) => {
229                // Without gradlew, falls back to directory name
230                assert_eq!(pkg.name(), Some("myproject"));
231                // Version is None without gradlew
232                assert_eq!(pkg.version(), None);
233            }
234            _ => panic!("Expected Package"),
235        }
236
237        temp_dir.close().unwrap();
238    }
239
240    #[tokio::test]
241    async fn test_gradle_project_finder_visit_groovy_package() {
242        let temp_dir = TempDir::new().unwrap();
243        let project_dir = temp_dir.path().join("groovyproject");
244        fs::create_dir_all(&project_dir).unwrap();
245
246        let build_gradle = project_dir.join("build.gradle");
247        fs::write(
248            &build_gradle,
249            r#"
250plugins {
251    id 'java'
252}
253
254group = 'com.example'
255version = '2.0.0'
256"#,
257        )
258        .unwrap();
259
260        let mut finder = GradleProjectFinder::new();
261        finder
262            .visit(&build_gradle, &PathBuf::from("groovyproject/build.gradle"))
263            .await
264            .unwrap();
265
266        let projects = finder.projects();
267        assert_eq!(projects.len(), 1);
268        match projects[0] {
269            Project::Package(pkg) => {
270                // Without gradlew, falls back to directory name
271                assert_eq!(pkg.name(), Some("groovyproject"));
272            }
273            _ => panic!("Expected Package"),
274        }
275
276        temp_dir.close().unwrap();
277    }
278
279    #[tokio::test]
280    async fn test_gradle_project_finder_visit_workspace() {
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.kts");
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        // Create settings.gradle.kts to mark as workspace
300        let settings_gradle = project_dir.join("settings.gradle.kts");
301        fs::write(
302            &settings_gradle,
303            r#"
304rootProject.name = "multiproject"
305include("subproject1", "subproject2")
306"#,
307        )
308        .unwrap();
309
310        let mut finder = GradleProjectFinder::new();
311        finder
312            .visit(
313                &build_gradle,
314                &PathBuf::from("multiproject/build.gradle.kts"),
315            )
316            .await
317            .unwrap();
318
319        let projects = finder.projects();
320        assert_eq!(projects.len(), 1);
321        match projects[0] {
322            Project::Workspace(ws) => {
323                // Without gradlew, falls back to directory name
324                assert_eq!(ws.name(), Some("multiproject"));
325            }
326            _ => panic!("Expected Workspace"),
327        }
328
329        temp_dir.close().unwrap();
330    }
331
332    #[tokio::test]
333    async fn test_gradle_project_finder_visit_non_gradle_file() {
334        let temp_dir = TempDir::new().unwrap();
335        let other_file = temp_dir.path().join("other.txt");
336        fs::write(&other_file, "some content").unwrap();
337
338        let mut finder = GradleProjectFinder::new();
339        finder
340            .visit(&other_file, &PathBuf::from("other.txt"))
341            .await
342            .unwrap();
343
344        assert_eq!(finder.projects().len(), 0);
345
346        temp_dir.close().unwrap();
347    }
348
349    #[tokio::test]
350    async fn test_gradle_project_finder_visit_duplicate() {
351        let temp_dir = TempDir::new().unwrap();
352        let project_dir = temp_dir.path().join("myproject");
353        fs::create_dir_all(&project_dir).unwrap();
354
355        let build_gradle = project_dir.join("build.gradle.kts");
356        fs::write(
357            &build_gradle,
358            r#"
359group = "com.example"
360version = "1.0.0"
361"#,
362        )
363        .unwrap();
364
365        let mut finder = GradleProjectFinder::new();
366        finder
367            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
368            .await
369            .unwrap();
370
371        assert_eq!(finder.projects().len(), 1);
372
373        // Visit again - should not add duplicate
374        finder
375            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
376            .await
377            .unwrap();
378
379        assert_eq!(finder.projects().len(), 1);
380
381        temp_dir.close().unwrap();
382    }
383
384    #[tokio::test]
385    async fn test_gradle_project_finder_projects_mut() {
386        let temp_dir = TempDir::new().unwrap();
387        let project_dir = temp_dir.path().join("myproject");
388        fs::create_dir_all(&project_dir).unwrap();
389
390        let build_gradle = project_dir.join("build.gradle.kts");
391        fs::write(
392            &build_gradle,
393            r#"
394group = "com.example"
395version = "1.0.0"
396"#,
397        )
398        .unwrap();
399
400        let mut finder = GradleProjectFinder::new();
401        finder
402            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
403            .await
404            .unwrap();
405
406        let mut_projects = finder.projects_mut();
407        assert_eq!(mut_projects.len(), 1);
408
409        temp_dir.close().unwrap();
410    }
411}