Skip to main content

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    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            projects: HashMap::new(),
31            project_files: vec!["build.gradle.kts", "build.gradle"],
32        }
33    }
34}
35
36/// Project info obtained from gradlew properties
37#[derive(Debug, Default)]
38struct GradleProperties {
39    name: Option<String>,
40    version: Option<String>,
41}
42
43/// Get project properties using gradlew command
44async fn get_gradle_properties(project_dir: &Path) -> Option<GradleProperties> {
45    // Determine gradlew command based on OS
46    let gradlew = if cfg!(windows) {
47        project_dir.join("gradlew.bat")
48    } else {
49        project_dir.join("gradlew")
50    };
51
52    // Check if gradlew exists
53    if !gradlew.exists() {
54        return None;
55    }
56
57    // Run gradlew properties -q
58    let output = Command::new(&gradlew)
59        .args(["properties", "-q"])
60        .current_dir(project_dir)
61        .stdout(Stdio::piped())
62        .stderr(Stdio::null())
63        .output()
64        .await
65        .ok()?;
66
67    if !output.status.success() {
68        return None;
69    }
70
71    let stdout = String::from_utf8_lossy(&output.stdout);
72    let mut props = GradleProperties::default();
73
74    // Parse properties output
75    // Format: "propertyName: value"
76    let name_pattern = Regex::new(r"(?m)^name:\s*(.+)$").ok()?;
77    let version_pattern = Regex::new(r"(?m)^version:\s*(.+)$").ok()?;
78
79    if let Some(caps) = name_pattern.captures(&stdout) {
80        let name = caps.get(1).map(|m| m.as_str().trim().to_string());
81        if name.as_deref() != Some("unspecified") {
82            props.name = name;
83        }
84    }
85
86    if let Some(caps) = version_pattern.captures(&stdout) {
87        let version = caps.get(1).map(|m| m.as_str().trim().to_string());
88        if version.as_deref() != Some("unspecified") {
89            props.version = version;
90        }
91    }
92
93    Some(props)
94}
95
96#[async_trait]
97impl ProjectFinder for GradleProjectFinder {
98    fn projects(&self) -> Vec<&Project> {
99        self.projects.values().collect::<Vec<_>>()
100    }
101
102    fn projects_mut(&mut self) -> Vec<&mut Project> {
103        self.projects.values_mut().collect::<Vec<_>>()
104    }
105
106    fn project_files(&self) -> &[&str] {
107        &self.project_files
108    }
109
110    async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()> {
111        if path.is_file()
112            && self.project_files().contains(
113                &path
114                    .file_name()
115                    .context(format!("File name not found - {}", path.display()))?
116                    .to_str()
117                    .context(format!("File name not found - {}", path.display()))?,
118            )
119        {
120            if self.projects.contains_key(path) {
121                return Ok(());
122            }
123
124            let project_dir = path
125                .parent()
126                .context(format!("Parent not found - {}", path.display()))?;
127
128            // Get properties from gradlew command
129            let props = get_gradle_properties(project_dir).await.unwrap_or_default();
130
131            // Use directory name as fallback for project name
132            let name = props.name.or_else(|| {
133                project_dir
134                    .file_name()
135                    .and_then(|n| n.to_str())
136                    .map(std::string::ToString::to_string)
137            });
138
139            let version = props.version;
140
141            // Check if this is a multi-project build (has settings.gradle or settings.gradle.kts)
142            let is_workspace = project_dir.join("settings.gradle.kts").is_file()
143                || project_dir.join("settings.gradle").is_file();
144
145            let (path, project) = if is_workspace {
146                (
147                    path.to_path_buf(),
148                    Project::Workspace(Box::new(GradleWorkspace::new(
149                        name,
150                        version,
151                        path.to_path_buf(),
152                        relative_path.to_path_buf(),
153                    ))),
154                )
155            } else {
156                (
157                    path.to_path_buf(),
158                    Project::Package(Box::new(GradlePackage::new(
159                        name,
160                        version,
161                        path.to_path_buf(),
162                        relative_path.to_path_buf(),
163                    ))),
164                )
165            };
166
167            self.projects.insert(path, project);
168        }
169        Ok(())
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use changepacks_core::Project;
177    use std::fs;
178    use tempfile::TempDir;
179
180    #[test]
181    fn test_gradle_project_finder_new() {
182        let finder = GradleProjectFinder::new();
183        assert_eq!(
184            finder.project_files(),
185            &["build.gradle.kts", "build.gradle"]
186        );
187        assert_eq!(finder.projects().len(), 0);
188    }
189
190    #[test]
191    fn test_gradle_project_finder_default() {
192        let finder = GradleProjectFinder::default();
193        assert_eq!(
194            finder.project_files(),
195            &["build.gradle.kts", "build.gradle"]
196        );
197        assert_eq!(finder.projects().len(), 0);
198    }
199
200    #[tokio::test]
201    async fn test_gradle_project_finder_visit_kts_package() {
202        let temp_dir = TempDir::new().unwrap();
203        let project_dir = temp_dir.path().join("myproject");
204        fs::create_dir_all(&project_dir).unwrap();
205
206        let build_gradle = project_dir.join("build.gradle.kts");
207        fs::write(
208            &build_gradle,
209            r#"
210plugins {
211    id("java")
212}
213
214group = "com.example"
215version = "1.0.0"
216"#,
217        )
218        .unwrap();
219
220        let mut finder = GradleProjectFinder::new();
221        finder
222            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
223            .await
224            .unwrap();
225
226        let projects = finder.projects();
227        assert_eq!(projects.len(), 1);
228        match projects[0] {
229            Project::Package(pkg) => {
230                // Without gradlew, falls back to directory name
231                assert_eq!(pkg.name(), Some("myproject"));
232                // Version is None without gradlew
233                assert_eq!(pkg.version(), None);
234            }
235            _ => panic!("Expected Package"),
236        }
237
238        temp_dir.close().unwrap();
239    }
240
241    #[tokio::test]
242    async fn test_gradle_project_finder_visit_groovy_package() {
243        let temp_dir = TempDir::new().unwrap();
244        let project_dir = temp_dir.path().join("groovyproject");
245        fs::create_dir_all(&project_dir).unwrap();
246
247        let build_gradle = project_dir.join("build.gradle");
248        fs::write(
249            &build_gradle,
250            r#"
251plugins {
252    id 'java'
253}
254
255group = 'com.example'
256version = '2.0.0'
257"#,
258        )
259        .unwrap();
260
261        let mut finder = GradleProjectFinder::new();
262        finder
263            .visit(&build_gradle, &PathBuf::from("groovyproject/build.gradle"))
264            .await
265            .unwrap();
266
267        let projects = finder.projects();
268        assert_eq!(projects.len(), 1);
269        match projects[0] {
270            Project::Package(pkg) => {
271                // Without gradlew, falls back to directory name
272                assert_eq!(pkg.name(), Some("groovyproject"));
273            }
274            _ => panic!("Expected Package"),
275        }
276
277        temp_dir.close().unwrap();
278    }
279
280    #[tokio::test]
281    async fn test_gradle_project_finder_visit_workspace() {
282        let temp_dir = TempDir::new().unwrap();
283        let project_dir = temp_dir.path().join("multiproject");
284        fs::create_dir_all(&project_dir).unwrap();
285
286        let build_gradle = project_dir.join("build.gradle.kts");
287        fs::write(
288            &build_gradle,
289            r#"
290plugins {
291    id("java")
292}
293
294group = "com.example"
295version = "1.0.0"
296"#,
297        )
298        .unwrap();
299
300        // Create settings.gradle.kts to mark as workspace
301        let settings_gradle = project_dir.join("settings.gradle.kts");
302        fs::write(
303            &settings_gradle,
304            r#"
305rootProject.name = "multiproject"
306include("subproject1", "subproject2")
307"#,
308        )
309        .unwrap();
310
311        let mut finder = GradleProjectFinder::new();
312        finder
313            .visit(
314                &build_gradle,
315                &PathBuf::from("multiproject/build.gradle.kts"),
316            )
317            .await
318            .unwrap();
319
320        let projects = finder.projects();
321        assert_eq!(projects.len(), 1);
322        match projects[0] {
323            Project::Workspace(ws) => {
324                // Without gradlew, falls back to directory name
325                assert_eq!(ws.name(), Some("multiproject"));
326            }
327            _ => panic!("Expected Workspace"),
328        }
329
330        temp_dir.close().unwrap();
331    }
332
333    #[tokio::test]
334    async fn test_gradle_project_finder_visit_non_gradle_file() {
335        let temp_dir = TempDir::new().unwrap();
336        let other_file = temp_dir.path().join("other.txt");
337        fs::write(&other_file, "some content").unwrap();
338
339        let mut finder = GradleProjectFinder::new();
340        finder
341            .visit(&other_file, &PathBuf::from("other.txt"))
342            .await
343            .unwrap();
344
345        assert_eq!(finder.projects().len(), 0);
346
347        temp_dir.close().unwrap();
348    }
349
350    #[tokio::test]
351    async fn test_gradle_project_finder_visit_duplicate() {
352        let temp_dir = TempDir::new().unwrap();
353        let project_dir = temp_dir.path().join("myproject");
354        fs::create_dir_all(&project_dir).unwrap();
355
356        let build_gradle = project_dir.join("build.gradle.kts");
357        fs::write(
358            &build_gradle,
359            r#"
360group = "com.example"
361version = "1.0.0"
362"#,
363        )
364        .unwrap();
365
366        let mut finder = GradleProjectFinder::new();
367        finder
368            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
369            .await
370            .unwrap();
371
372        assert_eq!(finder.projects().len(), 1);
373
374        // Visit again - should not add duplicate
375        finder
376            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
377            .await
378            .unwrap();
379
380        assert_eq!(finder.projects().len(), 1);
381
382        temp_dir.close().unwrap();
383    }
384
385    #[tokio::test]
386    async fn test_gradle_project_finder_projects_mut() {
387        let temp_dir = TempDir::new().unwrap();
388        let project_dir = temp_dir.path().join("myproject");
389        fs::create_dir_all(&project_dir).unwrap();
390
391        let build_gradle = project_dir.join("build.gradle.kts");
392        fs::write(
393            &build_gradle,
394            r#"
395group = "com.example"
396version = "1.0.0"
397"#,
398        )
399        .unwrap();
400
401        let mut finder = GradleProjectFinder::new();
402        finder
403            .visit(&build_gradle, &PathBuf::from("myproject/build.gradle.kts"))
404            .await
405            .unwrap();
406
407        let mut_projects = finder.projects_mut();
408        assert_eq!(mut_projects.len(), 1);
409
410        temp_dir.close().unwrap();
411    }
412
413    #[tokio::test]
414    async fn test_get_gradle_properties_no_gradlew() {
415        let temp_dir = TempDir::new().unwrap();
416        let result = get_gradle_properties(temp_dir.path()).await;
417        assert!(result.is_none());
418        temp_dir.close().unwrap();
419    }
420
421    #[tokio::test]
422    async fn test_get_gradle_properties_with_mock() {
423        let temp_dir = TempDir::new().unwrap();
424
425        // Create mock gradlew that outputs properties
426        if cfg!(windows) {
427            let gradlew_path = temp_dir.path().join("gradlew.bat");
428            fs::write(
429                &gradlew_path,
430                "@echo off\necho name: myproject\necho version: 1.2.3\n",
431            )
432            .unwrap();
433        } else {
434            let gradlew_path = temp_dir.path().join("gradlew");
435            fs::write(
436                &gradlew_path,
437                "#!/bin/sh\necho 'name: myproject'\necho 'version: 1.2.3'\n",
438            )
439            .unwrap();
440            // Make executable on Unix
441            #[cfg(unix)]
442            {
443                use std::os::unix::fs::PermissionsExt;
444                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
445            }
446        }
447
448        let result = get_gradle_properties(temp_dir.path()).await;
449        assert!(result.is_some());
450        let props = result.unwrap();
451        assert_eq!(props.name, Some("myproject".to_string()));
452        assert_eq!(props.version, Some("1.2.3".to_string()));
453
454        temp_dir.close().unwrap();
455    }
456
457    #[tokio::test]
458    async fn test_get_gradle_properties_unspecified() {
459        let temp_dir = TempDir::new().unwrap();
460
461        if cfg!(windows) {
462            let gradlew_path = temp_dir.path().join("gradlew.bat");
463            fs::write(
464                &gradlew_path,
465                "@echo off\necho name: unspecified\necho version: unspecified\n",
466            )
467            .unwrap();
468        } else {
469            let gradlew_path = temp_dir.path().join("gradlew");
470            fs::write(
471                &gradlew_path,
472                "#!/bin/sh\necho 'name: unspecified'\necho 'version: unspecified'\n",
473            )
474            .unwrap();
475            #[cfg(unix)]
476            {
477                use std::os::unix::fs::PermissionsExt;
478                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
479            }
480        }
481
482        let result = get_gradle_properties(temp_dir.path()).await;
483        assert!(result.is_some());
484        let props = result.unwrap();
485        assert!(props.name.is_none());
486        assert!(props.version.is_none());
487
488        temp_dir.close().unwrap();
489    }
490
491    #[tokio::test]
492    async fn test_get_gradle_properties_gradlew_fails() {
493        let temp_dir = TempDir::new().unwrap();
494
495        if cfg!(windows) {
496            let gradlew_path = temp_dir.path().join("gradlew.bat");
497            fs::write(&gradlew_path, "@echo off\nexit /b 1\n").unwrap();
498        } else {
499            let gradlew_path = temp_dir.path().join("gradlew");
500            fs::write(&gradlew_path, "#!/bin/sh\nexit 1\n").unwrap();
501            #[cfg(unix)]
502            {
503                use std::os::unix::fs::PermissionsExt;
504                fs::set_permissions(&gradlew_path, fs::Permissions::from_mode(0o755)).unwrap();
505            }
506        }
507
508        let result = get_gradle_properties(temp_dir.path()).await;
509        assert!(result.is_none());
510
511        temp_dir.close().unwrap();
512    }
513}