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