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`
142/// resolved via the process `PATH` environment variable.
143fn find_gradle_command(project_root: &Path) -> Option<PathBuf> {
144    find_gradle_command_with_path(project_root, std::env::var_os("PATH").as_deref())
145}
146
147/// Locate the Gradle command using an explicit `PATH` value.
148///
149/// Factored out of [`find_gradle_command`] so tests can inject a deterministic
150/// `PATH` (typically `None` or an empty path-list pointing at an isolated
151/// tempdir) instead of relying on the host's installed `gradle`. Production
152/// callers should always go through [`find_gradle_command`], which sources
153/// `PATH` from the process environment.
154fn find_gradle_command_with_path(
155    project_root: &Path,
156    path_var: Option<&std::ffi::OsStr>,
157) -> Option<PathBuf> {
158    let wrapper_name = if cfg!(windows) {
159        "gradlew.bat"
160    } else {
161        "gradlew"
162    };
163
164    let wrapper_path = project_root.join(wrapper_name);
165    if wrapper_path.exists() {
166        Some(wrapper_path)
167    } else {
168        which_binary_in(
169            if cfg!(windows) {
170                "gradle.bat"
171            } else {
172                "gradle"
173            },
174            path_var,
175        )
176    }
177}
178
179fn is_project_local_gradle_wrapper(project_root: &Path, command: &Path) -> bool {
180    let wrapper_name = if cfg!(windows) {
181        "gradlew.bat"
182    } else {
183        "gradlew"
184    };
185    command == project_root.join(wrapper_name)
186}
187
188/// Write the init script to a temporary file.
189fn write_init_script() -> ClasspathResult<tempfile::NamedTempFile> {
190    use std::io::Write;
191
192    let mut file = tempfile::Builder::new()
193        .prefix("sqry-gradle-init-")
194        .suffix(".gradle")
195        .tempfile()
196        .map_err(|e| {
197            ClasspathError::ResolutionFailed(format!("Failed to create init script temp file: {e}"))
198        })?;
199
200    file.write_all(INIT_SCRIPT.as_bytes()).map_err(|e| {
201        ClasspathError::ResolutionFailed(format!("Failed to write init script: {e}"))
202    })?;
203
204    file.flush().map_err(|e| {
205        ClasspathError::ResolutionFailed(format!("Failed to flush init script: {e}"))
206    })?;
207
208    Ok(file)
209}
210
211/// Execute the Gradle wrapper with the init script and return stdout.
212fn execute_gradle(
213    wrapper: &Path,
214    init_script: &Path,
215    project_root: &Path,
216    timeout_secs: u64,
217) -> ClasspathResult<String> {
218    let mut child = Command::new(wrapper)
219        .args([
220            "--init-script",
221            &init_script.to_string_lossy(),
222            "sqryListClasspath",
223            "--quiet",
224            "--no-daemon",
225        ])
226        .current_dir(project_root)
227        .stdout(std::process::Stdio::piped())
228        .stderr(std::process::Stdio::piped())
229        .spawn()
230        .map_err(|e| {
231            ClasspathError::ResolutionFailed(format!(
232                "Failed to spawn Gradle wrapper {}: {e}",
233                wrapper.display()
234            ))
235        })?;
236
237    let timeout = Duration::from_secs(timeout_secs);
238    match child.wait_timeout(timeout) {
239        Ok(Some(status)) => {
240            if status.success() {
241                let stdout = child
242                    .stdout
243                    .take()
244                    .map(|s| {
245                        std::io::BufReader::new(s)
246                            .lines()
247                            .map_while(Result::ok)
248                            .collect::<Vec<_>>()
249                            .join("\n")
250                    })
251                    .unwrap_or_default();
252                Ok(stdout)
253            } else {
254                let stderr = child
255                    .stderr
256                    .take()
257                    .map(|s| {
258                        std::io::BufReader::new(s)
259                            .lines()
260                            .map_while(Result::ok)
261                            .collect::<Vec<_>>()
262                            .join("\n")
263                    })
264                    .unwrap_or_default();
265                Err(ClasspathError::ResolutionFailed(format!(
266                    "Gradle exited with status {status}: {stderr}"
267                )))
268            }
269        }
270        Ok(None) => {
271            // Timeout — kill the process.
272            let _ = child.kill();
273            let _ = child.wait();
274            Err(ClasspathError::ResolutionFailed(format!(
275                "Gradle timed out after {timeout_secs}s"
276            )))
277        }
278        Err(e) => Err(ClasspathError::ResolutionFailed(format!(
279            "Failed to wait on Gradle process: {e}"
280        ))),
281    }
282}
283
284/// Parse structured output lines from the Gradle init script.
285///
286/// Preferred format: `SQRY_CP_JSON:{...json...}`
287/// Legacy format: `SQRY_CP:<module>:<group>:<name>:<version>:<path>`
288///
289/// Lines that do not match this format are silently skipped. Error lines
290/// (`SQRY_CP_ERR:`) are logged as warnings.
291pub(crate) fn parse_gradle_output(output: &str) -> Vec<ResolvedClasspath> {
292    let mut modules: HashMap<(String, PathBuf), Vec<ClasspathEntry>> = HashMap::new();
293
294    for line in output.lines() {
295        let trimmed = line.trim();
296
297        if let Some(err_payload) = trimmed.strip_prefix(CP_ERR_PREFIX) {
298            // Log error lines from Gradle but don't treat them as fatal.
299            warn!("Gradle resolution error: {err_payload}");
300            continue;
301        }
302
303        if let Some(payload) = trimmed.strip_prefix(CP_JSON_PREFIX)
304            && let Some(entry) = parse_cp_json_line(payload)
305        {
306            modules
307                .entry((entry.module_name, entry.module_root))
308                .or_default()
309                .push(entry.entry);
310            continue;
311        }
312
313        if let Some(payload) = trimmed.strip_prefix("SQRY_CP:")
314            && let Some(entry) = parse_cp_line(payload)
315        {
316            modules
317                .entry((entry.module_name, entry.module_root))
318                .or_default()
319                .push(entry.entry);
320        }
321        // All other lines are silently ignored (Gradle progress, warnings, etc.).
322    }
323
324    let mut result: Vec<ResolvedClasspath> = modules
325        .into_iter()
326        .map(|((module_name, module_root), entries)| ResolvedClasspath {
327            module_name,
328            module_root,
329            entries,
330        })
331        .collect();
332
333    // Sort by module name for deterministic output.
334    result.sort_by(|a, b| a.module_name.cmp(&b.module_name));
335    result
336}
337
338#[derive(Deserialize)]
339struct GradleClasspathJsonRecord {
340    module_name: String,
341    module_root: String,
342    group: String,
343    name: String,
344    version: String,
345    path: String,
346}
347
348struct ParsedGradleEntry {
349    module_name: String,
350    module_root: PathBuf,
351    entry: ClasspathEntry,
352}
353
354/// Parse a single JSON classpath payload.
355fn parse_cp_json_line(payload: &str) -> Option<ParsedGradleEntry> {
356    let record: GradleClasspathJsonRecord = serde_json::from_str(payload).ok()?;
357    if record.module_name.is_empty()
358        || record.module_root.is_empty()
359        || record.group.is_empty()
360        || record.name.is_empty()
361        || record.version.is_empty()
362        || record.path.is_empty()
363    {
364        return None;
365    }
366
367    Some(ParsedGradleEntry {
368        module_name: record.module_name,
369        module_root: PathBuf::from(record.module_root),
370        entry: ClasspathEntry {
371            jar_path: PathBuf::from(record.path),
372            coordinates: Some(format!(
373                "{}:{}:{}",
374                record.group, record.name, record.version
375            )),
376            is_direct: true,
377            source_jar: None,
378        },
379    })
380}
381
382/// Parse a single classpath payload after stripping the legacy `SQRY_CP:` prefix.
383///
384/// Expected: `<module>:<group>:<name>:<version>:<path>`
385///
386/// The path itself may contain colons (e.g., Windows drive letters like `C:\...`),
387/// so we split into exactly 5 parts, where the last part captures everything
388/// after the 4th colon.
389fn parse_cp_line(payload: &str) -> Option<ParsedGradleEntry> {
390    let mut parts = payload.splitn(5, ':');
391
392    let module = parts.next()?;
393    let group = parts.next()?;
394    let name = parts.next()?;
395    let version = parts.next()?;
396    let path_str = parts.next()?;
397
398    // Validate that we have non-empty components.
399    if module.is_empty()
400        || group.is_empty()
401        || name.is_empty()
402        || version.is_empty()
403        || path_str.is_empty()
404    {
405        return None;
406    }
407
408    let coordinates = format!("{group}:{name}:{version}");
409    let jar_path = PathBuf::from(path_str);
410
411    Some(ParsedGradleEntry {
412        module_name: module.to_string(),
413        module_root: PathBuf::from(module),
414        entry: ClasspathEntry {
415            jar_path,
416            coordinates: Some(coordinates),
417            is_direct: true,
418            source_jar: None,
419        },
420    })
421}
422
423/// Enrich classpath entries with source JAR paths by probing the Gradle cache.
424///
425/// For each entry with Maven coordinates, looks for a `-sources.jar` in the
426/// standard Gradle module cache layout:
427/// `~/.gradle/caches/modules-2/files-2.1/<group>/<name>/<version>/`
428fn enrich_source_jars(classpaths: Vec<ResolvedClasspath>) -> Vec<ResolvedClasspath> {
429    classpaths
430        .into_iter()
431        .map(|mut cp| {
432            for entry in &mut cp.entries {
433                if let Some(source_jar) = find_source_jar(entry) {
434                    entry.source_jar = Some(source_jar);
435                }
436            }
437            cp
438        })
439        .collect()
440}
441
442/// Attempt to find a source JAR for a classpath entry in the Gradle cache.
443fn find_source_jar(entry: &ClasspathEntry) -> Option<PathBuf> {
444    let coords = entry.coordinates.as_ref()?;
445    let mut coord_parts = coords.splitn(3, ':');
446    let group = coord_parts.next()?;
447    let name = coord_parts.next()?;
448    let version = coord_parts.next()?;
449
450    let gradle_cache = gradle_cache_dir()?;
451    let module_dir = gradle_cache
452        .join("caches")
453        .join("modules-2")
454        .join("files-2.1")
455        .join(group)
456        .join(name)
457        .join(version);
458
459    if !module_dir.is_dir() {
460        return None;
461    }
462
463    let source_jar_name = format!("{name}-{version}-sources.jar");
464
465    // The Gradle cache stores files under hash subdirectories, so we need to
466    // walk one level of hash dirs.
467    let entries = std::fs::read_dir(&module_dir).ok()?;
468    for hash_dir_entry in entries.flatten() {
469        if hash_dir_entry.file_type().ok()?.is_dir() {
470            let candidate = hash_dir_entry.path().join(&source_jar_name);
471            if candidate.exists() {
472                return Some(candidate);
473            }
474        }
475    }
476
477    None
478}
479
480/// Return the Gradle user home directory.
481///
482/// Checks `GRADLE_USER_HOME` environment variable first, then falls back to
483/// `~/.gradle`.
484fn gradle_cache_dir() -> Option<PathBuf> {
485    if let Ok(gradle_home) = std::env::var("GRADLE_USER_HOME") {
486        let path = PathBuf::from(gradle_home);
487        if path.is_dir() {
488            return Some(path);
489        }
490    }
491
492    home_dir().map(|home| home.join(".gradle"))
493}
494
495/// Portable home directory lookup (avoids pulling in the `dirs` crate).
496fn home_dir() -> Option<PathBuf> {
497    #[cfg(unix)]
498    {
499        std::env::var_os("HOME").map(PathBuf::from)
500    }
501    #[cfg(windows)]
502    {
503        std::env::var_os("USERPROFILE").map(PathBuf::from)
504    }
505    #[cfg(not(any(unix, windows)))]
506    {
507        None
508    }
509}
510
511/// Determine the cache directory for resolved classpath data.
512fn resolve_cache_dir(config: &ResolveConfig) -> PathBuf {
513    config
514        .cache_path
515        .clone()
516        .unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
517}
518
519/// Write resolved classpaths to the cache directory as JSON.
520fn write_cache(cache_dir: &Path, classpaths: &[ResolvedClasspath]) -> ClasspathResult<()> {
521    std::fs::create_dir_all(cache_dir)?;
522
523    let cache_path = cache_dir.join(CACHE_FILENAME);
524    let json = serde_json::to_string_pretty(classpaths)
525        .map_err(|e| ClasspathError::CacheError(format!("Failed to serialize classpath: {e}")))?;
526
527    std::fs::write(&cache_path, json)?;
528
529    debug!("Wrote classpath cache to {}", cache_path.display());
530    Ok(())
531}
532
533/// Read previously cached classpath data. Returns an empty vec with a warning
534/// if no cache exists.
535fn read_cache(cache_dir: &Path) -> ClasspathResult<Vec<ResolvedClasspath>> {
536    let cache_path = cache_dir.join(CACHE_FILENAME);
537
538    if !cache_path.exists() {
539        warn!(
540            "No cached classpath found at {}; returning empty classpath",
541            cache_path.display()
542        );
543        return Ok(Vec::new());
544    }
545
546    let json = std::fs::read_to_string(&cache_path)?;
547    let classpaths: Vec<ResolvedClasspath> = serde_json::from_str(&json).map_err(|e| {
548        ClasspathError::CacheError(format!("Failed to deserialize classpath cache: {e}"))
549    })?;
550
551    info!(
552        "Loaded {} modules from classpath cache at {}",
553        classpaths.len(),
554        cache_path.display()
555    );
556
557    Ok(classpaths)
558}
559
560fn read_cache_or_error(
561    cache_dir: &Path,
562    live_error: &str,
563) -> ClasspathResult<Vec<ResolvedClasspath>> {
564    let cache_path = cache_dir.join(CACHE_FILENAME);
565    let classpaths = read_cache(cache_dir)?;
566    if classpaths.is_empty() {
567        return Err(ClasspathError::ResolutionFailed(format!(
568            "{live_error}. No cached classpath available at {}. Add a project wrapper, install Gradle, or use --classpath-file.",
569            cache_path.display()
570        )));
571    }
572    warn_if_cache_stale(cache_dir, &classpaths);
573    Ok(classpaths)
574}
575
576fn warn_if_cache_stale(cache_dir: &Path, classpaths: &[ResolvedClasspath]) {
577    if classpaths.is_empty() {
578        return;
579    }
580    let cache_path = cache_dir.join(CACHE_FILENAME);
581    let Ok(cache_meta) = std::fs::metadata(&cache_path) else {
582        return;
583    };
584    let Ok(cache_mtime) = cache_meta.modified() else {
585        return;
586    };
587
588    let mut roots = HashSet::new();
589    for cp in classpaths {
590        roots.insert(cp.module_root.as_path());
591    }
592
593    for root in roots {
594        for marker in [
595            "build.gradle",
596            "build.gradle.kts",
597            "settings.gradle",
598            "settings.gradle.kts",
599            "gradle.properties",
600        ] {
601            let marker_path = root.join(marker);
602            let Ok(meta) = std::fs::metadata(&marker_path) else {
603                continue;
604            };
605            let Ok(modified) = meta.modified() else {
606                continue;
607            };
608            if modified > cache_mtime {
609                warn!(
610                    "Using cached Gradle classpath from {} even though {} is newer; cache may be stale",
611                    cache_path.display(),
612                    marker_path.display()
613                );
614                return;
615            }
616        }
617    }
618}
619
620/// Search for `name` inside `path_var`, treating `None` as an empty `PATH`
621/// (no candidates). Used by [`find_gradle_command_with_path`] for deterministic
622/// test injection.
623fn which_binary_in(name: &str, path_var: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
624    let path_var = path_var?;
625    for dir in std::env::split_paths(path_var) {
626        let candidate = dir.join(name);
627        if candidate.is_file() {
628            return Some(candidate);
629        }
630    }
631    None
632}
633
634/// Extension trait for [`std::process::Child`] providing timeout-aware waiting.
635///
636/// Uses a polling loop with short sleeps rather than platform-specific APIs,
637/// trading a small amount of latency for portability.
638trait WaitTimeout {
639    /// Wait for the child process to exit, returning `Ok(None)` if the timeout
640    /// expires before the process finishes.
641    fn wait_timeout(
642        &mut self,
643        timeout: Duration,
644    ) -> std::io::Result<Option<std::process::ExitStatus>>;
645}
646
647impl WaitTimeout for std::process::Child {
648    fn wait_timeout(
649        &mut self,
650        timeout: Duration,
651    ) -> std::io::Result<Option<std::process::ExitStatus>> {
652        let start = std::time::Instant::now();
653        let poll_interval = Duration::from_millis(100);
654
655        loop {
656            if let Some(status) = self.try_wait()? {
657                return Ok(Some(status));
658            }
659            if start.elapsed() >= timeout {
660                return Ok(None);
661            }
662            std::thread::sleep(poll_interval);
663        }
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use serial_test::serial;
671    use tempfile::TempDir;
672
673    // -----------------------------------------------------------------------
674    // parse_gradle_output tests
675    // -----------------------------------------------------------------------
676
677    #[test]
678    fn test_parse_valid_output_single_module() {
679        let output = "\
680SQRY_CP:app:com.google.guava:guava:33.0.0:/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar
681SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/home/user/.gradle/caches/modules-2/files-2.1/org.slf4j/slf4j-api/2.0.9/def456/slf4j-api-2.0.9.jar";
682
683        let result = parse_gradle_output(output);
684        assert_eq!(result.len(), 1);
685
686        let module = &result[0];
687        assert_eq!(module.module_name, "app");
688        assert_eq!(module.entries.len(), 2);
689
690        assert_eq!(
691            module.entries[0].coordinates.as_deref(),
692            Some("com.google.guava:guava:33.0.0")
693        );
694        assert_eq!(
695            module.entries[0].jar_path,
696            PathBuf::from(
697                "/home/user/.gradle/caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123/guava-33.0.0.jar"
698            )
699        );
700
701        assert_eq!(
702            module.entries[1].coordinates.as_deref(),
703            Some("org.slf4j:slf4j-api:2.0.9")
704        );
705    }
706
707    #[test]
708    fn test_parse_multi_module_output() {
709        let output = "\
710SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
711SQRY_CP:lib:org.apache.commons:commons-lang3:3.14.0:/path/to/commons-lang3.jar
712SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
713SQRY_CP:lib:com.fasterxml.jackson.core:jackson-core:2.16.0:/path/to/jackson-core.jar";
714
715        let result = parse_gradle_output(output);
716        assert_eq!(result.len(), 2);
717
718        let app = result.iter().find(|m| m.module_name == "app").unwrap();
719        assert_eq!(app.entries.len(), 2);
720
721        let lib = result.iter().find(|m| m.module_name == "lib").unwrap();
722        assert_eq!(lib.entries.len(), 2);
723    }
724
725    #[test]
726    fn test_parse_empty_output() {
727        let result = parse_gradle_output("");
728        assert!(result.is_empty());
729    }
730
731    #[test]
732    fn test_parse_output_with_noise() {
733        let output = "\
734Downloading https://services.gradle.org/distributions/gradle-8.5-bin.zip
735...........10%...........20%...........30%...........40%...........50%
736> Task :app:sqryListClasspath
737SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
738BUILD SUCCESSFUL in 5s
7391 actionable task: 1 executed";
740
741        let result = parse_gradle_output(output);
742        assert_eq!(result.len(), 1);
743        assert_eq!(result[0].entries.len(), 1);
744        assert_eq!(
745            result[0].entries[0].coordinates.as_deref(),
746            Some("com.google.guava:guava:33.0.0")
747        );
748    }
749
750    #[test]
751    fn test_parse_malformed_lines_skipped() {
752        let output = "\
753SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
754SQRY_CP:broken:only_three_parts
755SQRY_CP:::::/path/empty_fields
756SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar
757SQRY_CP:";
758
759        let result = parse_gradle_output(output);
760        assert_eq!(result.len(), 1);
761        assert_eq!(
762            result[0].entries.len(),
763            2,
764            "Only valid lines should produce entries"
765        );
766    }
767
768    #[test]
769    fn test_parse_error_lines_logged() {
770        let output = "\
771SQRY_CP:app:com.google.guava:guava:33.0.0:/path/to/guava.jar
772SQRY_CP_ERR:lib:Could not resolve configuration 'compileClasspath'
773SQRY_CP:app:org.slf4j:slf4j-api:2.0.9:/path/to/slf4j-api.jar";
774
775        let result = parse_gradle_output(output);
776        assert_eq!(result.len(), 1);
777        assert_eq!(result[0].entries.len(), 2);
778        // Error lines are logged but don't produce entries.
779    }
780
781    #[test]
782    fn test_parse_windows_path_with_colon() {
783        // The path contains a colon from the drive letter — the parser must
784        // handle this by splitting into at most 5 parts.
785        let output =
786            "SQRY_CP:app:com.google.guava:guava:33.0.0:C:\\Users\\dev\\.gradle\\caches\\guava.jar";
787
788        let result = parse_gradle_output(output);
789        assert_eq!(result.len(), 1);
790        assert_eq!(
791            result[0].entries[0].jar_path,
792            PathBuf::from("C:\\Users\\dev\\.gradle\\caches\\guava.jar")
793        );
794    }
795
796    // -----------------------------------------------------------------------
797    // source JAR path construction tests
798    // -----------------------------------------------------------------------
799
800    #[test]
801    #[serial]
802    fn test_source_jar_path_construction() {
803        let tmp = TempDir::new().unwrap();
804
805        // Simulate a Gradle cache structure.
806        let module_dir = tmp
807            .path()
808            .join("caches/modules-2/files-2.1/com.google.guava/guava/33.0.0/abc123");
809        std::fs::create_dir_all(&module_dir).unwrap();
810        let source_jar = module_dir.join("guava-33.0.0-sources.jar");
811        std::fs::write(&source_jar, b"fake jar").unwrap();
812
813        // Set GRADLE_USER_HOME so `gradle_cache_dir()` finds our temp dir.
814        // The serial attribute prevents concurrent env mutation by sibling tests.
815        let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
816
817        let entry = ClasspathEntry {
818            jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
819            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
820            is_direct: true,
821            source_jar: None,
822        };
823
824        let found = find_source_jar(&entry);
825        assert_eq!(found, Some(source_jar));
826    }
827
828    #[test]
829    #[serial]
830    fn test_source_jar_not_found() {
831        let tmp = TempDir::new().unwrap();
832        let _guard = EnvGuard::set("GRADLE_USER_HOME", tmp.path().to_str().unwrap());
833
834        let entry = ClasspathEntry {
835            jar_path: PathBuf::from("/path/to/guava-33.0.0.jar"),
836            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
837            is_direct: true,
838            source_jar: None,
839        };
840
841        let found = find_source_jar(&entry);
842        assert!(found.is_none());
843    }
844
845    #[test]
846    fn test_source_jar_no_coordinates() {
847        let entry = ClasspathEntry {
848            jar_path: PathBuf::from("/path/to/something.jar"),
849            coordinates: None,
850            is_direct: true,
851            source_jar: None,
852        };
853
854        let found = find_source_jar(&entry);
855        assert!(found.is_none());
856    }
857
858    // -----------------------------------------------------------------------
859    // Cache roundtrip tests
860    // -----------------------------------------------------------------------
861
862    #[test]
863    fn test_cache_roundtrip() {
864        let tmp = TempDir::new().unwrap();
865        let cache_dir = tmp.path().join("cache");
866
867        let classpaths = vec![
868            ResolvedClasspath {
869                module_name: "app".to_string(),
870                module_root: PathBuf::from("/repo/app"),
871                entries: vec![ClasspathEntry {
872                    jar_path: PathBuf::from("/path/to/guava.jar"),
873                    coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
874                    is_direct: true,
875                    source_jar: None,
876                }],
877            },
878            ResolvedClasspath {
879                module_name: "lib".to_string(),
880                module_root: PathBuf::from("/repo/lib"),
881                entries: vec![ClasspathEntry {
882                    jar_path: PathBuf::from("/path/to/commons.jar"),
883                    coordinates: Some("org.apache.commons:commons-lang3:3.14.0".to_string()),
884                    is_direct: true,
885                    source_jar: Some(PathBuf::from("/path/to/commons-sources.jar")),
886                }],
887            },
888        ];
889
890        write_cache(&cache_dir, &classpaths).expect("cache write should succeed");
891
892        let loaded = read_cache(&cache_dir).expect("cache read should succeed");
893        assert_eq!(loaded.len(), 2);
894
895        let app = loaded.iter().find(|m| m.module_name == "app").unwrap();
896        assert_eq!(app.entries.len(), 1);
897        assert_eq!(
898            app.entries[0].coordinates.as_deref(),
899            Some("com.google.guava:guava:33.0.0")
900        );
901
902        let lib = loaded.iter().find(|m| m.module_name == "lib").unwrap();
903        assert_eq!(
904            lib.entries[0].source_jar,
905            Some(PathBuf::from("/path/to/commons-sources.jar"))
906        );
907    }
908
909    #[test]
910    fn test_cache_read_missing_returns_empty() {
911        let tmp = TempDir::new().unwrap();
912        let cache_dir = tmp.path().join("nonexistent");
913
914        let result = read_cache(&cache_dir).expect("should succeed with empty vec");
915        assert!(result.is_empty());
916    }
917
918    // -----------------------------------------------------------------------
919    // gradle command detection tests
920    // -----------------------------------------------------------------------
921
922    #[test]
923    fn test_missing_gradle_command_returns_none() {
924        // Use the path-injecting helper directly so the test is deterministic
925        // regardless of whether the host has `gradle` installed in `PATH`
926        // (e.g. GitHub Actions runners ship a preinstalled gradle).
927        let tmp = TempDir::new().unwrap();
928        let result = find_gradle_command_with_path(tmp.path(), None);
929        assert!(
930            result.is_none(),
931            "expected None when no wrapper exists and PATH is empty, got {result:?}"
932        );
933    }
934
935    #[test]
936    fn test_gradle_wrapper_found() {
937        let tmp = TempDir::new().unwrap();
938        let wrapper_name = if cfg!(windows) {
939            "gradlew.bat"
940        } else {
941            "gradlew"
942        };
943        std::fs::write(tmp.path().join(wrapper_name), "#!/bin/sh\n").unwrap();
944
945        let result = find_gradle_command(tmp.path());
946        assert_eq!(result, Some(tmp.path().join(wrapper_name)));
947    }
948
949    // -----------------------------------------------------------------------
950    // init script writing test
951    // -----------------------------------------------------------------------
952
953    #[test]
954    fn test_init_script_content() {
955        let file = write_init_script().expect("should create init script");
956        let content = std::fs::read_to_string(file.path()).unwrap();
957        assert!(content.contains("sqryListClasspath"));
958        assert!(content.contains("SQRY_CP_JSON:"));
959        assert!(content.contains("compileClasspath"));
960        assert!(content.contains("resolvedConfiguration"));
961    }
962
963    // -----------------------------------------------------------------------
964    // resolve_cache_dir tests
965    // -----------------------------------------------------------------------
966
967    #[test]
968    fn test_resolve_cache_dir_default() {
969        let config = ResolveConfig {
970            project_root: PathBuf::from("/my/project"),
971            timeout_secs: 60,
972            cache_path: None,
973        };
974        let dir = resolve_cache_dir(&config);
975        assert_eq!(dir, PathBuf::from("/my/project/.sqry/classpath"));
976    }
977
978    #[test]
979    fn test_resolve_cache_dir_override() {
980        let config = ResolveConfig {
981            project_root: PathBuf::from("/my/project"),
982            timeout_secs: 60,
983            cache_path: Some(PathBuf::from("/custom/cache")),
984        };
985        let dir = resolve_cache_dir(&config);
986        assert_eq!(dir, PathBuf::from("/custom/cache"));
987    }
988
989    // -----------------------------------------------------------------------
990    // parse_cp_line unit tests
991    // -----------------------------------------------------------------------
992
993    #[test]
994    fn test_parse_cp_line_valid() {
995        let result = parse_cp_line("app:com.google.guava:guava:33.0.0:/path/to/guava.jar");
996        assert!(result.is_some());
997        let parsed = result.unwrap();
998        assert_eq!(parsed.module_name, "app");
999        assert_eq!(
1000            parsed.entry.coordinates.as_deref(),
1001            Some("com.google.guava:guava:33.0.0")
1002        );
1003        assert_eq!(parsed.entry.jar_path, PathBuf::from("/path/to/guava.jar"));
1004        assert!(parsed.entry.is_direct);
1005        assert!(parsed.entry.source_jar.is_none());
1006    }
1007
1008    #[test]
1009    fn test_parse_cp_line_too_few_parts() {
1010        assert!(parse_cp_line("app:group:name").is_none());
1011        assert!(parse_cp_line("app:group:name:version").is_none());
1012        assert!(parse_cp_line("").is_none());
1013    }
1014
1015    #[test]
1016    fn test_parse_cp_line_empty_fields() {
1017        assert!(parse_cp_line(":group:name:version:/path").is_none());
1018        assert!(parse_cp_line("app::name:version:/path").is_none());
1019        assert!(parse_cp_line("app:group::version:/path").is_none());
1020        assert!(parse_cp_line("app:group:name::/path").is_none());
1021        assert!(parse_cp_line("app:group:name:version:").is_none());
1022    }
1023
1024    // -----------------------------------------------------------------------
1025    // Helper: environment variable guard for tests
1026    // -----------------------------------------------------------------------
1027
1028    /// RAII guard that sets an environment variable and restores the original
1029    /// value when dropped.
1030    struct EnvGuard {
1031        key: String,
1032        original: Option<String>,
1033    }
1034
1035    impl EnvGuard {
1036        fn set(key: &str, value: &str) -> Self {
1037            let original = std::env::var(key).ok();
1038            // Safety: test-only, scoped via RAII guard.
1039            unsafe {
1040                std::env::set_var(key, value);
1041            }
1042            Self {
1043                key: key.to_string(),
1044                original,
1045            }
1046        }
1047    }
1048
1049    impl Drop for EnvGuard {
1050        fn drop(&mut self) {
1051            // Safety: test-only, restoring original env state.
1052            unsafe {
1053                match &self.original {
1054                    Some(val) => std::env::set_var(&self.key, val),
1055                    None => std::env::remove_var(&self.key),
1056                }
1057            }
1058        }
1059    }
1060}