1use 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
23const BAZEL_CQUERY_KIND_PATTERN: &str =
25 r#"kind("java_library|java_import|jvm_import", deps(//...))"#;
26
27const COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
29
30#[allow(clippy::missing_errors_doc)] pub fn resolve_bazel_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
41 info!(
42 "Resolving Bazel classpath in {}",
43 config.project_root.display()
44 );
45
46 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
66fn 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 .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
95fn 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
104fn 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 if trimmed.to_ascii_lowercase().ends_with(".jar") {
119 Some(PathBuf::from(trimmed))
120 } else {
121 None
122 }
123 })
124 .collect()
125}
126
127#[derive(Debug, serde::Deserialize)]
131struct MavenInstallDependency {
132 coord: String,
134 #[serde(default)]
136 file: Option<String>,
137}
138
139#[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
150type CoordinatesMap = std::collections::HashMap<String, String>;
152
153fn 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
178fn 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 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 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
201fn 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
211fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
218 let path_str = jar_path.to_str()?;
219
220 let maven2_idx = path_str.find("/maven2/")?;
222 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
223
224 let components: Vec<&str> = after_maven2.split('/').collect();
226 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 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
250fn 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, source_jar,
266 }
267 })
268 .collect()
269}
270
271fn resolve_coordinates(jar_path: &Path, coordinates_map: &CoordinatesMap) -> Option<String> {
277 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 parse_coursier_coordinates(jar_path)
287}
288
289fn 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 let sources_jar = parent.join(format!("{stem}-sources.jar"));
300 if sources_jar.exists() {
301 return Some(sources_jar);
302 }
303
304 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#[allow(clippy::case_sensitive_file_extension_comparisons)] fn 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
329fn 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
369fn which_binary(name: &str) -> Option<PathBuf> {
373 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
384fn 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 let start = std::time::Instant::now();
398 loop {
399 match child.try_wait() {
400 Ok(Some(_status)) => {
401 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 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
426fn 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#[allow(dead_code)]
435fn coursier_cache_dir() -> Option<PathBuf> {
436 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
437}
438
439fn dirs_path_home() -> Option<PathBuf> {
441 std::env::var_os("HOME").map(PathBuf::from)
442}
443
444#[cfg(test)]
447mod tests {
448 use super::*;
449 use tempfile::TempDir;
450
451 #[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]
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]
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]
626 fn test_missing_bazel_binary_error() {
627 let tmp = TempDir::new().unwrap();
629 let original_path = std::env::var_os("PATH");
630
631 unsafe { std::env::set_var("PATH", tmp.path()) };
635 let result = find_bazel_binary();
636 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]
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 let result = resolve_bazel_classpath(&config);
662 assert!(result.is_err());
664 }
665
666 #[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 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]
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]
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 assert!(!entries[0].is_direct);
782 assert!(!entries[1].is_direct);
783 }
784
785 #[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]
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}