1use std::collections::HashMap;
21use std::io::BufRead;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24use std::time::Duration;
25
26use log::{debug, info, warn};
27
28use crate::{ClasspathError, ClasspathResult};
29
30use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
31
32const INIT_SCRIPT: &str = r#"allprojects {
37 task sqryListClasspath {
38 doLast {
39 configurations.findAll { it.name == 'compileClasspath' || it.name == 'implementation' }
40 .each { config ->
41 try {
42 config.resolvedConfiguration.resolvedArtifacts.each { artifact ->
43 println "SQRY_CP:${project.name}:${artifact.moduleVersion.id.group}:${artifact.moduleVersion.id.name}:${artifact.moduleVersion.id.version}:${artifact.file}"
44 }
45 } catch (Exception e) {
46 println "SQRY_CP_ERR:${project.name}:${e.message}"
47 }
48 }
49 }
50 }
51}
52"#;
53
54const CP_PREFIX: &str = "SQRY_CP:";
56
57const CP_ERR_PREFIX: &str = "SQRY_CP_ERR:";
59
60const CACHE_FILENAME: &str = "resolved-classpath.json";
62
63#[allow(clippy::missing_errors_doc)] pub fn resolve_gradle_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
72 let wrapper = find_gradle_wrapper(&config.project_root)?;
73 info!("Found Gradle wrapper at {}", wrapper.display());
74
75 let init_script_file = write_init_script()?;
77 let init_script_path = init_script_file.path();
78
79 debug!("Wrote init script to {}", init_script_path.display());
80
81 let output = execute_gradle(
83 &wrapper,
84 init_script_path,
85 &config.project_root,
86 config.timeout_secs,
87 );
88
89 match output {
90 Ok(stdout) => {
91 let classpaths = parse_gradle_output(&stdout);
92 let classpaths = enrich_source_jars(classpaths);
94
95 let cache_dir = resolve_cache_dir(config);
97 if let Err(e) = write_cache(&cache_dir, &classpaths) {
98 warn!("Failed to write classpath cache: {e}");
99 }
100
101 Ok(classpaths)
102 }
103 Err(e) => {
104 warn!("Gradle resolution failed: {e}");
105 warn!("Attempting to fall back to cached classpath");
106 let cache_dir = resolve_cache_dir(config);
107 read_cache(&cache_dir)
108 }
109 }
110}
111
112fn find_gradle_wrapper(project_root: &Path) -> ClasspathResult<PathBuf> {
116 let wrapper_name = if cfg!(windows) {
117 "gradlew.bat"
118 } else {
119 "gradlew"
120 };
121
122 let wrapper_path = project_root.join(wrapper_name);
123 if wrapper_path.exists() {
124 Ok(wrapper_path)
125 } else {
126 Err(ClasspathError::ResolutionFailed(format!(
127 "Gradle wrapper '{}' not found in {}",
128 wrapper_name,
129 project_root.display()
130 )))
131 }
132}
133
134fn write_init_script() -> ClasspathResult<tempfile::NamedTempFile> {
136 use std::io::Write;
137
138 let mut file = tempfile::Builder::new()
139 .prefix("sqry-gradle-init-")
140 .suffix(".gradle")
141 .tempfile()
142 .map_err(|e| {
143 ClasspathError::ResolutionFailed(format!("Failed to create init script temp file: {e}"))
144 })?;
145
146 file.write_all(INIT_SCRIPT.as_bytes()).map_err(|e| {
147 ClasspathError::ResolutionFailed(format!("Failed to write init script: {e}"))
148 })?;
149
150 file.flush().map_err(|e| {
151 ClasspathError::ResolutionFailed(format!("Failed to flush init script: {e}"))
152 })?;
153
154 Ok(file)
155}
156
157fn execute_gradle(
159 wrapper: &Path,
160 init_script: &Path,
161 project_root: &Path,
162 timeout_secs: u64,
163) -> ClasspathResult<String> {
164 let mut child = Command::new(wrapper)
165 .args([
166 "--init-script",
167 &init_script.to_string_lossy(),
168 "sqryListClasspath",
169 "--quiet",
170 "--no-daemon",
171 ])
172 .current_dir(project_root)
173 .stdout(std::process::Stdio::piped())
174 .stderr(std::process::Stdio::piped())
175 .spawn()
176 .map_err(|e| {
177 ClasspathError::ResolutionFailed(format!(
178 "Failed to spawn Gradle wrapper {}: {e}",
179 wrapper.display()
180 ))
181 })?;
182
183 let timeout = Duration::from_secs(timeout_secs);
184 match child.wait_timeout(timeout) {
185 Ok(Some(status)) => {
186 if status.success() {
187 let stdout = child
188 .stdout
189 .take()
190 .map(|s| {
191 std::io::BufReader::new(s)
192 .lines()
193 .map_while(Result::ok)
194 .collect::<Vec<_>>()
195 .join("\n")
196 })
197 .unwrap_or_default();
198 Ok(stdout)
199 } else {
200 let stderr = child
201 .stderr
202 .take()
203 .map(|s| {
204 std::io::BufReader::new(s)
205 .lines()
206 .map_while(Result::ok)
207 .collect::<Vec<_>>()
208 .join("\n")
209 })
210 .unwrap_or_default();
211 Err(ClasspathError::ResolutionFailed(format!(
212 "Gradle exited with status {status}: {stderr}"
213 )))
214 }
215 }
216 Ok(None) => {
217 let _ = child.kill();
219 let _ = child.wait();
220 Err(ClasspathError::ResolutionFailed(format!(
221 "Gradle timed out after {timeout_secs}s"
222 )))
223 }
224 Err(e) => Err(ClasspathError::ResolutionFailed(format!(
225 "Failed to wait on Gradle process: {e}"
226 ))),
227 }
228}
229
230pub(crate) fn parse_gradle_output(output: &str) -> Vec<ResolvedClasspath> {
237 let mut modules: HashMap<String, Vec<ClasspathEntry>> = HashMap::new();
238
239 for line in output.lines() {
240 let trimmed = line.trim();
241
242 if let Some(err_payload) = trimmed.strip_prefix(CP_ERR_PREFIX) {
243 warn!("Gradle resolution error: {err_payload}");
245 continue;
246 }
247
248 if let Some(payload) = trimmed.strip_prefix(CP_PREFIX)
249 && let Some(entry) = parse_cp_line(payload)
250 {
251 modules.entry(entry.0).or_default().push(entry.1);
252 }
253 }
255
256 let mut result: Vec<ResolvedClasspath> = modules
257 .into_iter()
258 .map(|(module_name, entries)| ResolvedClasspath {
259 module_name,
260 entries,
261 })
262 .collect();
263
264 result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
266 result
267}
268
269fn parse_cp_line(payload: &str) -> Option<(String, ClasspathEntry)> {
277 let mut parts = payload.splitn(5, ':');
278
279 let module = parts.next()?;
280 let group = parts.next()?;
281 let name = parts.next()?;
282 let version = parts.next()?;
283 let path_str = parts.next()?;
284
285 if module.is_empty()
287 || group.is_empty()
288 || name.is_empty()
289 || version.is_empty()
290 || path_str.is_empty()
291 {
292 return None;
293 }
294
295 let coordinates = format!("{group}:{name}:{version}");
296 let jar_path = PathBuf::from(path_str);
297
298 Some((
299 module.to_string(),
300 ClasspathEntry {
301 jar_path,
302 coordinates: Some(coordinates),
303 is_direct: true,
304 source_jar: None,
305 },
306 ))
307}
308
309fn enrich_source_jars(classpaths: Vec<ResolvedClasspath>) -> Vec<ResolvedClasspath> {
315 classpaths
316 .into_iter()
317 .map(|mut cp| {
318 for entry in &mut cp.entries {
319 if let Some(source_jar) = find_source_jar(entry) {
320 entry.source_jar = Some(source_jar);
321 }
322 }
323 cp
324 })
325 .collect()
326}
327
328fn find_source_jar(entry: &ClasspathEntry) -> Option<PathBuf> {
330 let coords = entry.coordinates.as_ref()?;
331 let mut coord_parts = coords.splitn(3, ':');
332 let group = coord_parts.next()?;
333 let name = coord_parts.next()?;
334 let version = coord_parts.next()?;
335
336 let gradle_cache = gradle_cache_dir()?;
337 let module_dir = gradle_cache
338 .join("caches")
339 .join("modules-2")
340 .join("files-2.1")
341 .join(group)
342 .join(name)
343 .join(version);
344
345 if !module_dir.is_dir() {
346 return None;
347 }
348
349 let source_jar_name = format!("{name}-{version}-sources.jar");
350
351 let entries = std::fs::read_dir(&module_dir).ok()?;
354 for hash_dir_entry in entries.flatten() {
355 if hash_dir_entry.file_type().ok()?.is_dir() {
356 let candidate = hash_dir_entry.path().join(&source_jar_name);
357 if candidate.exists() {
358 return Some(candidate);
359 }
360 }
361 }
362
363 None
364}
365
366fn gradle_cache_dir() -> Option<PathBuf> {
371 if let Ok(gradle_home) = std::env::var("GRADLE_USER_HOME") {
372 let path = PathBuf::from(gradle_home);
373 if path.is_dir() {
374 return Some(path);
375 }
376 }
377
378 home_dir().map(|home| home.join(".gradle"))
379}
380
381fn home_dir() -> Option<PathBuf> {
383 #[cfg(unix)]
384 {
385 std::env::var_os("HOME").map(PathBuf::from)
386 }
387 #[cfg(windows)]
388 {
389 std::env::var_os("USERPROFILE").map(PathBuf::from)
390 }
391 #[cfg(not(any(unix, windows)))]
392 {
393 None
394 }
395}
396
397fn resolve_cache_dir(config: &ResolveConfig) -> PathBuf {
399 config
400 .cache_path
401 .clone()
402 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
403}
404
405fn write_cache(cache_dir: &Path, classpaths: &[ResolvedClasspath]) -> ClasspathResult<()> {
407 std::fs::create_dir_all(cache_dir)?;
408
409 let cache_path = cache_dir.join(CACHE_FILENAME);
410 let json = serde_json::to_string_pretty(classpaths)
411 .map_err(|e| ClasspathError::CacheError(format!("Failed to serialize classpath: {e}")))?;
412
413 std::fs::write(&cache_path, json)?;
414
415 debug!("Wrote classpath cache to {}", cache_path.display());
416 Ok(())
417}
418
419fn read_cache(cache_dir: &Path) -> ClasspathResult<Vec<ResolvedClasspath>> {
422 let cache_path = cache_dir.join(CACHE_FILENAME);
423
424 if !cache_path.exists() {
425 warn!(
426 "No cached classpath found at {}; returning empty classpath",
427 cache_path.display()
428 );
429 return Ok(Vec::new());
430 }
431
432 let json = std::fs::read_to_string(&cache_path)?;
433 let classpaths: Vec<ResolvedClasspath> = serde_json::from_str(&json).map_err(|e| {
434 ClasspathError::CacheError(format!("Failed to deserialize classpath cache: {e}"))
435 })?;
436
437 info!(
438 "Loaded {} modules from classpath cache at {}",
439 classpaths.len(),
440 cache_path.display()
441 );
442
443 Ok(classpaths)
444}
445
446trait WaitTimeout {
451 fn wait_timeout(
454 &mut self,
455 timeout: Duration,
456 ) -> std::io::Result<Option<std::process::ExitStatus>>;
457}
458
459impl WaitTimeout for std::process::Child {
460 fn wait_timeout(
461 &mut self,
462 timeout: Duration,
463 ) -> std::io::Result<Option<std::process::ExitStatus>> {
464 let start = std::time::Instant::now();
465 let poll_interval = Duration::from_millis(100);
466
467 loop {
468 if let Some(status) = self.try_wait()? {
469 return Ok(Some(status));
470 }
471 if start.elapsed() >= timeout {
472 return Ok(None);
473 }
474 std::thread::sleep(poll_interval);
475 }
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use tempfile::TempDir;
483
484 #[test]
489 fn test_parse_valid_output_single_module() {
490 let output = "\
491SQRY_CP:app:com.google.guava:guava:33.0.0:/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar
492SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/home/user/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9/def456/slf4j-api-2.0.9.jar";
493
494 let result = parse_gradle_output(output);
495 assert_eq!(result.len(), 1);
496
497 let module = &result[0];
498 assert_eq!(module.module_name, "app");
499 assert_eq!(module.entries.len(), 2);
500
501 assert_eq!(
502 module.entries[0].coordinates.as_deref(),
503 Some("com.google.guava:guava:33.0.0")
504 );
505 assert_eq!(
506 module.entries[0].jar_path,
507 PathBuf::from(
508 "/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar"
509 )
510 );
511
512 assert_eq!(
513 module.entries[1].coordinates.as_deref(),
514 Some("org.slf4j:slf4j-api:2.0.9")
515 );
516 }
517
518 #[test]
519 fn test_parse_multi_module_output() {
520 let output = "\
521SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
522SQRY_CP:lib:org.apache.commons:commons-lang3:3.14.0:/path/to/commons-lang3.jar
523SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
524SQRY_CP:lib:com.fasterxml.jackson.core:jackson-core:2.16.0:/path/to/jackson-core.jar";
525
526 let result = parse_gradle_output(output);
527 assert_eq!(result.len(), 2);
528
529 let app = result.iter().find(|m| m.module_name == "app").unwrap();
530 assert_eq!(app.entries.len(), 2);
531
532 let lib = result.iter().find(|m| m.module_name == "lib").unwrap();
533 assert_eq!(lib.entries.len(), 2);
534 }
535
536 #[test]
537 fn test_parse_empty_output() {
538 let result = parse_gradle_output("");
539 assert!(result.is_empty());
540 }
541
542 #[test]
543 fn test_parse_output_with_noise() {
544 let output = "\
545Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip
546...........10%...........20%...........30%...........40%...........50%
547> Task :app:sqryListClasspath
548SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
549BUILD SUCCESSFUL in 5s
5501 actionable task: 1 executed";
551
552 let result = parse_gradle_output(output);
553 assert_eq!(result.len(), 1);
554 assert_eq!(result[0].entries.len(), 1);
555 assert_eq!(
556 result[0].entries[0].coordinates.as_deref(),
557 Some("com.google.guava:guava:33.0.0")
558 );
559 }
560
561 #[test]
562 fn test_parse_malformed_lines_skipped() {
563 let output = "\
564SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
565SQRY_CP:broken:only_three_parts
566SQRY_CP:::::/path/empty_fields
567SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
568SQRY_CP:";
569
570 let result = parse_gradle_output(output);
571 assert_eq!(result.len(), 1);
572 assert_eq!(
573 result[0].entries.len(),
574 2,
575 "Only valid lines should produce entries"
576 );
577 }
578
579 #[test]
580 fn test_parse_error_lines_logged() {
581 let output = "\
582SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
583SQRY_CP_ERR:lib:Could not resolve configuration 'compileClasspath'
584SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar";
585
586 let result = parse_gradle_output(output);
587 assert_eq!(result.len(), 1);
588 assert_eq!(result[0].entries.len(), 2);
589 }
591
592 #[test]
593 fn test_parse_windows_path_with_colon() {
594 let output =
597 "SQRY_CP:app:com.google.guava:guava:33.0.0:C:\\Users\\dev\\.gradle\\caches\\guava.jar";
598
599 let result = parse_gradle_output(output);
600 assert_eq!(result.len(), 1);
601 assert_eq!(
602 result[0].entries[0].jar_path,
603 PathBuf::from("C:\\Users\\dev\\.gradle\\caches\\guava.jar")
604 );
605 }
606
607 #[test]
612 fn test_source_jar_path_construction() {
613 let tmp = TempDir::new().unwrap();
614
615 let module_dir = tmp
617 .path()
618 .join("caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123");
619 std::fs::create_dir_all(&module_dir).unwrap();
620 let source_jar = module_dir.join("guava-33.0.0-sources.jar");
621 std::fs::write(&source_jar, b"fake jar").unwrap();
622
623 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
627
628 let entry = ClasspathEntry {
629 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
630 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
631 is_direct: true,
632 source_jar: None,
633 };
634
635 let found = find_source_jar(&entry);
636 assert_eq!(found, Some(source_jar));
637 }
638
639 #[test]
640 fn test_source_jar_not_found() {
641 let tmp = TempDir::new().unwrap();
642 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
643
644 let entry = ClasspathEntry {
645 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
646 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
647 is_direct: true,
648 source_jar: None,
649 };
650
651 let found = find_source_jar(&entry);
652 assert!(found.is_none());
653 }
654
655 #[test]
656 fn test_source_jar_no_coordinates() {
657 let entry = ClasspathEntry {
658 jar_path: PathBuf::from("/path/to/something.jar"),
659 coordinates: None,
660 is_direct: true,
661 source_jar: None,
662 };
663
664 let found = find_source_jar(&entry);
665 assert!(found.is_none());
666 }
667
668 #[test]
673 fn test_cache_roundtrip() {
674 let tmp = TempDir::new().unwrap();
675 let cache_dir = tmp.path().join("cache");
676
677 let classpaths = vec![
678 ResolvedClasspath {
679 module_name: "app".to_string(),
680 entries: vec![ClasspathEntry {
681 jar_path: PathBuf::from("/path/to/guava.jar"),
682 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
683 is_direct: true,
684 source_jar: None,
685 }],
686 },
687 ResolvedClasspath {
688 module_name: "lib".to_string(),
689 entries: vec![ClasspathEntry {
690 jar_path: PathBuf::from("/path/to/commons.jar"),
691 coordinates: Some("org.apache.commons:commons-lang3:3.14.0".to_string()),
692 is_direct: true,
693 source_jar: Some(PathBuf::from("/path/to/commons-sources.jar")),
694 }],
695 },
696 ];
697
698 write_cache(&cache_dir, &classpaths).expect("cache write should succeed");
699
700 let loaded = read_cache(&cache_dir).expect("cache read should succeed");
701 assert_eq!(loaded.len(), 2);
702
703 let app = loaded.iter().find(|m| m.module_name == "app").unwrap();
704 assert_eq!(app.entries.len(), 1);
705 assert_eq!(
706 app.entries[0].coordinates.as_deref(),
707 Some("com.google.guava:guava:33.0.0")
708 );
709
710 let lib = loaded.iter().find(|m| m.module_name == "lib").unwrap();
711 assert_eq!(
712 lib.entries[0].source_jar,
713 Some(PathBuf::from("/path/to/commons-sources.jar"))
714 );
715 }
716
717 #[test]
718 fn test_cache_read_missing_returns_empty() {
719 let tmp = TempDir::new().unwrap();
720 let cache_dir = tmp.path().join("nonexistent");
721
722 let result = read_cache(&cache_dir).expect("should succeed with empty vec");
723 assert!(result.is_empty());
724 }
725
726 #[test]
731 fn test_missing_gradlew_error() {
732 let tmp = TempDir::new().unwrap();
733 let result = find_gradle_wrapper(tmp.path());
734 assert!(result.is_err());
735
736 let err = result.unwrap_err();
737 let msg = err.to_string();
738 assert!(
739 msg.contains("not found"),
740 "Error message should mention 'not found': {msg}"
741 );
742 }
743
744 #[test]
745 fn test_gradlew_found() {
746 let tmp = TempDir::new().unwrap();
747 let wrapper_name = if cfg!(windows) {
748 "gradlew.bat"
749 } else {
750 "gradlew"
751 };
752 std::fs::write(tmp.path().join(wrapper_name), "#!/bin/sh\n").unwrap();
753
754 let result = find_gradle_wrapper(tmp.path());
755 assert!(result.is_ok());
756 assert_eq!(result.unwrap(), tmp.path().join(wrapper_name));
757 }
758
759 #[test]
764 fn test_init_script_content() {
765 let file = write_init_script().expect("should create init script");
766 let content = std::fs::read_to_string(file.path()).unwrap();
767 assert!(content.contains("sqryListClasspath"));
768 assert!(content.contains("SQRY_CP:"));
769 assert!(content.contains("compileClasspath"));
770 assert!(content.contains("resolvedConfiguration"));
771 }
772
773 #[test]
778 fn test_resolve_cache_dir_default() {
779 let config = ResolveConfig {
780 project_root: PathBuf::from("/my/project"),
781 timeout_secs: 60,
782 cache_path: None,
783 };
784 let dir = resolve_cache_dir(&config);
785 assert_eq!(dir, PathBuf::from("/my/project/.sqry/classpath"));
786 }
787
788 #[test]
789 fn test_resolve_cache_dir_override() {
790 let config = ResolveConfig {
791 project_root: PathBuf::from("/my/project"),
792 timeout_secs: 60,
793 cache_path: Some(PathBuf::from("/custom/cache")),
794 };
795 let dir = resolve_cache_dir(&config);
796 assert_eq!(dir, PathBuf::from("/custom/cache"));
797 }
798
799 #[test]
804 fn test_parse_cp_line_valid() {
805 let result = parse_cp_line("app:com.google.guava:guava:33.0.0:/path/to/guava.jar");
806 assert!(result.is_some());
807 let (module, entry) = result.unwrap();
808 assert_eq!(module, "app");
809 assert_eq!(
810 entry.coordinates.as_deref(),
811 Some("com.google.guava:guava:33.0.0")
812 );
813 assert_eq!(entry.jar_path, PathBuf::from("/path/to/guava.jar"));
814 assert!(entry.is_direct);
815 assert!(entry.source_jar.is_none());
816 }
817
818 #[test]
819 fn test_parse_cp_line_too_few_parts() {
820 assert!(parse_cp_line("app:group:name").is_none());
821 assert!(parse_cp_line("app:group:name:version").is_none());
822 assert!(parse_cp_line("").is_none());
823 }
824
825 #[test]
826 fn test_parse_cp_line_empty_fields() {
827 assert!(parse_cp_line(":group:name:version:/path").is_none());
828 assert!(parse_cp_line("app::name:version:/path").is_none());
829 assert!(parse_cp_line("app:group::version:/path").is_none());
830 assert!(parse_cp_line("app:group:name::/path").is_none());
831 assert!(parse_cp_line("app:group:name:version:").is_none());
832 }
833
834 struct EnvGuard {
841 key: String,
842 original: Option<String>,
843 }
844
845 impl EnvGuard {
846 fn set(key: &str, value: &str) -> Self {
847 let original = std::env::var(key).ok();
848 unsafe {
850 std::env::set_var(key, value);
851 }
852 Self {
853 key: key.to_string(),
854 original,
855 }
856 }
857 }
858
859 impl Drop for EnvGuard {
860 fn drop(&mut self) {
861 unsafe {
863 match &self.original {
864 Some(val) => std::env::set_var(&self.key, val),
865 None => std::env::remove_var(&self.key),
866 }
867 }
868 }
869 }
870}