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> {
143 let wrapper_name = if cfg!(windows) {
144 "gradlew.bat"
145 } else {
146 "gradlew"
147 };
148
149 let wrapper_path = project_root.join(wrapper_name);
150 if wrapper_path.exists() {
151 Some(wrapper_path)
152 } else {
153 which_binary(if cfg!(windows) {
154 "gradle.bat"
155 } else {
156 "gradle"
157 })
158 }
159}
160
161fn is_project_local_gradle_wrapper(project_root: &Path, command: &Path) -> bool {
162 let wrapper_name = if cfg!(windows) {
163 "gradlew.bat"
164 } else {
165 "gradlew"
166 };
167 command == project_root.join(wrapper_name)
168}
169
170fn write_init_script() -> ClasspathResult<tempfile::NamedTempFile> {
172 use std::io::Write;
173
174 let mut file = tempfile::Builder::new()
175 .prefix("sqry-gradle-init-")
176 .suffix(".gradle")
177 .tempfile()
178 .map_err(|e| {
179 ClasspathError::ResolutionFailed(format!("Failed to create init script temp file: {e}"))
180 })?;
181
182 file.write_all(INIT_SCRIPT.as_bytes()).map_err(|e| {
183 ClasspathError::ResolutionFailed(format!("Failed to write init script: {e}"))
184 })?;
185
186 file.flush().map_err(|e| {
187 ClasspathError::ResolutionFailed(format!("Failed to flush init script: {e}"))
188 })?;
189
190 Ok(file)
191}
192
193fn execute_gradle(
195 wrapper: &Path,
196 init_script: &Path,
197 project_root: &Path,
198 timeout_secs: u64,
199) -> ClasspathResult<String> {
200 let mut child = Command::new(wrapper)
201 .args([
202 "--init-script",
203 &init_script.to_string_lossy(),
204 "sqryListClasspath",
205 "--quiet",
206 "--no-daemon",
207 ])
208 .current_dir(project_root)
209 .stdout(std::process::Stdio::piped())
210 .stderr(std::process::Stdio::piped())
211 .spawn()
212 .map_err(|e| {
213 ClasspathError::ResolutionFailed(format!(
214 "Failed to spawn Gradle wrapper {}: {e}",
215 wrapper.display()
216 ))
217 })?;
218
219 let timeout = Duration::from_secs(timeout_secs);
220 match child.wait_timeout(timeout) {
221 Ok(Some(status)) => {
222 if status.success() {
223 let stdout = child
224 .stdout
225 .take()
226 .map(|s| {
227 std::io::BufReader::new(s)
228 .lines()
229 .map_while(Result::ok)
230 .collect::<Vec<_>>()
231 .join("\n")
232 })
233 .unwrap_or_default();
234 Ok(stdout)
235 } else {
236 let stderr = child
237 .stderr
238 .take()
239 .map(|s| {
240 std::io::BufReader::new(s)
241 .lines()
242 .map_while(Result::ok)
243 .collect::<Vec<_>>()
244 .join("\n")
245 })
246 .unwrap_or_default();
247 Err(ClasspathError::ResolutionFailed(format!(
248 "Gradle exited with status {status}: {stderr}"
249 )))
250 }
251 }
252 Ok(None) => {
253 let _ = child.kill();
255 let _ = child.wait();
256 Err(ClasspathError::ResolutionFailed(format!(
257 "Gradle timed out after {timeout_secs}s"
258 )))
259 }
260 Err(e) => Err(ClasspathError::ResolutionFailed(format!(
261 "Failed to wait on Gradle process: {e}"
262 ))),
263 }
264}
265
266pub(crate) fn parse_gradle_output(output: &str) -> Vec<ResolvedClasspath> {
274 let mut modules: HashMap<(String, PathBuf), Vec<ClasspathEntry>> = HashMap::new();
275
276 for line in output.lines() {
277 let trimmed = line.trim();
278
279 if let Some(err_payload) = trimmed.strip_prefix(CP_ERR_PREFIX) {
280 warn!("Gradle resolution error: {err_payload}");
282 continue;
283 }
284
285 if let Some(payload) = trimmed.strip_prefix(CP_JSON_PREFIX)
286 && let Some(entry) = parse_cp_json_line(payload)
287 {
288 modules
289 .entry((entry.module_name, entry.module_root))
290 .or_default()
291 .push(entry.entry);
292 continue;
293 }
294
295 if let Some(payload) = trimmed.strip_prefix("SQRY_CP:")
296 && let Some(entry) = parse_cp_line(payload)
297 {
298 modules
299 .entry((entry.module_name, entry.module_root))
300 .or_default()
301 .push(entry.entry);
302 }
303 }
305
306 let mut result: Vec<ResolvedClasspath> = modules
307 .into_iter()
308 .map(|((module_name, module_root), entries)| ResolvedClasspath {
309 module_name,
310 module_root,
311 entries,
312 })
313 .collect();
314
315 result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
317 result
318}
319
320#[derive(Deserialize)]
321struct GradleClasspathJsonRecord {
322 module_name: String,
323 module_root: String,
324 group: String,
325 name: String,
326 version: String,
327 path: String,
328}
329
330struct ParsedGradleEntry {
331 module_name: String,
332 module_root: PathBuf,
333 entry: ClasspathEntry,
334}
335
336fn parse_cp_json_line(payload: &str) -> Option<ParsedGradleEntry> {
338 let record: GradleClasspathJsonRecord = serde_json::from_str(payload).ok()?;
339 if record.module_name.is_empty()
340 || record.module_root.is_empty()
341 || record.group.is_empty()
342 || record.name.is_empty()
343 || record.version.is_empty()
344 || record.path.is_empty()
345 {
346 return None;
347 }
348
349 Some(ParsedGradleEntry {
350 module_name: record.module_name,
351 module_root: PathBuf::from(record.module_root),
352 entry: ClasspathEntry {
353 jar_path: PathBuf::from(record.path),
354 coordinates: Some(format!(
355 "{}:{}:{}",
356 record.group, record.name, record.version
357 )),
358 is_direct: true,
359 source_jar: None,
360 },
361 })
362}
363
364fn parse_cp_line(payload: &str) -> Option<ParsedGradleEntry> {
372 let mut parts = payload.splitn(5, ':');
373
374 let module = parts.next()?;
375 let group = parts.next()?;
376 let name = parts.next()?;
377 let version = parts.next()?;
378 let path_str = parts.next()?;
379
380 if module.is_empty()
382 || group.is_empty()
383 || name.is_empty()
384 || version.is_empty()
385 || path_str.is_empty()
386 {
387 return None;
388 }
389
390 let coordinates = format!("{group}:{name}:{version}");
391 let jar_path = PathBuf::from(path_str);
392
393 Some(ParsedGradleEntry {
394 module_name: module.to_string(),
395 module_root: PathBuf::from(module),
396 entry: ClasspathEntry {
397 jar_path,
398 coordinates: Some(coordinates),
399 is_direct: true,
400 source_jar: None,
401 },
402 })
403}
404
405fn enrich_source_jars(classpaths: Vec<ResolvedClasspath>) -> Vec<ResolvedClasspath> {
411 classpaths
412 .into_iter()
413 .map(|mut cp| {
414 for entry in &mut cp.entries {
415 if let Some(source_jar) = find_source_jar(entry) {
416 entry.source_jar = Some(source_jar);
417 }
418 }
419 cp
420 })
421 .collect()
422}
423
424fn find_source_jar(entry: &ClasspathEntry) -> Option<PathBuf> {
426 let coords = entry.coordinates.as_ref()?;
427 let mut coord_parts = coords.splitn(3, ':');
428 let group = coord_parts.next()?;
429 let name = coord_parts.next()?;
430 let version = coord_parts.next()?;
431
432 let gradle_cache = gradle_cache_dir()?;
433 let module_dir = gradle_cache
434 .join("caches")
435 .join("modules-2")
436 .join("files-2.1")
437 .join(group)
438 .join(name)
439 .join(version);
440
441 if !module_dir.is_dir() {
442 return None;
443 }
444
445 let source_jar_name = format!("{name}-{version}-sources.jar");
446
447 let entries = std::fs::read_dir(&module_dir).ok()?;
450 for hash_dir_entry in entries.flatten() {
451 if hash_dir_entry.file_type().ok()?.is_dir() {
452 let candidate = hash_dir_entry.path().join(&source_jar_name);
453 if candidate.exists() {
454 return Some(candidate);
455 }
456 }
457 }
458
459 None
460}
461
462fn gradle_cache_dir() -> Option<PathBuf> {
467 if let Ok(gradle_home) = std::env::var("GRADLE_USER_HOME") {
468 let path = PathBuf::from(gradle_home);
469 if path.is_dir() {
470 return Some(path);
471 }
472 }
473
474 home_dir().map(|home| home.join(".gradle"))
475}
476
477fn home_dir() -> Option<PathBuf> {
479 #[cfg(unix)]
480 {
481 std::env::var_os("HOME").map(PathBuf::from)
482 }
483 #[cfg(windows)]
484 {
485 std::env::var_os("USERPROFILE").map(PathBuf::from)
486 }
487 #[cfg(not(any(unix, windows)))]
488 {
489 None
490 }
491}
492
493fn resolve_cache_dir(config: &ResolveConfig) -> PathBuf {
495 config
496 .cache_path
497 .clone()
498 .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
499}
500
501fn write_cache(cache_dir: &Path, classpaths: &[ResolvedClasspath]) -> ClasspathResult<()> {
503 std::fs::create_dir_all(cache_dir)?;
504
505 let cache_path = cache_dir.join(CACHE_FILENAME);
506 let json = serde_json::to_string_pretty(classpaths)
507 .map_err(|e| ClasspathError::CacheError(format!("Failed to serialize classpath: {e}")))?;
508
509 std::fs::write(&cache_path, json)?;
510
511 debug!("Wrote classpath cache to {}", cache_path.display());
512 Ok(())
513}
514
515fn read_cache(cache_dir: &Path) -> ClasspathResult<Vec<ResolvedClasspath>> {
518 let cache_path = cache_dir.join(CACHE_FILENAME);
519
520 if !cache_path.exists() {
521 warn!(
522 "No cached classpath found at {}; returning empty classpath",
523 cache_path.display()
524 );
525 return Ok(Vec::new());
526 }
527
528 let json = std::fs::read_to_string(&cache_path)?;
529 let classpaths: Vec<ResolvedClasspath> = serde_json::from_str(&json).map_err(|e| {
530 ClasspathError::CacheError(format!("Failed to deserialize classpath cache: {e}"))
531 })?;
532
533 info!(
534 "Loaded {} modules from classpath cache at {}",
535 classpaths.len(),
536 cache_path.display()
537 );
538
539 Ok(classpaths)
540}
541
542fn read_cache_or_error(
543 cache_dir: &Path,
544 live_error: &str,
545) -> ClasspathResult<Vec<ResolvedClasspath>> {
546 let cache_path = cache_dir.join(CACHE_FILENAME);
547 let classpaths = read_cache(cache_dir)?;
548 if classpaths.is_empty() {
549 return Err(ClasspathError::ResolutionFailed(format!(
550 "{live_error}. No cached classpath available at {}. Add a project wrapper, install Gradle, or use --classpath-file.",
551 cache_path.display()
552 )));
553 }
554 warn_if_cache_stale(cache_dir, &classpaths);
555 Ok(classpaths)
556}
557
558fn warn_if_cache_stale(cache_dir: &Path, classpaths: &[ResolvedClasspath]) {
559 if classpaths.is_empty() {
560 return;
561 }
562 let cache_path = cache_dir.join(CACHE_FILENAME);
563 let Ok(cache_meta) = std::fs::metadata(&cache_path) else {
564 return;
565 };
566 let Ok(cache_mtime) = cache_meta.modified() else {
567 return;
568 };
569
570 let mut roots = HashSet::new();
571 for cp in classpaths {
572 roots.insert(cp.module_root.as_path());
573 }
574
575 for root in roots {
576 for marker in [
577 "build.gradle",
578 "build.gradle.kts",
579 "settings.gradle",
580 "settings.gradle.kts",
581 "gradle.properties",
582 ] {
583 let marker_path = root.join(marker);
584 let Ok(meta) = std::fs::metadata(&marker_path) else {
585 continue;
586 };
587 let Ok(modified) = meta.modified() else {
588 continue;
589 };
590 if modified > cache_mtime {
591 warn!(
592 "Using cached Gradle classpath from {} even though {} is newer; cache may be stale",
593 cache_path.display(),
594 marker_path.display()
595 );
596 return;
597 }
598 }
599 }
600}
601
602fn which_binary(name: &str) -> Option<PathBuf> {
603 let path_var = std::env::var_os("PATH")?;
604 for dir in std::env::split_paths(&path_var) {
605 let candidate = dir.join(name);
606 if candidate.is_file() {
607 return Some(candidate);
608 }
609 }
610 None
611}
612
613trait WaitTimeout {
618 fn wait_timeout(
621 &mut self,
622 timeout: Duration,
623 ) -> std::io::Result<Option<std::process::ExitStatus>>;
624}
625
626impl WaitTimeout for std::process::Child {
627 fn wait_timeout(
628 &mut self,
629 timeout: Duration,
630 ) -> std::io::Result<Option<std::process::ExitStatus>> {
631 let start = std::time::Instant::now();
632 let poll_interval = Duration::from_millis(100);
633
634 loop {
635 if let Some(status) = self.try_wait()? {
636 return Ok(Some(status));
637 }
638 if start.elapsed() >= timeout {
639 return Ok(None);
640 }
641 std::thread::sleep(poll_interval);
642 }
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649 use tempfile::TempDir;
650
651 #[test]
656 fn test_parse_valid_output_single_module() {
657 let output = "\
658SQRY_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
659SQRY_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";
660
661 let result = parse_gradle_output(output);
662 assert_eq!(result.len(), 1);
663
664 let module = &result[0];
665 assert_eq!(module.module_name, "app");
666 assert_eq!(module.entries.len(), 2);
667
668 assert_eq!(
669 module.entries[0].coordinates.as_deref(),
670 Some("com.google.guava:guava:33.0.0")
671 );
672 assert_eq!(
673 module.entries[0].jar_path,
674 PathBuf::from(
675 "/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar"
676 )
677 );
678
679 assert_eq!(
680 module.entries[1].coordinates.as_deref(),
681 Some("org.slf4j:slf4j-api:2.0.9")
682 );
683 }
684
685 #[test]
686 fn test_parse_multi_module_output() {
687 let output = "\
688SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
689SQRY_CP:lib:org.apache.commons:commons-lang3:3.14.0:/path/to/commons-lang3.jar
690SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
691SQRY_CP:lib:com.fasterxml.jackson.core:jackson-core:2.16.0:/path/to/jackson-core.jar";
692
693 let result = parse_gradle_output(output);
694 assert_eq!(result.len(), 2);
695
696 let app = result.iter().find(|m| m.module_name == "app").unwrap();
697 assert_eq!(app.entries.len(), 2);
698
699 let lib = result.iter().find(|m| m.module_name == "lib").unwrap();
700 assert_eq!(lib.entries.len(), 2);
701 }
702
703 #[test]
704 fn test_parse_empty_output() {
705 let result = parse_gradle_output("");
706 assert!(result.is_empty());
707 }
708
709 #[test]
710 fn test_parse_output_with_noise() {
711 let output = "\
712Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip
713...........10%...........20%...........30%...........40%...........50%
714> Task :app:sqryListClasspath
715SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
716BUILD SUCCESSFUL in 5s
7171 actionable task: 1 executed";
718
719 let result = parse_gradle_output(output);
720 assert_eq!(result.len(), 1);
721 assert_eq!(result[0].entries.len(), 1);
722 assert_eq!(
723 result[0].entries[0].coordinates.as_deref(),
724 Some("com.google.guava:guava:33.0.0")
725 );
726 }
727
728 #[test]
729 fn test_parse_malformed_lines_skipped() {
730 let output = "\
731SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
732SQRY_CP:broken:only_three_parts
733SQRY_CP:::::/path/empty_fields
734SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
735SQRY_CP:";
736
737 let result = parse_gradle_output(output);
738 assert_eq!(result.len(), 1);
739 assert_eq!(
740 result[0].entries.len(),
741 2,
742 "Only valid lines should produce entries"
743 );
744 }
745
746 #[test]
747 fn test_parse_error_lines_logged() {
748 let output = "\
749SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
750SQRY_CP_ERR:lib:Could not resolve configuration 'compileClasspath'
751SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar";
752
753 let result = parse_gradle_output(output);
754 assert_eq!(result.len(), 1);
755 assert_eq!(result[0].entries.len(), 2);
756 }
758
759 #[test]
760 fn test_parse_windows_path_with_colon() {
761 let output =
764 "SQRY_CP:app:com.google.guava:guava:33.0.0:C:\\Users\\dev\\.gradle\\caches\\guava.jar";
765
766 let result = parse_gradle_output(output);
767 assert_eq!(result.len(), 1);
768 assert_eq!(
769 result[0].entries[0].jar_path,
770 PathBuf::from("C:\\Users\\dev\\.gradle\\caches\\guava.jar")
771 );
772 }
773
774 #[test]
779 fn test_source_jar_path_construction() {
780 let tmp = TempDir::new().unwrap();
781
782 let module_dir = tmp
784 .path()
785 .join("caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123");
786 std::fs::create_dir_all(&module_dir).unwrap();
787 let source_jar = module_dir.join("guava-33.0.0-sources.jar");
788 std::fs::write(&source_jar, b"fake jar").unwrap();
789
790 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
794
795 let entry = ClasspathEntry {
796 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
797 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
798 is_direct: true,
799 source_jar: None,
800 };
801
802 let found = find_source_jar(&entry);
803 assert_eq!(found, Some(source_jar));
804 }
805
806 #[test]
807 fn test_source_jar_not_found() {
808 let tmp = TempDir::new().unwrap();
809 let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
810
811 let entry = ClasspathEntry {
812 jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
813 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
814 is_direct: true,
815 source_jar: None,
816 };
817
818 let found = find_source_jar(&entry);
819 assert!(found.is_none());
820 }
821
822 #[test]
823 fn test_source_jar_no_coordinates() {
824 let entry = ClasspathEntry {
825 jar_path: PathBuf::from("/path/to/something.jar"),
826 coordinates: None,
827 is_direct: true,
828 source_jar: None,
829 };
830
831 let found = find_source_jar(&entry);
832 assert!(found.is_none());
833 }
834
835 #[test]
840 fn test_cache_roundtrip() {
841 let tmp = TempDir::new().unwrap();
842 let cache_dir = tmp.path().join("cache");
843
844 let classpaths = vec![
845 ResolvedClasspath {
846 module_name: "app".to_string(),
847 module_root: PathBuf::from("/repo/app"),
848 entries: vec![ClasspathEntry {
849 jar_path: PathBuf::from("/path/to/guava.jar"),
850 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
851 is_direct: true,
852 source_jar: None,
853 }],
854 },
855 ResolvedClasspath {
856 module_name: "lib".to_string(),
857 module_root: PathBuf::from("/repo/lib"),
858 entries: vec![ClasspathEntry {
859 jar_path: PathBuf::from("/path/to/commons.jar"),
860 coordinates: Some("org.apache.commons:commons-lang3:3.14.0".to_string()),
861 is_direct: true,
862 source_jar: Some(PathBuf::from("/path/to/commons-sources.jar")),
863 }],
864 },
865 ];
866
867 write_cache(&cache_dir, &classpaths).expect("cache write should succeed");
868
869 let loaded = read_cache(&cache_dir).expect("cache read should succeed");
870 assert_eq!(loaded.len(), 2);
871
872 let app = loaded.iter().find(|m| m.module_name == "app").unwrap();
873 assert_eq!(app.entries.len(), 1);
874 assert_eq!(
875 app.entries[0].coordinates.as_deref(),
876 Some("com.google.guava:guava:33.0.0")
877 );
878
879 let lib = loaded.iter().find(|m| m.module_name == "lib").unwrap();
880 assert_eq!(
881 lib.entries[0].source_jar,
882 Some(PathBuf::from("/path/to/commons-sources.jar"))
883 );
884 }
885
886 #[test]
887 fn test_cache_read_missing_returns_empty() {
888 let tmp = TempDir::new().unwrap();
889 let cache_dir = tmp.path().join("nonexistent");
890
891 let result = read_cache(&cache_dir).expect("should succeed with empty vec");
892 assert!(result.is_empty());
893 }
894
895 #[test]
900 fn test_missing_gradle_command_returns_none() {
901 let tmp = TempDir::new().unwrap();
902 let result = find_gradle_command(tmp.path());
903 assert!(result.is_none());
904 }
905
906 #[test]
907 fn test_gradle_wrapper_found() {
908 let tmp = TempDir::new().unwrap();
909 let wrapper_name = if cfg!(windows) {
910 "gradlew.bat"
911 } else {
912 "gradlew"
913 };
914 std::fs::write(tmp.path().join(wrapper_name), "#!/bin/sh\n").unwrap();
915
916 let result = find_gradle_command(tmp.path());
917 assert_eq!(result, Some(tmp.path().join(wrapper_name)));
918 }
919
920 #[test]
925 fn test_init_script_content() {
926 let file = write_init_script().expect("should create init script");
927 let content = std::fs::read_to_string(file.path()).unwrap();
928 assert!(content.contains("sqryListClasspath"));
929 assert!(content.contains("SQRY_CP_JSON:"));
930 assert!(content.contains("compileClasspath"));
931 assert!(content.contains("resolvedConfiguration"));
932 }
933
934 #[test]
939 fn test_resolve_cache_dir_default() {
940 let config = ResolveConfig {
941 project_root: PathBuf::from("/my/project"),
942 timeout_secs: 60,
943 cache_path: None,
944 };
945 let dir = resolve_cache_dir(&config);
946 assert_eq!(dir, PathBuf::from("/my/project/.sqry/classpath"));
947 }
948
949 #[test]
950 fn test_resolve_cache_dir_override() {
951 let config = ResolveConfig {
952 project_root: PathBuf::from("/my/project"),
953 timeout_secs: 60,
954 cache_path: Some(PathBuf::from("/custom/cache")),
955 };
956 let dir = resolve_cache_dir(&config);
957 assert_eq!(dir, PathBuf::from("/custom/cache"));
958 }
959
960 #[test]
965 fn test_parse_cp_line_valid() {
966 let result = parse_cp_line("app:com.google.guava:guava:33.0.0:/path/to/guava.jar");
967 assert!(result.is_some());
968 let parsed = result.unwrap();
969 assert_eq!(parsed.module_name, "app");
970 assert_eq!(
971 parsed.entry.coordinates.as_deref(),
972 Some("com.google.guava:guava:33.0.0")
973 );
974 assert_eq!(parsed.entry.jar_path, PathBuf::from("/path/to/guava.jar"));
975 assert!(parsed.entry.is_direct);
976 assert!(parsed.entry.source_jar.is_none());
977 }
978
979 #[test]
980 fn test_parse_cp_line_too_few_parts() {
981 assert!(parse_cp_line("app:group:name").is_none());
982 assert!(parse_cp_line("app:group:name:version").is_none());
983 assert!(parse_cp_line("").is_none());
984 }
985
986 #[test]
987 fn test_parse_cp_line_empty_fields() {
988 assert!(parse_cp_line(":group:name:version:/path").is_none());
989 assert!(parse_cp_line("app::name:version:/path").is_none());
990 assert!(parse_cp_line("app:group::version:/path").is_none());
991 assert!(parse_cp_line("app:group:name::/path").is_none());
992 assert!(parse_cp_line("app:group:name:version:").is_none());
993 }
994
995 struct EnvGuard {
1002 key: String,
1003 original: Option<String>,
1004 }
1005
1006 impl EnvGuard {
1007 fn set(key: &str, value: &str) -> Self {
1008 let original = std::env::var(key).ok();
1009 unsafe {
1011 std::env::set_var(key, value);
1012 }
1013 Self {
1014 key: key.to_string(),
1015 original,
1016 }
1017 }
1018 }
1019
1020 impl Drop for EnvGuard {
1021 fn drop(&mut self) {
1022 unsafe {
1024 match &self.original {
1025 Some(val) => std::env::set_var(&self.key, val),
1026 None => std::env::remove_var(&self.key),
1027 }
1028 }
1029 }
1030 }
1031}