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_CQUERY_KIND_PATTERN: &str =
23 r#"kind("java_library|java_import|jvm_import", deps(//...))"#;
24
25const COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
27
28#[allow(clippy::missing_errors_doc)] pub fn resolve_bazel_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
39 info!(
40 "Resolving Bazel classpath in {}",
41 config.project_root.display()
42 );
43
44 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
64fn 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 .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
93fn 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
102fn 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 if trimmed.to_ascii_lowercase().ends_with(".jar") {
117 Some(PathBuf::from(trimmed))
118 } else {
119 None
120 }
121 })
122 .collect()
123}
124
125#[derive(Debug, serde::Deserialize)]
129struct MavenInstallDependency {
130 coord: String,
132 #[serde(default)]
134 file: Option<String>,
135}
136
137#[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
148type CoordinatesMap = std::collections::HashMap<String, String>;
150
151fn 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
176fn 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 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 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
199fn 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
209fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
216 let path_str = jar_path.to_str()?;
217
218 let maven2_idx = path_str.find("/maven2/")?;
220 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
221
222 let components: Vec<&str> = after_maven2.split('/').collect();
224 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 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
248fn 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, source_jar,
264 }
265 })
266 .collect()
267}
268
269fn resolve_coordinates(jar_path: &Path, coordinates_map: &CoordinatesMap) -> Option<String> {
275 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 parse_coursier_coordinates(jar_path)
285}
286
287fn 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 let sources_jar = parent.join(format!("{stem}-sources.jar"));
298 if sources_jar.exists() {
299 return Some(sources_jar);
300 }
301
302 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#[allow(clippy::case_sensitive_file_extension_comparisons)] fn 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
327fn 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
362fn which_binary(name: &str) -> Option<PathBuf> {
366 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
377fn 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 let start = std::time::Instant::now();
391 loop {
392 match child.try_wait() {
393 Ok(Some(_status)) => {
394 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 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
419fn 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#[allow(dead_code)]
428fn coursier_cache_dir() -> Option<PathBuf> {
429 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
430}
431
432fn dirs_path_home() -> Option<PathBuf> {
434 std::env::var_os("HOME").map(PathBuf::from)
435}
436
437#[cfg(test)]
440mod tests {
441 use super::*;
442 use tempfile::TempDir;
443
444 #[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]
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]
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]
619 fn test_missing_bazel_binary_error() {
620 let tmp = TempDir::new().unwrap();
622 let original_path = std::env::var_os("PATH");
623
624 unsafe { std::env::set_var("PATH", tmp.path()) };
628 let result = find_bazel_binary();
629 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]
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 let result = resolve_bazel_classpath(&config);
655 assert!(result.is_err());
657 }
658
659 #[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 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]
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]
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 assert!(!entries[0].is_direct);
775 assert!(!entries[1].is_direct);
776 }
777
778 #[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]
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}