Skip to main content

sqry_classpath/resolve/
gradle.rs

1//! Gradle classpath resolver.
2//!
3//! Extracts classpath JARs from Gradle projects by writing a temporary init script
4//! and executing `gradlew --init-script <script> sqryListClasspath`. Parses the
5//! structured output lines to build [`ResolvedClasspath`] entries per module.
6//!
7//! ## Strategy
8//!
9//! 1. Write a temporary init script that adds a `sqryListClasspath` task to all projects.
10//! 2. Locate `gradlew` (or `gradlew.bat` on Windows) in the project root.
11//! 3. Execute the wrapper with the init script. Timeout defaults to 60 seconds.
12//! 4. Parse `SQRY_CP:<module>:<group>:<name>:<version>:<path>` lines.
13//! 5. On failure or timeout, fall back to a cached `resolved-classpath.json`.
14//!
15//! ## Security
16//!
17//! Only the project's own Gradle wrapper is executed — never a system-wide `gradle`
18//! binary. This prevents supply-chain attacks via a rogue global installation.
19
20use 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
32/// The Groovy init script injected into the Gradle build.
33///
34/// Adds a `sqryListClasspath` task to every project that iterates resolved
35/// artifacts from `compileClasspath` and prints structured lines.
36const 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
54/// Output line prefix for successful classpath entries.
55const CP_PREFIX: &str = "SQRY_CP:";
56
57/// Output line prefix for per-module resolution errors.
58const CP_ERR_PREFIX: &str = "SQRY_CP_ERR:";
59
60/// Cache filename written inside `.sqry/classpath/`.
61const CACHE_FILENAME: &str = "resolved-classpath.json";
62
63/// Resolve classpath for a Gradle project.
64///
65/// Writes a temporary init script, executes `gradlew --init-script <script>
66/// sqryListClasspath`, and parses the output for JAR paths. On failure or
67/// timeout, falls back to a previously cached classpath if available.
68///
69/// Only the project-local `gradlew` wrapper is used — never system `gradle`.
70#[allow(clippy::missing_errors_doc)] // Internal helper
71pub 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    // Write the init script to a temp file that will be cleaned up on drop.
76    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    // Build and execute the Gradle command.
82    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            // Enrich with source JAR discovery.
93            let classpaths = enrich_source_jars(classpaths);
94
95            // Cache the result for future fallback.
96            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
112/// Locate the Gradle wrapper script in the project root.
113///
114/// On Windows, looks for `gradlew.bat`; on Unix, `gradlew`.
115fn 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
134/// Write the init script to a temporary file.
135fn 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
157/// Execute the Gradle wrapper with the init script and return stdout.
158fn 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            // Timeout — kill the process.
218            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
230/// Parse structured output lines from the Gradle init script.
231///
232/// Expected format: `SQRY_CP:<module>:<group>:<name>:<version>:<path>`
233///
234/// Lines that do not match this format are silently skipped. Error lines
235/// (`SQRY_CP_ERR:`) are logged as warnings.
236pub(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            // Log error lines from Gradle but don't treat them as fatal.
244            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        // All other lines are silently ignored (Gradle progress, warnings, etc.).
254    }
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    // Sort by module name for deterministic output.
265    result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
266    result
267}
268
269/// Parse a single classpath payload after stripping the `SQRY_CP:` prefix.
270///
271/// Expected: `<module>:<group>:<name>:<version>:<path>`
272///
273/// The path itself may contain colons (e.g., Windows drive letters like `C:\...`),
274/// so we split into exactly 5 parts, where the last part captures everything
275/// after the 4th colon.
276fn 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    // Validate that we have non-empty components.
286    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
309/// Enrich classpath entries with source JAR paths by probing the Gradle cache.
310///
311/// For each entry with Maven coordinates, looks for a `-sources.jar` in the
312/// standard Gradle module cache layout:
313/// `~/.gradle/caches/modules-2/files-2.1/<group>/<name>/<version>/`
314fn 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
328/// Attempt to find a source JAR for a classpath entry in the Gradle cache.
329fn 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    // The Gradle cache stores files under hash subdirectories, so we need to
352    // walk one level of hash dirs.
353    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
366/// Return the Gradle user home directory.
367///
368/// Checks `GRADLE_USER_HOME` environment variable first, then falls back to
369/// `~/.gradle`.
370fn 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
381/// Portable home directory lookup (avoids pulling in the `dirs` crate).
382fn 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
397/// Determine the cache directory for resolved classpath data.
398fn 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
405/// Write resolved classpaths to the cache directory as JSON.
406fn 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
419/// Read previously cached classpath data. Returns an empty vec with a warning
420/// if no cache exists.
421fn 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
446/// Extension trait for [`std::process::Child`] providing timeout-aware waiting.
447///
448/// Uses a polling loop with short sleeps rather than platform-specific APIs,
449/// trading a small amount of latency for portability.
450trait WaitTimeout {
451    /// Wait for the child process to exit, returning `Ok(None)` if the timeout
452    /// expires before the process finishes.
453    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    // -----------------------------------------------------------------------
485    // parse_gradle_output tests
486    // -----------------------------------------------------------------------
487
488    #[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        // Error lines are logged but don't produce entries.
590    }
591
592    #[test]
593    fn test_parse_windows_path_with_colon() {
594        // The path contains a colon from the drive letter — the parser must
595        // handle this by splitting into at most 5 parts.
596        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    // -----------------------------------------------------------------------
608    // source JAR path construction tests
609    // -----------------------------------------------------------------------
610
611    #[test]
612    fn test_source_jar_path_construction() {
613        let tmp = TempDir::new().unwrap();
614
615        // Simulate a Gradle cache structure.
616        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        // Set GRADLE_USER_HOME so `gradle_cache_dir()` finds our temp dir.
624        // Safety: tests are run with --test-threads=1 for env var isolation,
625        // or this test is self-contained enough that the RAII guard suffices.
626        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    // -----------------------------------------------------------------------
669    // Cache roundtrip tests
670    // -----------------------------------------------------------------------
671
672    #[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    // -----------------------------------------------------------------------
727    // gradlew detection tests
728    // -----------------------------------------------------------------------
729
730    #[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    // -----------------------------------------------------------------------
760    // init script writing test
761    // -----------------------------------------------------------------------
762
763    #[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    // -----------------------------------------------------------------------
774    // resolve_cache_dir tests
775    // -----------------------------------------------------------------------
776
777    #[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    // -----------------------------------------------------------------------
800    // parse_cp_line unit tests
801    // -----------------------------------------------------------------------
802
803    #[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    // -----------------------------------------------------------------------
835    // Helper: environment variable guard for tests
836    // -----------------------------------------------------------------------
837
838    /// RAII guard that sets an environment variable and restores the original
839    /// value when dropped.
840    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            // Safety: test-only, scoped via RAII guard.
849            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            // Safety: test-only, restoring original env state.
862            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}