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