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#[derive(Debug, Default)]
38struct GradleProperties {
39 name: Option<String>,
40 version: Option<String>,
41}
42
43async fn get_gradle_properties(project_dir: &Path) -> Option<GradleProperties> {
45 let gradlew = if cfg!(windows) {
47 project_dir.join("gradlew.bat")
48 } else {
49 project_dir.join("gradlew")
50 };
51
52 if !gradlew.exists() {
54 return None;
55 }
56
57 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 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 let props = get_gradle_properties(project_dir).await.unwrap_or_default();
130
131 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 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 assert_eq!(pkg.name(), Some("myproject"));
232 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 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 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 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 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 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 #[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}