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.
329fn try_cache_fallback(
330    config: &ResolveConfig,
331    original_error: &ClasspathError,
332) -> ClasspathResult<Vec<ResolvedClasspath>> {
333    if let Some(ref cache_path) = config.cache_path {
334        let cache_path = if cache_path.is_dir() {
335            cache_path.join(BAZEL_CACHE_FILE)
336        } else {
337            cache_path.clone()
338        };
339        if cache_path.exists() {
340            info!("Loading cached classpath from {}", cache_path.display());
341            let content = std::fs::read_to_string(&cache_path).map_err(|e| {
342                ClasspathError::CacheError(format!(
343                    "Failed to read cache file {}: {e}",
344                    cache_path.display()
345                ))
346            })?;
347            let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
348                ClasspathError::CacheError(format!(
349                    "Failed to parse cache file {}: {e}",
350                    cache_path.display()
351                ))
352            })?;
353            return Ok(cached);
354        }
355        warn!(
356            "Cache file {} does not exist; cannot fall back",
357            cache_path.display()
358        );
359    }
360
361    Err(ClasspathError::ResolutionFailed(format!(
362        "Bazel resolution failed and no cache available. Original error: {original_error}"
363    )))
364}
365
366// ── Utility functions ───────────────────────────────────────────────────────
367
368/// Find a binary on `$PATH` using `which`-style lookup.
369fn which_binary(name: &str) -> Option<PathBuf> {
370    // Use the `which` crate pattern: scan PATH entries.
371    let path_var = std::env::var_os("PATH")?;
372    for dir in std::env::split_paths(&path_var) {
373        let candidate = dir.join(name);
374        if candidate.is_file() {
375            return Some(candidate);
376        }
377    }
378    None
379}
380
381/// Run a command with a timeout, returning its output.
382fn run_command_with_timeout(
383    cmd: &mut Command,
384    timeout_secs: u64,
385) -> ClasspathResult<std::process::Output> {
386    let mut child = cmd
387        .stdout(std::process::Stdio::piped())
388        .spawn()
389        .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
390
391    let timeout = Duration::from_secs(timeout_secs);
392
393    // Wait with timeout using a polling approach.
394    let start = std::time::Instant::now();
395    loop {
396        match child.try_wait() {
397            Ok(Some(_status)) => {
398                // Process exited; collect output.
399                return child.wait_with_output().map_err(|e| {
400                    ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
401                });
402            }
403            Ok(None) => {
404                if start.elapsed() >= timeout {
405                    // Kill the process on timeout.
406                    let _ = child.kill();
407                    let _ = child.wait();
408                    return Err(ClasspathError::ResolutionFailed(format!(
409                        "Command timed out after {timeout_secs}s"
410                    )));
411                }
412                std::thread::sleep(Duration::from_millis(100));
413            }
414            Err(e) => {
415                return Err(ClasspathError::ResolutionFailed(format!(
416                    "Failed to check process status: {e}"
417                )));
418            }
419        }
420    }
421}
422
423/// Infer a module name from the project root directory name.
424fn infer_module_name(project_root: &Path) -> String {
425    project_root
426        .file_name()
427        .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
428}
429
430// ── Tests ───────────────────────────────────────────────────────────────────
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use tempfile::TempDir;
436
437    // ── Test: parse_cquery_output filters to JARs only ──────────────────
438
439    #[test]
440    fn test_parse_cquery_output_filters_jars() {
441        let output = b"\
442bazel-out/k8-fastbuild/bin/external/maven/com/google/guava/guava/33.0.0/guava-33.0.0.jar
443bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp.jar
444bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp-class.jar
445some/path/to/resource.txt
446another/path/to/data.proto
447";
448
449        let result = parse_cquery_output(output);
450        assert_eq!(result.len(), 3);
451        assert!(
452            result
453                .iter()
454                .all(|p| p.extension().is_some_and(|e| e == "jar"))
455        );
456    }
457
458    #[test]
459    fn test_parse_cquery_output_empty() {
460        let result = parse_cquery_output(b"");
461        assert!(result.is_empty());
462    }
463
464    #[test]
465    fn test_parse_cquery_output_filters_non_jar() {
466        let output = b"\
467/path/to/classes/
468/path/to/resource.xml
469/path/to/source.srcjar
470/path/to/real.jar
471";
472        let result = parse_cquery_output(output);
473        assert_eq!(result.len(), 1);
474        assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
475    }
476
477    #[test]
478    fn test_parse_cquery_output_blank_lines_ignored() {
479        let output = b"\
480/path/a.jar
481
482/path/b.jar
483
484";
485        let result = parse_cquery_output(output);
486        assert_eq!(result.len(), 2);
487    }
488
489    // ── Test: maven_install.json parsing ─────────────────────────────────
490
491    #[test]
492    fn test_maven_install_json_parsing() {
493        let tmp = TempDir::new().unwrap();
494        let json = serde_json::json!({
495            "dependency_tree": {
496                "dependencies": [
497                    {
498                        "coord": "com.google.guava:guava:33.0.0",
499                        "file": "v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
500                    },
501                    {
502                        "coord": "org.slf4j:slf4j-api:2.0.9",
503                        "file": "v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
504                    }
505                ]
506            }
507        });
508
509        let path = tmp.path().join("maven_install.json");
510        std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
511
512        let map = load_maven_install_json(tmp.path());
513        assert!(map.contains_key("guava-33.0.0.jar"));
514        assert_eq!(map["guava-33.0.0.jar"], "com.google.guava:guava:33.0.0");
515        assert!(map.contains_key("slf4j-api-2.0.9.jar"));
516        assert_eq!(map["slf4j-api-2.0.9.jar"], "org.slf4j:slf4j-api:2.0.9");
517    }
518
519    #[test]
520    fn test_maven_install_json_missing_returns_empty() {
521        let tmp = TempDir::new().unwrap();
522        let map = load_maven_install_json(tmp.path());
523        assert!(map.is_empty());
524    }
525
526    #[test]
527    fn test_maven_install_json_malformed_returns_empty() {
528        let tmp = TempDir::new().unwrap();
529        let path = tmp.path().join("maven_install.json");
530        std::fs::write(&path, "{ invalid json }}}").unwrap();
531
532        let map = load_maven_install_json(tmp.path());
533        assert!(map.is_empty());
534    }
535
536    #[test]
537    fn test_maven_install_json_no_dependency_tree() {
538        let tmp = TempDir::new().unwrap();
539        let path = tmp.path().join("maven_install.json");
540        std::fs::write(&path, r#"{"version": "1.0"}"#).unwrap();
541
542        let map = load_maven_install_json(tmp.path());
543        assert!(map.is_empty());
544    }
545
546    #[test]
547    fn test_maven_install_json_third_party_location() {
548        let tmp = TempDir::new().unwrap();
549        let third_party = tmp.path().join("third_party");
550        std::fs::create_dir_all(&third_party).unwrap();
551        let json = serde_json::json!({
552            "dependency_tree": {
553                "dependencies": [
554                    {
555                        "coord": "junit:junit:4.13.2",
556                        "file": "v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar"
557                    }
558                ]
559            }
560        });
561        let path = third_party.join("maven_install.json");
562        std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
563
564        let map = load_maven_install_json(tmp.path());
565        assert!(map.contains_key("junit-4.13.2.jar"));
566    }
567
568    // ── Test: coordinate derivation ─────────────────────────────────────
569
570    #[test]
571    fn test_derive_jar_filename_from_coord() {
572        assert_eq!(
573            derive_jar_filename_from_coord("com.google.guava:guava:33.0.0"),
574            Some("guava-33.0.0.jar".to_string())
575        );
576        assert_eq!(
577            derive_jar_filename_from_coord("org.slf4j:slf4j-api:2.0.9"),
578            Some("slf4j-api-2.0.9.jar".to_string())
579        );
580        assert_eq!(derive_jar_filename_from_coord("invalid"), None);
581        assert_eq!(derive_jar_filename_from_coord("group:artifact"), None);
582    }
583
584    #[test]
585    fn test_parse_coursier_coordinates() {
586        let path = PathBuf::from(
587            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
588        );
589        let coords = parse_coursier_coordinates(&path);
590        assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
591    }
592
593    #[test]
594    fn test_parse_coursier_coordinates_single_group() {
595        let path = PathBuf::from(
596            "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar",
597        );
598        let coords = parse_coursier_coordinates(&path);
599        assert_eq!(coords, Some("junit:junit:4.13.2".to_string()));
600    }
601
602    #[test]
603    fn test_parse_coursier_coordinates_not_coursier_path() {
604        let path = PathBuf::from("/usr/local/lib/some.jar");
605        let coords = parse_coursier_coordinates(&path);
606        assert_eq!(coords, None);
607    }
608
609    // ── Test: missing bazel binary ──────────────────────────────────────
610
611    #[test]
612    fn test_missing_bazel_binary_error() {
613        // Temporarily override PATH to ensure bazel is not found.
614        let tmp = TempDir::new().unwrap();
615        let original_path = std::env::var_os("PATH");
616
617        // Set PATH to empty directory only.
618        // SAFETY: This test is not run in parallel with other tests that depend
619        // on PATH. We restore the original value immediately after the check.
620        unsafe { std::env::set_var("PATH", tmp.path()) };
621        let result = find_bazel_binary();
622        // Restore PATH.
623        if let Some(p) = original_path {
624            unsafe { std::env::set_var("PATH", p) };
625        }
626
627        assert!(result.is_err());
628        let err_msg = result.unwrap_err().to_string();
629        assert!(
630            err_msg.contains("not found"),
631            "Error should mention 'not found': {err_msg}"
632        );
633    }
634
635    // ── Test: resolve with no bazel and no cache ────────────────────────
636
637    #[test]
638    fn test_resolve_no_bazel_no_cache_returns_error() {
639        let tmp = TempDir::new().unwrap();
640        let config = ResolveConfig {
641            project_root: tmp.path().to_path_buf(),
642            timeout_secs: 5,
643            cache_path: None,
644        };
645
646        // This will fail because bazel is not installed in the test environment.
647        let result = resolve_bazel_classpath(&config);
648        // Should fail (no bazel, no cache).
649        assert!(result.is_err());
650    }
651
652    // ── Test: cache fallback ────────────────────────────────────────────
653
654    #[test]
655    fn test_cache_fallback_loads_cached_classpath() {
656        let tmp = TempDir::new().unwrap();
657        let cache_path = tmp.path().join("classpath_cache.json");
658
659        // Write a cached classpath.
660        let cached = vec![ResolvedClasspath {
661            module_name: "cached-project".to_string(),
662            module_root: tmp.path().to_path_buf(),
663            entries: vec![ClasspathEntry {
664                jar_path: PathBuf::from("/cached/guava.jar"),
665                coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
666                is_direct: false,
667                source_jar: None,
668            }],
669        }];
670        std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
671
672        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
673        let config = ResolveConfig {
674            project_root: tmp.path().to_path_buf(),
675            timeout_secs: 5,
676            cache_path: Some(cache_path),
677        };
678
679        let result = try_cache_fallback(&config, &original_error);
680        assert!(result.is_ok());
681        let resolved = result.unwrap();
682        assert_eq!(resolved.len(), 1);
683        assert_eq!(resolved[0].module_name, "cached-project");
684        assert_eq!(resolved[0].entries.len(), 1);
685        assert_eq!(
686            resolved[0].entries[0].coordinates,
687            Some("com.google.guava:guava:33.0.0".to_string())
688        );
689    }
690
691    #[test]
692    fn test_cache_fallback_missing_cache_file() {
693        let tmp = TempDir::new().unwrap();
694        let cache_path = tmp.path().join("nonexistent.json");
695        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
696        let config = ResolveConfig {
697            project_root: tmp.path().to_path_buf(),
698            timeout_secs: 5,
699            cache_path: Some(cache_path),
700        };
701
702        let result = try_cache_fallback(&config, &original_error);
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn test_cache_fallback_no_cache_configured() {
708        let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
709        let config = ResolveConfig {
710            project_root: PathBuf::from("/tmp"),
711            timeout_secs: 5,
712            cache_path: None,
713        };
714
715        let result = try_cache_fallback(&config, &original_error);
716        assert!(result.is_err());
717        let err_msg = result.unwrap_err().to_string();
718        assert!(err_msg.contains("no cache available"));
719    }
720
721    // ── Test: source JAR discovery ──────────────────────────────────────
722
723    #[test]
724    fn test_find_source_jar_same_directory() {
725        let tmp = TempDir::new().unwrap();
726        let main_jar = tmp.path().join("guava-33.0.0.jar");
727        let sources_jar = tmp.path().join("guava-33.0.0-sources.jar");
728        std::fs::write(&main_jar, b"").unwrap();
729        std::fs::write(&sources_jar, b"").unwrap();
730
731        let result = find_source_jar(&main_jar);
732        assert_eq!(result, Some(sources_jar));
733    }
734
735    #[test]
736    fn test_find_source_jar_not_present() {
737        let tmp = TempDir::new().unwrap();
738        let main_jar = tmp.path().join("guava-33.0.0.jar");
739        std::fs::write(&main_jar, b"").unwrap();
740
741        let result = find_source_jar(&main_jar);
742        assert_eq!(result, None);
743    }
744
745    // ── Test: build_entries ─────────────────────────────────────────────
746
747    #[test]
748    fn test_build_entries_with_coordinates() {
749        let jar_paths = vec![
750            PathBuf::from("/some/path/guava-33.0.0.jar"),
751            PathBuf::from("/some/path/unknown.jar"),
752        ];
753        let mut coords = CoordinatesMap::new();
754        coords.insert(
755            "guava-33.0.0.jar".to_string(),
756            "com.google.guava:guava:33.0.0".to_string(),
757        );
758
759        let entries = build_entries(&jar_paths, &coords);
760        assert_eq!(entries.len(), 2);
761        assert_eq!(
762            entries[0].coordinates,
763            Some("com.google.guava:guava:33.0.0".to_string())
764        );
765        assert_eq!(entries[1].coordinates, None);
766        // All entries from Bazel cquery are transitive.
767        assert!(!entries[0].is_direct);
768        assert!(!entries[1].is_direct);
769    }
770
771    // ── Test: infer_module_name ─────────────────────────────────────────
772
773    #[test]
774    fn test_infer_module_name() {
775        assert_eq!(
776            infer_module_name(Path::new("/home/user/my-project")),
777            "my-project"
778        );
779        assert_eq!(infer_module_name(Path::new("/")), "root");
780    }
781
782    // ── Test: coursier source JAR derivation ────────────────────────────
783
784    #[test]
785    fn test_find_coursier_source_jar_derivation() {
786        let jar = PathBuf::from("/cache/v1/guava-33.0.0.jar");
787        let result = find_coursier_source_jar(&jar);
788        assert_eq!(
789            result,
790            Some(PathBuf::from("/cache/v1/guava-33.0.0-sources.jar"))
791        );
792    }
793
794    #[test]
795    fn test_find_coursier_source_jar_already_sources() {
796        let jar = PathBuf::from("/cache/v1/guava-33.0.0-sources.jar");
797        let result = find_coursier_source_jar(&jar);
798        assert_eq!(result, None);
799    }
800}