Skip to main content

sqry_classpath/resolve/
bazel.rs

1//! Bazel classpath resolver.
2//!
3//! Resolves JVM classpath entries from Bazel workspaces by:
4//! 1. Running `bazel cquery` to list Java compilation outputs
5//! 2. Parsing output for JAR paths in `bazel-out/` and external repository cache
6//! 3. Parsing `maven_install.json` for Maven coordinate mapping (`rules_jvm_external`)
7//! 4. Looking up source JARs in the Coursier cache
8//! 5. Falling back to cached classpath on failure
9
10use std::io::BufRead;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::Duration;
14
15use log::{debug, info, warn};
16
17use crate::{ClasspathError, ClasspathResult};
18
19use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
20
21const BAZEL_CACHE_FILE: &str = "bazel-resolved-classpath.json";
22
23/// Bazel cquery command and arguments for listing Java dependency outputs.
24const BAZEL_CQUERY_KIND_PATTERN: &str =
25    r#"kind("java_library|java_import|jvm_import", deps(//...))"#;
26
27// ── Public API ──────────────────────────────────────────────────────────────
28
29/// Resolve classpath for a Bazel project.
30///
31/// Strategy:
32/// 1. Try `bazel cquery` to list Java compilation outputs
33/// 2. Parse output for JAR paths in `bazel-out/` and external repository cache
34/// 3. Try `maven_install.json` for coordinates mapping
35/// 4. On failure, fall back to cache
36#[allow(clippy::missing_errors_doc)] // Internal helper
37pub fn resolve_bazel_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
38    info!(
39        "Resolving Bazel classpath in {}",
40        config.project_root.display()
41    );
42
43    // Attempt live resolution via bazel cquery.
44    match run_bazel_cquery(config) {
45        Ok(jar_paths) => {
46            info!("Bazel cquery returned {} JAR paths", jar_paths.len());
47            let coordinates_map = load_maven_install_json(&config.project_root);
48            let entries = build_entries(&jar_paths, &coordinates_map);
49            let resolved = ResolvedClasspath {
50                module_name: infer_module_name(&config.project_root),
51                module_root: config.project_root.clone(),
52                entries,
53            };
54            Ok(vec![resolved])
55        }
56        Err(e) => {
57            warn!("Bazel cquery failed: {e}. Attempting cache fallback.");
58            try_cache_fallback(config, &e)
59        }
60    }
61}
62
63// ── Bazel cquery execution ──────────────────────────────────────────────────
64
65/// Run `bazel cquery` and return the list of JAR file paths from its output.
66fn run_bazel_cquery(config: &ResolveConfig) -> ClasspathResult<Vec<PathBuf>> {
67    let bazel_bin = find_bazel_binary()?;
68
69    let mut cmd = Command::new(&bazel_bin);
70    cmd.arg("cquery")
71        .arg(BAZEL_CQUERY_KIND_PATTERN)
72        .arg("--output=files")
73        .current_dir(&config.project_root)
74        // Suppress Bazel's own stderr noise.
75        .stderr(std::process::Stdio::null());
76
77    debug!("Running: {} cquery ... --output=files", bazel_bin.display());
78
79    let output = run_command_with_timeout(&mut cmd, config.timeout_secs)?;
80
81    if !output.status.success() {
82        return Err(ClasspathError::ResolutionFailed(format!(
83            "bazel cquery exited with status {}",
84            output.status
85        )));
86    }
87
88    let jars = parse_cquery_output(&output.stdout);
89    Ok(jars)
90}
91
92/// Locate the `bazel` binary on `$PATH`.
93fn find_bazel_binary() -> ClasspathResult<PathBuf> {
94    which_binary("bazel").ok_or_else(|| {
95        ClasspathError::ResolutionFailed(
96            "bazel binary not found on PATH. Install Bazel to resolve classpath.".to_string(),
97        )
98    })
99}
100
101/// Parse raw `bazel cquery --output=files` output, keeping only `.jar` paths.
102///
103/// Each line of output is a single file path. We filter to keep only lines
104/// ending in `.jar` (case-insensitive) to exclude `.srcjar`, class dirs, etc.
105fn parse_cquery_output(stdout: &[u8]) -> Vec<PathBuf> {
106    stdout
107        .lines()
108        .filter_map(|line| {
109            let line = line.ok()?;
110            let trimmed = line.trim();
111            if trimmed.is_empty() {
112                return None;
113            }
114            // Only keep .jar files (not .srcjar, .aar, etc.)
115            if trimmed.to_ascii_lowercase().ends_with(".jar") {
116                Some(PathBuf::from(trimmed))
117            } else {
118                None
119            }
120        })
121        .collect()
122}
123
124// ── maven_install.json ──────────────────────────────────────────────────────
125
126/// A single dependency entry from `maven_install.json`.
127#[derive(Debug, serde::Deserialize)]
128struct MavenInstallDependency {
129    /// Maven coordinate, e.g. `com.google.guava:guava:33.0.0`.
130    coord: String,
131    /// Relative file path within the Coursier/repository cache.
132    #[serde(default)]
133    file: Option<String>,
134}
135
136/// Top-level structure of `maven_install.json` (only the fields we need).
137#[derive(Debug, serde::Deserialize)]
138struct MavenInstallJson {
139    dependency_tree: Option<DependencyTree>,
140}
141
142#[derive(Debug, serde::Deserialize)]
143struct DependencyTree {
144    dependencies: Vec<MavenInstallDependency>,
145}
146
147/// Coordinate mapping: JAR filename → Maven coordinate string.
148type CoordinatesMap = std::collections::HashMap<String, String>;
149
150/// Try to load `maven_install.json` (from `rules_jvm_external`) and build a
151/// mapping from JAR filename to Maven coordinates.
152///
153/// Returns an empty map on any error (file missing, parse error, etc.).
154fn load_maven_install_json(project_root: &Path) -> CoordinatesMap {
155    let candidates = [
156        project_root.join("maven_install.json"),
157        project_root.join("third_party/maven_install.json"),
158    ];
159
160    for path in &candidates {
161        if let Some(map) = try_parse_maven_install(path) {
162            info!(
163                "Loaded {} coordinate mappings from {}",
164                map.len(),
165                path.display()
166            );
167            return map;
168        }
169    }
170
171    debug!("No maven_install.json found; coordinate mapping unavailable");
172    CoordinatesMap::new()
173}
174
175/// Parse a single `maven_install.json` file into a coordinate map.
176fn try_parse_maven_install(path: &Path) -> Option<CoordinatesMap> {
177    let content = std::fs::read_to_string(path).ok()?;
178    let parsed: MavenInstallJson = serde_json::from_str(&content).ok()?;
179    let tree = parsed.dependency_tree?;
180
181    let mut map = CoordinatesMap::with_capacity(tree.dependencies.len());
182    for dep in &tree.dependencies {
183        // Build a filename from the coordinate for matching.
184        // Also store the explicit `file` field's basename if present.
185        if let Some(ref file_path) = dep.file
186            && let Some(basename) = Path::new(file_path).file_name()
187        {
188            map.insert(basename.to_string_lossy().to_string(), dep.coord.clone());
189        }
190        // Also derive filename from coordinates: artifact-version.jar
191        if let Some(derived) = derive_jar_filename_from_coord(&dep.coord) {
192            map.insert(derived, dep.coord.clone());
193        }
194    }
195    Some(map)
196}
197
198/// Derive `artifact-version.jar` from a Maven coordinate like `group:artifact:version`.
199fn derive_jar_filename_from_coord(coord: &str) -> Option<String> {
200    let parts: Vec<&str> = coord.split(':').collect();
201    if parts.len() >= 3 {
202        Some(format!("{}-{}.jar", parts[1], parts[2]))
203    } else {
204        None
205    }
206}
207
208/// Parse Maven coordinates from a Coursier cache path.
209///
210/// Coursier cache paths follow the pattern:
211/// `~/.cache/coursier/v1/https/repo1.maven.org/maven2/<group-path>/<artifact>/<version>/<artifact>-<version>.jar`
212///
213/// We extract `group:artifact:version` from this structure.
214fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
215    let path_str = jar_path.to_str()?;
216
217    // Look for the `/maven2/` segment that precedes the Maven layout.
218    let maven2_idx = path_str.find("/maven2/")?;
219    let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
220
221    // Split into path components.
222    let components: Vec<&str> = after_maven2.split('/').collect();
223    // Need at least: group-parts... / artifact / version / filename
224    if components.len() < 3 {
225        return None;
226    }
227
228    let filename = *components.last()?;
229    let version = components[components.len() - 2];
230    let artifact = components[components.len() - 3];
231    let group_parts = &components[..components.len() - 3];
232
233    if group_parts.is_empty() {
234        return None;
235    }
236
237    // Verify filename matches expected pattern.
238    let expected_prefix = format!("{artifact}-{version}");
239    if !filename.starts_with(&expected_prefix) {
240        return None;
241    }
242
243    let group = group_parts.join(".");
244    Some(format!("{group}:{artifact}:{version}"))
245}
246
247// ── Entry construction ──────────────────────────────────────────────────────
248
249/// Build `ClasspathEntry` records from JAR paths, enriching with coordinates
250/// and source JAR locations where possible.
251fn build_entries(jar_paths: &[PathBuf], coordinates_map: &CoordinatesMap) -> Vec<ClasspathEntry> {
252    jar_paths
253        .iter()
254        .map(|jar_path| {
255            let coordinates = resolve_coordinates(jar_path, coordinates_map);
256            let source_jar = find_source_jar(jar_path);
257
258            ClasspathEntry {
259                jar_path: jar_path.clone(),
260                coordinates,
261                is_direct: false, // Bazel cquery returns the full transitive closure.
262                source_jar,
263            }
264        })
265        .collect()
266}
267
268/// Try to resolve Maven coordinates for a JAR path.
269///
270/// Strategy:
271/// 1. Look up the JAR filename in the `maven_install.json` coordinate map
272/// 2. Try parsing coordinates from a Coursier cache path structure
273fn resolve_coordinates(jar_path: &Path, coordinates_map: &CoordinatesMap) -> Option<String> {
274    // Strategy 1: Filename lookup in maven_install.json mappings.
275    if let Some(filename) = jar_path.file_name() {
276        let filename_str = filename.to_string_lossy();
277        if let Some(coord) = coordinates_map.get(filename_str.as_ref()) {
278            return Some(coord.clone());
279        }
280    }
281
282    // Strategy 2: Parse from Coursier cache path.
283    parse_coursier_coordinates(jar_path)
284}
285
286/// Find a source JAR alongside a main JAR.
287///
288/// Looks in two locations:
289/// 1. Same directory: `artifact-version-sources.jar`
290/// 2. Coursier cache: replace `.jar` with `-sources.jar` in the filename
291fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
292    let stem = jar_path.file_stem()?.to_string_lossy();
293    let parent = jar_path.parent()?;
294
295    // Try `<stem>-sources.jar` in the same directory.
296    let sources_jar = parent.join(format!("{stem}-sources.jar"));
297    if sources_jar.exists() {
298        return Some(sources_jar);
299    }
300
301    // Try Coursier cache: look for `-sources.jar` variant.
302    if let Some(coursier_sources) = find_coursier_source_jar(jar_path)
303        && coursier_sources.exists()
304    {
305        return Some(coursier_sources);
306    }
307
308    None
309}
310
311/// Derive the Coursier cache path for a source JAR given the main JAR path.
312///
313/// In Coursier cache, source JARs live at the same path but with `-sources`
314/// appended before `.jar`.
315#[allow(clippy::case_sensitive_file_extension_comparisons)] // Known file extensions
316fn find_coursier_source_jar(jar_path: &Path) -> Option<PathBuf> {
317    let path_str = jar_path.to_str()?;
318    if path_str.ends_with(".jar") && !path_str.ends_with("-sources.jar") {
319        let sources_path = format!("{}-sources.jar", &path_str[..path_str.len() - 4]);
320        Some(PathBuf::from(sources_path))
321    } else {
322        None
323    }
324}
325
326// ── Cache fallback ──────────────────────────────────────────────────────────
327
328/// Attempt to load a previously cached classpath when live resolution fails.
329/// Read a previously cached Bazel classpath without invoking Bazel. Returns
330/// `None` when no usable cache is present. Used when build-tool execution is
331/// disabled (`--no-build-tool`).
332pub(crate) fn read_cached_classpath(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
333    let cache_path = config.cache_path.as_ref()?;
334    let cache_file = if cache_path.is_dir() {
335        cache_path.join(BAZEL_CACHE_FILE)
336    } else {
337        cache_path.clone()
338    };
339    if !cache_file.exists() {
340        return None;
341    }
342    let content = std::fs::read_to_string(&cache_file).ok()?;
343    let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).ok()?;
344    (!cached.is_empty()).then_some(cached)
345}
346
347fn try_cache_fallback(
348    config: &ResolveConfig,
349    original_error: &ClasspathError,
350) -> ClasspathResult<Vec<ResolvedClasspath>> {
351    if let Some(ref cache_path) = config.cache_path {
352        let cache_path = if cache_path.is_dir() {
353            cache_path.join(BAZEL_CACHE_FILE)
354        } else {
355            cache_path.clone()
356        };
357        if cache_path.exists() {
358            info!("Loading cached classpath from {}", cache_path.display());
359            let content = std::fs::read_to_string(&cache_path).map_err(|e| {
360                ClasspathError::CacheError(format!(
361                    "Failed to read cache file {}: {e}",
362                    cache_path.display()
363                ))
364            })?;
365            let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
366                ClasspathError::CacheError(format!(
367                    "Failed to parse cache file {}: {e}",
368                    cache_path.display()
369                ))
370            })?;
371            return Ok(cached);
372        }
373        warn!(
374            "Cache file {} does not exist; cannot fall back",
375            cache_path.display()
376        );
377    }
378
379    Err(ClasspathError::ResolutionFailed(format!(
380        "Bazel resolution failed and no cache available. Original error: {original_error}"
381    )))
382}
383
384// ── Utility functions ───────────────────────────────────────────────────────
385
386/// Find a binary on `$PATH` using `which`-style lookup.
387fn which_binary(name: &str) -> Option<PathBuf> {
388    // Use the `which` crate pattern: scan PATH entries.
389    let path_var = std::env::var_os("PATH")?;
390    for dir in std::env::split_paths(&path_var) {
391        let candidate = dir.join(name);
392        if candidate.is_file() {
393            return Some(candidate);
394        }
395    }
396    None
397}
398
399/// Run a command with a timeout, returning its output.
400fn run_command_with_timeout(
401    cmd: &mut Command,
402    timeout_secs: u64,
403) -> ClasspathResult<std::process::Output> {
404    let mut child = cmd
405        .stdout(std::process::Stdio::piped())
406        .spawn()
407        .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
408
409    let timeout = Duration::from_secs(timeout_secs);
410
411    // Wait with timeout using a polling approach.
412    let start = std::time::Instant::now();
413    loop {
414        match child.try_wait() {
415            Ok(Some(_status)) => {
416                // Process exited; collect output.
417                return child.wait_with_output().map_err(|e| {
418                    ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
419                });
420            }
421            Ok(None) => {
422                if start.elapsed() >= timeout {
423                    // Kill the process on timeout.
424                    let _ = child.kill();
425                    let _ = child.wait();
426                    return Err(ClasspathError::ResolutionFailed(format!(
427                        "Command timed out after {timeout_secs}s"
428                    )));
429                }
430                std::thread::sleep(Duration::from_millis(100));
431            }
432            Err(e) => {
433                return Err(ClasspathError::ResolutionFailed(format!(
434                    "Failed to check process status: {e}"
435                )));
436            }
437        }
438    }
439}
440
441/// Infer a module name from the project root directory name.
442fn infer_module_name(project_root: &Path) -> String {
443    project_root
444        .file_name()
445        .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
446}
447
448// ── Tests ───────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use tempfile::TempDir;
454
455    // ── Test: parse_cquery_output filters to JARs only ──────────────────
456
457    #[test]
458    fn test_parse_cquery_output_filters_jars() {
459        let output = b"\
460bazel-out/k8-fastbuild/bin/external/maven/com/google/guava/guava/33.0.0/guava-33.0.0.jar
461bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp.jar
462bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp-class.jar
463some/path/to/resource.txt
464another/path/to/data.proto
465";
466
467        let result = parse_cquery_output(output);
468        assert_eq!(result.len(), 3);
469        assert!(
470            result
471                .iter()
472                .all(|p| p.extension().is_some_and(|e| e == "jar"))
473        );
474    }
475
476    #[test]
477    fn test_parse_cquery_output_empty() {
478        let result = parse_cquery_output(b"");
479        assert!(result.is_empty());
480    }
481
482    #[test]
483    fn test_parse_cquery_output_filters_non_jar() {
484        let output = b"\
485/path/to/classes/
486/path/to/resource.xml
487/path/to/source.srcjar
488/path/to/real.jar
489";
490        let result = parse_cquery_output(output);
491        assert_eq!(result.len(), 1);
492        assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
493    }
494
495    #[test]
496    fn test_parse_cquery_output_blank_lines_ignored() {
497        let output = b"\
498/path/a.jar
499
500/path/b.jar
501
502";
503        let result = parse_cquery_output(output);
504        assert_eq!(result.len(), 2);
505    }
506
507    // ── Test: maven_install.json parsing ─────────────────────────────────
508
509    #[test]
510    fn test_maven_install_json_parsing() {
511        let tmp = TempDir::new().unwrap();
512        let json = serde_json::json!({
513            "dependency_tree": {
514                "dependencies": [
515                    {
516                        "coord": "com.google.guava:guava:33.0.0",
517                        "file": "v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
518                    },
519                    {
520                        "coord": "org.slf4j:slf4j-api:2.0.9",
521                        "file": "v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
522                    }
523                ]
524            }
525        });
526
527        let path = tmp.path().join("maven_install.json");
528        std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
529
530        let map = load_maven_install_json(tmp.path());
531        assert!(map.contains_key("guava-33.0.0.jar"));
532        assert_eq!(map["guava-33.0.0.jar"], "com.google.guava:guava:33.0.0");
533        assert!(map.contains_key("slf4j-api-2.0.9.jar"));
534        assert_eq!(map["slf4j-api-2.0.9.jar"], "org.slf4j:slf4j-api:2.0.9");
535    }
536
537    #[test]
538    fn test_maven_install_json_missing_returns_empty() {
539        let tmp = TempDir::new().unwrap();
540        let map = load_maven_install_json(tmp.path());
541        assert!(map.is_empty());
542    }
543
544    #[test]
545    fn test_maven_install_json_malformed_returns_empty() {
546        let tmp = TempDir::new().unwrap();
547        let path = tmp.path().join("maven_install.json");
548        std::fs::write(&path, "{ invalid json }}}").unwrap();
549
550        let map = load_maven_install_json(tmp.path());
551        assert!(map.is_empty());
552    }
553
554    #[test]
555    fn test_maven_install_json_no_dependency_tree() {
556        let tmp = TempDir::new().unwrap();
557        let path = tmp.path().join("maven_install.json");
558        std::fs::write(&path, r#"{"version": "1.0"}"#).unwrap();
559
560        let map = load_maven_install_json(tmp.path());
561        assert!(map.is_empty());
562    }
563
564    #[test]
565    fn test_maven_install_json_third_party_location() {
566        let tmp = TempDir::new().unwrap();
567        let third_party = tmp.path().join("third_party");
568        std::fs::create_dir_all(&third_party).unwrap();
569        let json = serde_json::json!({
570            "dependency_tree": {
571                "dependencies": [
572                    {
573                        "coord": "junit:junit:4.13.2",
574                        "file": "v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar"
575                    }
576                ]
577            }
578        });
579        let path = third_party.join("maven_install.json");
580        std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
581
582        let map = load_maven_install_json(tmp.path());
583        assert!(map.contains_key("junit-4.13.2.jar"));
584    }
585
586    // ── Test: coordinate derivation ─────────────────────────────────────
587
588    #[test]
589    fn test_derive_jar_filename_from_coord() {
590        assert_eq!(
591            derive_jar_filename_from_coord("com.google.guava:guava:33.0.0"),
592            Some("guava-33.0.0.jar".to_string())
593        );
594        assert_eq!(
595            derive_jar_filename_from_coord("org.slf4j:slf4j-api:2.0.9"),
596            Some("slf4j-api-2.0.9.jar".to_string())
597        );
598        assert_eq!(derive_jar_filename_from_coord("invalid"), None);
599        assert_eq!(derive_jar_filename_from_coord("group:artifact"), None);
600    }
601
602    #[test]
603    fn test_parse_coursier_coordinates() {
604        let path = PathBuf::from(
605            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
606        );
607        let coords = parse_coursier_coordinates(&path);
608        assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
609    }
610
611    #[test]
612    fn test_parse_coursier_coordinates_single_group() {
613        let path = PathBuf::from(
614            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar",
615        );
616        let coords = parse_coursier_coordinates(&path);
617        assert_eq!(coords, Some("junit:junit:4.13.2".to_string()));
618    }
619
620    #[test]
621    fn test_parse_coursier_coordinates_not_coursier_path() {
622        let path = PathBuf::from("/usr/local/lib/some.jar");
623        let coords = parse_coursier_coordinates(&path);
624        assert_eq!(coords, None);
625    }
626
627    // ── Test: missing bazel binary ──────────────────────────────────────
628
629    #[test]
630    fn test_missing_bazel_binary_error() {
631        // Temporarily override PATH to ensure bazel is not found.
632        let tmp = TempDir::new().unwrap();
633        let original_path = std::env::var_os("PATH");
634
635        // Set PATH to empty directory only.
636        // SAFETY: This test is not run in parallel with other tests that depend
637        // on PATH. We restore the original value immediately after the check.
638        unsafe { std::env::set_var("PATH", tmp.path()) };
639        let result = find_bazel_binary();
640        // Restore PATH.
641        if let Some(p) = original_path {
642            unsafe { std::env::set_var("PATH", p) };
643        }
644
645        assert!(result.is_err());
646        let err_msg = result.unwrap_err().to_string();
647        assert!(
648            err_msg.contains("not found"),
649            "Error should mention 'not found': {err_msg}"
650        );
651    }
652
653    // ── Test: resolve with no bazel and no cache ────────────────────────
654
655    #[test]
656    fn test_resolve_no_bazel_no_cache_returns_error() {
657        let tmp = TempDir::new().unwrap();
658        let config = ResolveConfig {
659            project_root: tmp.path().to_path_buf(),
660            timeout_secs: 5,
661            cache_path: None,
662        };
663
664        // This will fail because bazel is not installed in the test environment.
665        let result = resolve_bazel_classpath(&config);
666        // Should fail (no bazel, no cache).
667        assert!(result.is_err());
668    }
669
670    // ── Test: cache fallback ────────────────────────────────────────────
671
672    #[test]
673    fn test_cache_fallback_loads_cached_classpath() {
674        let tmp = TempDir::new().unwrap();
675        let cache_path = tmp.path().join("classpath_cache.json");
676
677        // Write a cached classpath.
678        let cached = vec![ResolvedClasspath {
679            module_name: "cached-project".to_string(),
680            module_root: tmp.path().to_path_buf(),
681            entries: vec![ClasspathEntry {
682                jar_path: PathBuf::from("/cached/guava.jar"),
683                coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
684                is_direct: false,
685                source_jar: None,
686            }],
687        }];
688        std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
689
690        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
691        let config = ResolveConfig {
692            project_root: tmp.path().to_path_buf(),
693            timeout_secs: 5,
694            cache_path: Some(cache_path),
695        };
696
697        let result = try_cache_fallback(&config, &original_error);
698        assert!(result.is_ok());
699        let resolved = result.unwrap();
700        assert_eq!(resolved.len(), 1);
701        assert_eq!(resolved[0].module_name, "cached-project");
702        assert_eq!(resolved[0].entries.len(), 1);
703        assert_eq!(
704            resolved[0].entries[0].coordinates,
705            Some("com.google.guava:guava:33.0.0".to_string())
706        );
707    }
708
709    #[test]
710    fn test_cache_fallback_missing_cache_file() {
711        let tmp = TempDir::new().unwrap();
712        let cache_path = tmp.path().join("nonexistent.json");
713        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
714        let config = ResolveConfig {
715            project_root: tmp.path().to_path_buf(),
716            timeout_secs: 5,
717            cache_path: Some(cache_path),
718        };
719
720        let result = try_cache_fallback(&config, &original_error);
721        assert!(result.is_err());
722    }
723
724    #[test]
725    fn test_cache_fallback_no_cache_configured() {
726        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
727        let config = ResolveConfig {
728            project_root: PathBuf::from("/tmp"),
729            timeout_secs: 5,
730            cache_path: None,
731        };
732
733        let result = try_cache_fallback(&config, &original_error);
734        assert!(result.is_err());
735        let err_msg = result.unwrap_err().to_string();
736        assert!(err_msg.contains("no cache available"));
737    }
738
739    // ── Test: source JAR discovery ──────────────────────────────────────
740
741    #[test]
742    fn test_find_source_jar_same_directory() {
743        let tmp = TempDir::new().unwrap();
744        let main_jar = tmp.path().join("guava-33.0.0.jar");
745        let sources_jar = tmp.path().join("guava-33.0.0-sources.jar");
746        std::fs::write(&main_jar, b"").unwrap();
747        std::fs::write(&sources_jar, b"").unwrap();
748
749        let result = find_source_jar(&main_jar);
750        assert_eq!(result, Some(sources_jar));
751    }
752
753    #[test]
754    fn test_find_source_jar_not_present() {
755        let tmp = TempDir::new().unwrap();
756        let main_jar = tmp.path().join("guava-33.0.0.jar");
757        std::fs::write(&main_jar, b"").unwrap();
758
759        let result = find_source_jar(&main_jar);
760        assert_eq!(result, None);
761    }
762
763    // ── Test: build_entries ─────────────────────────────────────────────
764
765    #[test]
766    fn test_build_entries_with_coordinates() {
767        let jar_paths = vec![
768            PathBuf::from("/some/path/guava-33.0.0.jar"),
769            PathBuf::from("/some/path/unknown.jar"),
770        ];
771        let mut coords = CoordinatesMap::new();
772        coords.insert(
773            "guava-33.0.0.jar".to_string(),
774            "com.google.guava:guava:33.0.0".to_string(),
775        );
776
777        let entries = build_entries(&jar_paths, &coords);
778        assert_eq!(entries.len(), 2);
779        assert_eq!(
780            entries[0].coordinates,
781            Some("com.google.guava:guava:33.0.0".to_string())
782        );
783        assert_eq!(entries[1].coordinates, None);
784        // All entries from Bazel cquery are transitive.
785        assert!(!entries[0].is_direct);
786        assert!(!entries[1].is_direct);
787    }
788
789    // ── Test: infer_module_name ─────────────────────────────────────────
790
791    #[test]
792    fn test_infer_module_name() {
793        assert_eq!(
794            infer_module_name(Path::new("/home/user/my-project")),
795            "my-project"
796        );
797        assert_eq!(infer_module_name(Path::new("/")), "root");
798    }
799
800    // ── Test: coursier source JAR derivation ────────────────────────────
801
802    #[test]
803    fn test_find_coursier_source_jar_derivation() {
804        let jar = PathBuf::from("/cache/v1/guava-33.0.0.jar");
805        let result = find_coursier_source_jar(&jar);
806        assert_eq!(
807            result,
808            Some(PathBuf::from("/cache/v1/guava-33.0.0-sources.jar"))
809        );
810    }
811
812    #[test]
813    fn test_find_coursier_source_jar_already_sources() {
814        let jar = PathBuf::from("/cache/v1/guava-33.0.0-sources.jar");
815        let result = find_coursier_source_jar(&jar);
816        assert_eq!(result, None);
817    }
818}