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