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