1use std::collections::{HashMap, HashSet};
24use std::io::BufRead;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27use std::time::Duration;
28
29use log::{debug, info, warn};
30use serde::Deserialize;
31
32use crate::{ClasspathError, ClasspathResult};
33
34use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
35
36const INIT_SCRIPT: &str = r#"import groovy.json.JsonOutput
41
42allprojects {
43 task sqryListClasspath {
44 doLast {
45 configurations.findAll { it.name == 'compileClasspath' || it.name == 'implementation' }
46 .each { config ->
47 try {
48 config.resolvedConfiguration.resolvedArtifacts.each { artifact ->
49 println "SQRY_CP_JSON:" + JsonOutput.toJson([
50 module_name: project.name,
51 module_root: project.projectDir.absolutePath,
52 group: artifact.moduleVersion.id.group,
53 name: artifact.moduleVersion.id.name,
54 version: artifact.moduleVersion.id.version,
55 path: artifact.file.absolutePath,
56 ])
57 }
58 } catch (Exception e) {
59 println "SQRY_CP_ERR:${project.name}:${e.message}"
60 }
61 }
62 }
63 }
64}
65"#;
66
67const CP_JSON_PREFIX: &str = "SQRY_CP_JSON:";
69
70const CP_ERR_PREFIX: &str = "SQRY_CP_ERR:";
72
73const CACHE_FILENAME: &str = "resolved-classpath.json";
75
76#[allow(clippy::missing_errors_doc)] pub fn resolve_gradle_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
86 let cache_dir = resolve_cache_dir(config);
87 let gradle_command = find_gradle_command(&config.project_root);
88 let Some(gradle_command) = gradle_command else {
89 warn!(
90 "No Gradle wrapper or installed gradle found for {}",
91 config.project_root.display()
92 );
93 return read_cache_or_error(&cache_dir, "No Gradle wrapper or installed gradle found");
94 };
95 if !is_project_local_gradle_wrapper(&config.project_root, &gradle_command) {
96 warn!(
97 "Gradle wrapper missing in {}; falling back to installed Gradle at {}. This may be less reproducible if the installed version differs from the project's expected wrapper version.",
98 config.project_root.display(),
99 gradle_command.display()
100 );
101 }
102 info!("Using Gradle command {}", gradle_command.display());
103
104 let init_script_file = write_init_script()?;
106 let init_script_path = init_script_file.path();
107
108 debug!("Wrote init script to {}", init_script_path.display());
109
110 let output = execute_gradle(
112 &gradle_command,
113 init_script_path,
114 &config.project_root,
115 config.timeout_secs,
116 );
117
118 match output {
119 Ok(stdout) => {
120 let classpaths = parse_gradle_output(&stdout);
121 let classpaths = enrich_source_jars(classpaths);
123
124 if let Err(e) = write_cache(&cache_dir, &classpaths) {
126 warn!("Failed to write classpath cache: {e}");
127 }
128
129 Ok(classpaths)
130 }
131 Err(e) => {
132 warn!("Gradle resolution failed: {e}");
133 warn!("Attempting to fall back to cached classpath");
134 read_cache_or_error(&cache_dir, &e.to_string())
135 }
136 }
137}
138
139fn find_gradle_command(project_root: &Path) -> Option<PathBuf> {
144 find_gradle_command_with_path(project_root, std::env::var_os("PATH").as_deref())
145}
146
147fn find_gradle_command_with_path(
155 project_root: &Path,
156 path_var: Option<&std::ffi::OsStr>,
157) -> Option<PathBuf> {
158 let wrapper_name = if cfg!(windows) {
159 "gradlew.bat"
160 } else {
161 "gradlew"
162 };
163
164 let wrapper_path = project_root.join(wrapper_name);
165 if wrapper_path.exists() {
166 Some(wrapper_path)
167 } else {
168 which_binary_in(
169 if cfg!(windows) {
170 "gradle.bat"
171 } else {
172 "gradle"
173 },
174 path_var,
175 )
176 }
177}
178
179fn is_project_local_gradle_wrapper(project_root: &Path, command: &Path) -> bool {
180 let wrapper_name = if cfg!(windows) {
181 "gradlew.bat"
182 } else {
183 "gradlew"
184 };
185 command == project_root.join(wrapper_name)
186}
187
188fn write_init_script() -> ClasspathResult<tempfile::NamedTempFile> {
190 use std::io::Write;
191
192 let mut file = tempfile::Builder::new()
193 .prefix("sqry-gradle-init-")
194 .suffix(".gradle")
195 .tempfile()
196 .map_err(|e| {
197 ClasspathError::ResolutionFailed(format!("Failed to create init script temp file: {e}"))
198 })?;
199
200 file.write_all(INIT_SCRIPT.as_bytes()).map_err(|e| {
201 ClasspathError::ResolutionFailed(format!("Failed to write init script: {e}"))
202 })?;
203
204 file.flush().map_err(|e| {
205 ClasspathError::ResolutionFailed(format!("Failed to flush init script: {e}"))
206 })?;
207
208 Ok(file)
209}
210
211fn execute_gradle(
213 wrapper: &Path,
214 init_script: &Path,
215 project_root: &Path,
216 timeout_secs: u64,
217) -> ClasspathResult<String> {
218 let mut child = Command::new(wrapper)
219 .args([
220 "--init-script",
221 &init_script.to_string_lossy(),
222 "sqryListClasspath",
223 "--quiet",
224 "--no-daemon",
225 ])
226 .current_dir(project_root)
227 .stdout(std::process::Stdio::piped())
228 .stderr(std::process::Stdio::piped())
229 .spawn()
230 .map_err(|e| {
231 ClasspathError::ResolutionFailed(format!(
232 "Failed to spawn Gradle wrapper {}: {e}",
233 wrapper.display()
234 ))
235 })?;
236
237 let timeout = Duration::from_secs(timeout_secs);
238 match child.wait_timeout(timeout) {
239 Ok(Some(status)) => {
240 if status.success() {
241 let stdout = child
242 .stdout
243 .take()
244 .map(|s| {
245 std::io::BufReader::new(s)
246 .lines()
247 .map_while(Result::ok)
248 .collect::<Vec<_>>()
249 .join("\n")
250 })
251 .unwrap_or_default();
252 Ok(stdout)
253 } else {
254 let stderr = child
255 .stderr
256 .take()
257 .map(|s| {
258 std::io::BufReader::new(s)
259 .lines()
260 .map_while(Result::ok)
261 .collect::<Vec<_>>()
262 .join("\n")
263 })
264 .unwrap_or_default();
265 Err(ClasspathError::ResolutionFailed(format!(
266 "Gradle exited with status {status}: {stderr}"
267 )))
268 }
269 }
270 Ok(None) => {
271 let _ = child.kill();
273 let _ = child.wait();
274 Err(ClasspathError::ResolutionFailed(format!(
275 "Gradle timed out after {timeout_secs}s"
276 )))
277 }
278 Err(e) => Err(ClasspathError::ResolutionFailed(format!(
279 "Failed to wait on Gradle process: {e}"
280 ))),
281 }
282}
283
284pub(crate) fn parse_gradle_output(output: &str) -> Vec<ResolvedClasspath> {
292 let mut modules: HashMap<(String, PathBuf), Vec<ClasspathEntry>> = HashMap::new();
293
294 for line in output.lines() {
295 let trimmed = line.trim();
296
297 if let Some(err_payload) = trimmed.strip_prefix(CP_ERR_PREFIX) {
298 warn!("Gradle resolution error: {err_payload}");
300 continue;
301 }
302
303 if let Some(payload) = trimmed.strip_prefix(CP_JSON_PREFIX)
304 && let Some(entry) = parse_cp_json_line(payload)
305 {
306 modules
307 .entry((entry.module_name, entry.module_root))
308 .or_default()
309 .push(entry.entry);
310 continue;
311 }
312
313 if let Some(payload) = trimmed.strip_prefix("SQRY_CP:")
314 && let Some(entry) = parse_cp_line(payload)
315 {
316 modules
317 .entry((entry.module_name, entry.module_root))
318 .or_default()
319 .push(entry.entry);
320 }
321 }
323
324 let mut result: Vec<ResolvedClasspath> = modules
325 .into_iter()
326 .map(|((module_name, module_root), entries)| ResolvedClasspath {
327 module_name,
328 module_root,
329 entries,
330 })
331 .collect();
332
333 result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
335 result
336}
337
338#[derive(Deserialize)]
339struct GradleClasspathJsonRecord {
340 module_name: String,
341 module_root: String,
342 group: String,
343 name: String,
344 version: String,
345 path: String,
346}
347
348struct ParsedGradleEntry {
349 module_name: String,
350 module_root: PathBuf,
351 entry: ClasspathEntry,
352}
353
354fn parse_cp_json_line(payload: &str) -> Option<ParsedGradleEntry> {
356 let record: GradleClasspathJsonRecord = serde_json::from_str(payload).ok()?;
357 if record.module_name.is_empty()
358 || record.module_root.is_empty()
359 || record.group.is_empty()
360 || record.name.is_empty()
361 || record.version.is_empty()
362 || record.path.is_empty()
363 {
364 return None;
365 }
366
367 Some(ParsedGradleEntry {
368 module_name: record.module_name,
369 module_root: PathBuf::from(record.module_root),
370 entry: ClasspathEntry {
371 jar_path: PathBuf::from(record.path),
372 coordinates: Some(format!(
373 "{}:{}:{}",
374 record.group, record.name, record.version
375 )),
376 is_direct: true,
377 source_jar: None,
378 },
379 })
380}
381
382fn parse_cp_line(payload: &str) -> Option<ParsedGradleEntry> {
390 let mut parts = payload.splitn(5, ':');
391
392 let module = parts.next()?;
393 let group = parts.next()?;
394 let name = parts.next()?;
395 let version = parts.next()?;
396 let path_str = parts.next()?;
397
398 if module.is_empty()
400 || group.is_empty()
401 || name.is_empty()
402 || version.is_empty()
403 || path_str.is_empty()
404 {
405 return None;
406 }
407
408 let coordinates = format!("{group}:{name}:{version}");
409 let jar_path = PathBuf::from(path_str);
410
411 Some(ParsedGradleEntry {
412 module_name: module.to_string(),
413 module_root: PathBuf::from(module),
414 entry: ClasspathEntry {
415 jar_path,
416 coordinates: Some(coordinates),
417 is_direct: true,
418 source_jar: None,
419 },
420 })
421}
422
423fn enrich_source_jars(classpaths: Vec<ResolvedClasspath>) -> Vec<ResolvedClasspath> {
429 classpaths
430 .into_iter()
431 .map(|mut cp| {
432 for entry in &mut cp.entries {
433 if let Some(source_jar) = find_source_jar(entry) {
434 entry.source_jar = Some(source_jar);
435 }
436 }
437 cp
438 })
439 .collect()
440}
441
442fn find_source_jar(entry: &ClasspathEntry) -> Option<PathBuf> {
444 let coords = entry.coordinates.as_ref()?;
445 let mut coord_parts = coords.splitn(3, ':');
446 let group = coord_parts.next()?;
447 let name = coord_parts.next()?;
448 let version = coord_parts.next()?;
449
450 let gradle_cache = gradle_cache_dir()?;
451 let module_dir = gradle_cache
452 .join("caches")
453 .join("modules-2")
454 .join("files-2.1")
455 .join(group)
456 .join(name)
457 .join(version);
458
459 if !module_dir.is_dir() {
460 return None;
461 }
462
463 let source_jar_name = format!("{name}-{version}-sources.jar");
464
465 let entries = std::fs::read_dir(&module_dir).ok()?;
468 for hash_dir_entry in entries.flatten() {
469 if hash_dir_entry.file_type().ok()?.is_dir() {
470 let candidate = hash_dir_entry.path().join(&source_jar_name);
471 if candidate.exists() {
472 return Some(candidate);
473 }
474 }
475 }
476
477 None
478}
479
480fn gradle_cache_dir() -> Option<PathBuf> {
485 if let Ok(gradle_home) = std::env::var("GRADLE_USER_HOME") {
486 let path = PathBuf::from(gradle_home);
487 if path.is_dir() {
488 return Some(path);
489 }
490 }
491
492 home_dir().map(|home| home.join(".gradle"))
493}
494
495fn home_dir() -> Option<PathBuf> {
497 #[cfg(unix)]
498 {
499 std::env::var_os("HOME").map(PathBuf::from)
500 }
501 #[cfg(windows)]
502 {
503 std::env::var_os("USERPROFILE").map(PathBuf::from)
504 }
505 #[cfg(not(any(unix, windows)))]
506 {
507 None
508 }
509}
510
511fn resolve_cache_dir(config: &ResolveConfig) -> PathBuf {
513 config
514 .cache_path
515 .clone()
516 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
517}
518
519fn write_cache(cache_dir: &Path, classpaths: &[ResolvedClasspath]) -> ClasspathResult<()> {
521 std::fs::create_dir_all(cache_dir)?;
522
523 let cache_path = cache_dir.join(CACHE_FILENAME);
524 let json = serde_json::to_string_pretty(classpaths)
525 .map_err(|e| ClasspathError::CacheError(format!("Failed to serialize classpath: {e}")))?;
526
527 std::fs::write(&cache_path, json)?;
528
529 debug!("Wrote classpath cache to {}", cache_path.display());
530 Ok(())
531}
532
533fn read_cache(cache_dir: &Path) -> ClasspathResult<Vec<ResolvedClasspath>> {
536 let cache_path = cache_dir.join(CACHE_FILENAME);
537
538 if !cache_path.exists() {
539 warn!(
540 "No cached classpath found at {}; returning empty classpath",
541 cache_path.display()
542 );
543 return Ok(Vec::new());
544 }
545
546 let json = std::fs::read_to_string(&cache_path)?;
547 let classpaths: Vec<ResolvedClasspath> = serde_json::from_str(&json).map_err(|e| {
548 ClasspathError::CacheError(format!("Failed to deserialize classpath cache: {e}"))
549 })?;
550
551 info!(
552 "Loaded {} modules from classpath cache at {}",
553 classpaths.len(),
554 cache_path.display()
555 );
556
557 Ok(classpaths)
558}
559
560fn read_cache_or_error(
561 cache_dir: &Path,
562 live_error: &str,
563) -> ClasspathResult<Vec<ResolvedClasspath>> {
564 let cache_path = cache_dir.join(CACHE_FILENAME);
565 let classpaths = read_cache(cache_dir)?;
566 if classpaths.is_empty() {
567 return Err(ClasspathError::ResolutionFailed(format!(
568 "{live_error}. No cached classpath available at {}. Add a project wrapper, install Gradle, or use --classpath-file.",
569 cache_path.display()
570 )));
571 }
572 warn_if_cache_stale(cache_dir, &classpaths);
573 Ok(classpaths)
574}
575
576fn warn_if_cache_stale(cache_dir: &Path, classpaths: &[ResolvedClasspath]) {
577 if classpaths.is_empty() {
578 return;
579 }
580 let cache_path = cache_dir.join(CACHE_FILENAME);
581 let Ok(cache_meta) = std::fs::metadata(&cache_path) else {
582 return;
583 };
584 let Ok(cache_mtime) = cache_meta.modified() else {
585 return;
586 };
587
588 let mut roots = HashSet::new();
589 for cp in classpaths {
590 roots.insert(cp.module_root.as_path());
591 }
592
593 for root in roots {
594 for marker in [
595 "build.gradle",
596 "build.gradle.kts",
597 "settings.gradle",
598 "settings.gradle.kts",
599 "gradle.properties",
600 ] {
601 let marker_path = root.join(marker);
602 let Ok(meta) = std::fs::metadata(&marker_path) else {
603 continue;
604 };
605 let Ok(modified) = meta.modified() else {
606 continue;
607 };
608 if modified > cache_mtime {
609 warn!(
610 "Using cached Gradle classpath from {} even though {} is newer; cache may be stale",
611 cache_path.display(),
612 marker_path.display()
613 );
614 return;
615 }
616 }
617 }
618}
619
620fn which_binary_in(name: &str, path_var: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
624 let path_var = path_var?;
625 for dir in std::env::split_paths(path_var) {
626 let candidate = dir.join(name);
627 if candidate.is_file() {
628 return Some(candidate);
629 }
630 }
631 None
632}
633
634trait WaitTimeout {
639 fn wait_timeout(
642 &mut self,
643 timeout: Duration,
644 ) -> std::io::Result<Option<std::process::ExitStatus>>;
645}
646
647impl WaitTimeout for std::process::Child {
648 fn wait_timeout(
649 &mut self,
650 timeout: Duration,
651 ) -> std::io::Result<Option<std::process::ExitStatus>> {
652 let start = std::time::Instant::now();
653 let poll_interval = Duration::from_millis(100);
654
655 loop {
656 if let Some(status) = self.try_wait()? {
657 return Ok(Some(status));
658 }
659 if start.elapsed() >= timeout {
660 return Ok(None);
661 }
662 std::thread::sleep(poll_interval);
663 }
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670 use serial_test::serial;
671 use tempfile::TempDir;
672
673 #[test]
678 fn test_parse_valid_output_single_module() {
679 let output = "\
680SQRY_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
681SQRY_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";
682
683 let result = parse_gradle_output(output);
684 assert_eq!(result.len(), 1);
685
686 let module = &result[0];
687 assert_eq!(module.module_name, "app");
688 assert_eq!(module.entries.len(), 2);
689
690 assert_eq!(
691 module.entries[0].coordinates.as_deref(),
692 Some("com.google.guava:guava:33.0.0")
693 );
694 assert_eq!(
695 module.entries[0].jar_path,
696 PathBuf::from(
697 "/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar"
698 )
699 );
700
701 assert_eq!(
702 module.entries[1].coordinates.as_deref(),
703 Some("org.slf4j:slf4j-api:2.0.9")
704 );
705 }
706
707 #[test]
708 fn test_parse_multi_module_output() {
709 let output = "\
710SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
711SQRY_CP:lib:org.apache.commons:commons-lang3:3.14.0:/path/to/commons-lang3.jar
712SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
713SQRY_CP:lib:com.fasterxml.jackson.core:jackson-core:2.16.0:/path/to/jackson-core.jar";
714
715 let result = parse_gradle_output(output);
716 assert_eq!(result.len(), 2);
717
718 let app = result.iter().find(|m| m.module_name == "app").unwrap();
719 assert_eq!(app.entries.len(), 2);
720
721 let lib = result.iter().find(|m| m.module_name == "lib").unwrap();
722 assert_eq!(lib.entries.len(), 2);
723 }
724
725 #[test]
726 fn test_parse_empty_output() {
727 let result = parse_gradle_output("");
728 assert!(result.is_empty());
729 }
730
731 #[test]
732 fn test_parse_output_with_noise() {
733 let output = "\
734Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip
735...........10%...........20%...........30%...........40%...........50%
736> Task :app:sqryListClasspath
737SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
738BUILD SUCCESSFUL in 5s
7391 actionable task: 1 executed";
740
741 let result = parse_gradle_output(output);
742 assert_eq!(result.len(), 1);
743 assert_eq!(result[0].entries.len(), 1);
744 assert_eq!(
745 result[0].entries[0].coordinates.as_deref(),
746 Some("com.google.guava:guava:33.0.0")
747 );
748 }
749
750 #[test]
751 fn test_parse_malformed_lines_skipped() {
752 let output = "\
753SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
754SQRY_CP:broken:only_three_parts
755SQRY_CP:::::/path/empty_fields
756SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
757SQRY_CP:";
758
759 let result = parse_gradle_output(output);
760 assert_eq!(result.len(), 1);
761 assert_eq!(
762 result[0].entries.len(),
763 2,
764 "Only valid lines should produce entries"
765 );
766 }
767
768 #[test]
769 fn test_parse_error_lines_logged() {
770 let output = "\
771SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
772SQRY_CP_ERR:lib:Could not resolve configuration 'compileClasspath'
773SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar";
774
775 let result = parse_gradle_output(output);
776 assert_eq!(result.len(), 1);
777 assert_eq!(result[0].entries.len(), 2);
778 }
780
781 #[test]
782 fn test_parse_windows_path_with_colon() {
783 let output =
786 "SQRY_CP:app:com.google.guava:guava:33.0.0:C:\\Users\\dev\\.gradle\\caches\\guava.jar";
787
788 let result = parse_gradle_output(output);
789 assert_eq!(result.len(), 1);
790 assert_eq!(
791 result[0].entries[0].jar_path,
792 PathBuf::from("C:\\Users\\dev\\.gradle\\caches\\guava.jar")
793 );
794 }
795
796 #[test]
801 #[serial]
802 fn test_source_jar_path_construction() {
803 let tmp = TempDir::new().unwrap();
804
805 let module_dir = tmp
807 .path()
808 .join("caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123");
809 std::fs::create_dir_all(&module_dir).unwrap();
810 let source_jar = module_dir.join("guava-33.0.0-sources.jar");
811 std::fs::write(&source_jar, b"fake jar").unwrap();
812
813 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
816
817 let entry = ClasspathEntry {
818 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
819 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
820 is_direct: true,
821 source_jar: None,
822 };
823
824 let found = find_source_jar(&entry);
825 assert_eq!(found, Some(source_jar));
826 }
827
828 #[test]
829 #[serial]
830 fn test_source_jar_not_found() {
831 let tmp = TempDir::new().unwrap();
832 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
833
834 let entry = ClasspathEntry {
835 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
836 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
837 is_direct: true,
838 source_jar: None,
839 };
840
841 let found = find_source_jar(&entry);
842 assert!(found.is_none());
843 }
844
845 #[test]
846 fn test_source_jar_no_coordinates() {
847 let entry = ClasspathEntry {
848 jar_path: PathBuf::from("/path/to/something.jar"),
849 coordinates: None,
850 is_direct: true,
851 source_jar: None,
852 };
853
854 let found = find_source_jar(&entry);
855 assert!(found.is_none());
856 }
857
858 #[test]
863 fn test_cache_roundtrip() {
864 let tmp = TempDir::new().unwrap();
865 let cache_dir = tmp.path().join("cache");
866
867 let classpaths = vec![
868 ResolvedClasspath {
869 module_name: "app".to_string(),
870 module_root: PathBuf::from("/repo/app"),
871 entries: vec![ClasspathEntry {
872 jar_path: PathBuf::from("/path/to/guava.jar"),
873 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
874 is_direct: true,
875 source_jar: None,
876 }],
877 },
878 ResolvedClasspath {
879 module_name: "lib".to_string(),
880 module_root: PathBuf::from("/repo/lib"),
881 entries: vec![ClasspathEntry {
882 jar_path: PathBuf::from("/path/to/commons.jar"),
883 coordinates: Some("org.apache.commons:commons-lang3:3.14.0".to_string()),
884 is_direct: true,
885 source_jar: Some(PathBuf::from("/path/to/commons-sources.jar")),
886 }],
887 },
888 ];
889
890 write_cache(&cache_dir, &classpaths).expect("cache write should succeed");
891
892 let loaded = read_cache(&cache_dir).expect("cache read should succeed");
893 assert_eq!(loaded.len(), 2);
894
895 let app = loaded.iter().find(|m| m.module_name == "app").unwrap();
896 assert_eq!(app.entries.len(), 1);
897 assert_eq!(
898 app.entries[0].coordinates.as_deref(),
899 Some("com.google.guava:guava:33.0.0")
900 );
901
902 let lib = loaded.iter().find(|m| m.module_name == "lib").unwrap();
903 assert_eq!(
904 lib.entries[0].source_jar,
905 Some(PathBuf::from("/path/to/commons-sources.jar"))
906 );
907 }
908
909 #[test]
910 fn test_cache_read_missing_returns_empty() {
911 let tmp = TempDir::new().unwrap();
912 let cache_dir = tmp.path().join("nonexistent");
913
914 let result = read_cache(&cache_dir).expect("should succeed with empty vec");
915 assert!(result.is_empty());
916 }
917
918 #[test]
923 fn test_missing_gradle_command_returns_none() {
924 let tmp = TempDir::new().unwrap();
928 let result = find_gradle_command_with_path(tmp.path(), None);
929 assert!(
930 result.is_none(),
931 "expected None when no wrapper exists and PATH is empty, got {result:?}"
932 );
933 }
934
935 #[test]
936 fn test_gradle_wrapper_found() {
937 let tmp = TempDir::new().unwrap();
938 let wrapper_name = if cfg!(windows) {
939 "gradlew.bat"
940 } else {
941 "gradlew"
942 };
943 std::fs::write(tmp.path().join(wrapper_name), "#!/bin/sh\n").unwrap();
944
945 let result = find_gradle_command(tmp.path());
946 assert_eq!(result, Some(tmp.path().join(wrapper_name)));
947 }
948
949 #[test]
954 fn test_init_script_content() {
955 let file = write_init_script().expect("should create init script");
956 let content = std::fs::read_to_string(file.path()).unwrap();
957 assert!(content.contains("sqryListClasspath"));
958 assert!(content.contains("SQRY_CP_JSON:"));
959 assert!(content.contains("compileClasspath"));
960 assert!(content.contains("resolvedConfiguration"));
961 }
962
963 #[test]
968 fn test_resolve_cache_dir_default() {
969 let config = ResolveConfig {
970 project_root: PathBuf::from("/my/project"),
971 timeout_secs: 60,
972 cache_path: None,
973 };
974 let dir = resolve_cache_dir(&config);
975 assert_eq!(dir, PathBuf::from("/my/project/.sqry/classpath"));
976 }
977
978 #[test]
979 fn test_resolve_cache_dir_override() {
980 let config = ResolveConfig {
981 project_root: PathBuf::from("/my/project"),
982 timeout_secs: 60,
983 cache_path: Some(PathBuf::from("/custom/cache")),
984 };
985 let dir = resolve_cache_dir(&config);
986 assert_eq!(dir, PathBuf::from("/custom/cache"));
987 }
988
989 #[test]
994 fn test_parse_cp_line_valid() {
995 let result = parse_cp_line("app:com.google.guava:guava:33.0.0:/path/to/guava.jar");
996 assert!(result.is_some());
997 let parsed = result.unwrap();
998 assert_eq!(parsed.module_name, "app");
999 assert_eq!(
1000 parsed.entry.coordinates.as_deref(),
1001 Some("com.google.guava:guava:33.0.0")
1002 );
1003 assert_eq!(parsed.entry.jar_path, PathBuf::from("/path/to/guava.jar"));
1004 assert!(parsed.entry.is_direct);
1005 assert!(parsed.entry.source_jar.is_none());
1006 }
1007
1008 #[test]
1009 fn test_parse_cp_line_too_few_parts() {
1010 assert!(parse_cp_line("app:group:name").is_none());
1011 assert!(parse_cp_line("app:group:name:version").is_none());
1012 assert!(parse_cp_line("").is_none());
1013 }
1014
1015 #[test]
1016 fn test_parse_cp_line_empty_fields() {
1017 assert!(parse_cp_line(":group:name:version:/path").is_none());
1018 assert!(parse_cp_line("app::name:version:/path").is_none());
1019 assert!(parse_cp_line("app:group::version:/path").is_none());
1020 assert!(parse_cp_line("app:group:name::/path").is_none());
1021 assert!(parse_cp_line("app:group:name:version:").is_none());
1022 }
1023
1024 struct EnvGuard {
1031 key: String,
1032 original: Option<String>,
1033 }
1034
1035 impl EnvGuard {
1036 fn set(key: &str, value: &str) -> Self {
1037 let original = std::env::var(key).ok();
1038 unsafe {
1040 std::env::set_var(key, value);
1041 }
1042 Self {
1043 key: key.to_string(),
1044 original,
1045 }
1046 }
1047 }
1048
1049 impl Drop for EnvGuard {
1050 fn drop(&mut self) {
1051 unsafe {
1053 match &self.original {
1054 Some(val) => std::env::set_var(&self.key, val),
1055 None => std::env::remove_var(&self.key),
1056 }
1057 }
1058 }
1059 }
1060}