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, or
11//!    fall back to installed `gradle`.
12//! 3. Execute the selected Gradle command with the init script. Timeout defaults
13//!    to 60 seconds.
14//! 4. Parse `SQRY_CP:<module>:<group>:<name>:<version>:<path>` lines.
15//! 5. On failure or timeout, fall back to a cached `resolved-classpath.json`.
16//!
17//! ## Security
18//!
19//! Prefer the project's own Gradle wrapper. If the wrapper is absent, sqry can
20//! fall back to installed `gradle`; that path should be treated as less
21//! reproducible and is logged explicitly.
22
23use 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
36/// The Groovy init script injected into the Gradle build.
37///
38/// Adds a `sqryListClasspath` task to every project that iterates resolved
39/// artifacts from `compileClasspath` and prints structured lines.
40const 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
67/// Output line prefix for successful classpath entries.
68const CP_JSON_PREFIX: &str = "SQRY_CP_JSON:";
69
70/// Output line prefix for per-module resolution errors.
71const CP_ERR_PREFIX: &str = "SQRY_CP_ERR:";
72
73/// Cache filename written inside `.sqry/classpath/`.
74const CACHE_FILENAME: &str = "resolved-classpath.json";
75
76/// Resolve classpath for a Gradle project.
77///
78/// Writes a temporary init script, executes `gradlew --init-script <script>
79/// sqryListClasspath`, and parses the output for JAR paths. On failure or
80/// timeout, falls back to a previously cached classpath if available.
81///
82/// Prefer the project-local `gradlew` wrapper and fall back to installed
83/// `gradle` only when the wrapper is absent.
84#[allow(clippy::missing_errors_doc)] // Internal helper
85pub 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    // Write the init script to a temp file that will be cleaned up on drop.
105    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    // Build and execute the Gradle command.
111    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            // Enrich with source JAR discovery.
122            let classpaths = enrich_source_jars(classpaths);
123
124            // Cache the result for future fallback.
125            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
139/// Locate the Gradle command to use.
140///
141/// Prefers the project-local wrapper and falls back to installed `gradle`.
142fn 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
170/// Write the init script to a temporary file.
171fn 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
193/// Execute the Gradle wrapper with the init script and return stdout.
194fn 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            // Timeout — kill the process.
254            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
266/// Parse structured output lines from the Gradle init script.
267///
268/// Preferred format: `SQRY_CP_JSON:{...json...}`
269/// Legacy format: `SQRY_CP:<module>:<group>:<name>:<version>:<path>`
270///
271/// Lines that do not match this format are silently skipped. Error lines
272/// (`SQRY_CP_ERR:`) are logged as warnings.
273pub(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            // Log error lines from Gradle but don't treat them as fatal.
281            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        // All other lines are silently ignored (Gradle progress, warnings, etc.).
304    }
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    // Sort by module name for deterministic output.
316    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
336/// Parse a single JSON classpath payload.
337fn 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
364/// Parse a single classpath payload after stripping the legacy `SQRY_CP:` prefix.
365///
366/// Expected: `<module>:<group>:<name>:<version>:<path>`
367///
368/// The path itself may contain colons (e.g., Windows drive letters like `C:\...`),
369/// so we split into exactly 5 parts, where the last part captures everything
370/// after the 4th colon.
371fn 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    // Validate that we have non-empty components.
381    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
405/// Enrich classpath entries with source JAR paths by probing the Gradle cache.
406///
407/// For each entry with Maven coordinates, looks for a `-sources.jar` in the
408/// standard Gradle module cache layout:
409/// `~/.gradle/caches/modules-2/files-2.1/<group>/<name>/<version>/`
410fn 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
424/// Attempt to find a source JAR for a classpath entry in the Gradle cache.
425fn 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    // The Gradle cache stores files under hash subdirectories, so we need to
448    // walk one level of hash dirs.
449    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
462/// Return the Gradle user home directory.
463///
464/// Checks `GRADLE_USER_HOME` environment variable first, then falls back to
465/// `~/.gradle`.
466fn 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
477/// Portable home directory lookup (avoids pulling in the `dirs` crate).
478fn 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
493/// Determine the cache directory for resolved classpath data.
494fn 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
501/// Write resolved classpaths to the cache directory as JSON.
502fn 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
515/// Read previously cached classpath data. Returns an empty vec with a warning
516/// if no cache exists.
517fn 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
613/// Extension trait for [`std::process::Child`] providing timeout-aware waiting.
614///
615/// Uses a polling loop with short sleeps rather than platform-specific APIs,
616/// trading a small amount of latency for portability.
617trait WaitTimeout {
618    /// Wait for the child process to exit, returning `Ok(None)` if the timeout
619    /// expires before the process finishes.
620    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    // -----------------------------------------------------------------------
652    // parse_gradle_output tests
653    // -----------------------------------------------------------------------
654
655    #[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        // Error lines are logged but don't produce entries.
757    }
758
759    #[test]
760    fn test_parse_windows_path_with_colon() {
761        // The path contains a colon from the drive letter — the parser must
762        // handle this by splitting into at most 5 parts.
763        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    // -----------------------------------------------------------------------
775    // source JAR path construction tests
776    // -----------------------------------------------------------------------
777
778    #[test]
779    fn test_source_jar_path_construction() {
780        let tmp = TempDir::new().unwrap();
781
782        // Simulate a Gradle cache structure.
783        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        // Set GRADLE_USER_HOME so `gradle_cache_dir()` finds our temp dir.
791        // Safety: tests are run with --test-threads=1 for env var isolation,
792        // or this test is self-contained enough that the RAII guard suffices.
793        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    // -----------------------------------------------------------------------
836    // Cache roundtrip tests
837    // -----------------------------------------------------------------------
838
839    #[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    // -----------------------------------------------------------------------
896    // gradle command detection tests
897    // -----------------------------------------------------------------------
898
899    #[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    // -----------------------------------------------------------------------
921    // init script writing test
922    // -----------------------------------------------------------------------
923
924    #[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    // -----------------------------------------------------------------------
935    // resolve_cache_dir tests
936    // -----------------------------------------------------------------------
937
938    #[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    // -----------------------------------------------------------------------
961    // parse_cp_line unit tests
962    // -----------------------------------------------------------------------
963
964    #[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    // -----------------------------------------------------------------------
996    // Helper: environment variable guard for tests
997    // -----------------------------------------------------------------------
998
999    /// RAII guard that sets an environment variable and restores the original
1000    /// value when dropped.
1001    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            // Safety: test-only, scoped via RAII guard.
1010            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            // Safety: test-only, restoring original env state.
1023            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}