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 entries,
53 };
54 Ok(vec![resolved])
55 }
56 Err(e) => {
57 warn!("Bazel cquery failed: {e}. Attempting cache fallback.");
58 try_cache_fallback(config, &e)
59 }
60 }
61}
62
63fn run_bazel_cquery(config: &ResolveConfig) -> ClasspathResult<Vec<PathBuf>> {
67 let bazel_bin = find_bazel_binary()?;
68
69 let mut cmd = Command::new(&bazel_bin);
70 cmd.arg("cquery")
71 .arg(BAZEL_CQUERY_KIND_PATTERN)
72 .arg("--output=files")
73 .current_dir(&config.project_root)
74 .stderr(std::process::Stdio::null());
76
77 debug!("Running: {} cquery ... --output=files", bazel_bin.display());
78
79 let output = run_command_with_timeout(&mut cmd, config.timeout_secs)?;
80
81 if !output.status.success() {
82 return Err(ClasspathError::ResolutionFailed(format!(
83 "bazel cquery exited with status {}",
84 output.status
85 )));
86 }
87
88 let jars = parse_cquery_output(&output.stdout);
89 Ok(jars)
90}
91
92fn find_bazel_binary() -> ClasspathResult<PathBuf> {
94 which_binary("bazel").ok_or_else(|| {
95 ClasspathError::ResolutionFailed(
96 "bazel binary not found on PATH. Install Bazel to resolve classpath.".to_string(),
97 )
98 })
99}
100
101fn parse_cquery_output(stdout: &[u8]) -> Vec<PathBuf> {
106 stdout
107 .lines()
108 .filter_map(|line| {
109 let line = line.ok()?;
110 let trimmed = line.trim();
111 if trimmed.is_empty() {
112 return None;
113 }
114 if trimmed.to_ascii_lowercase().ends_with(".jar") {
116 Some(PathBuf::from(trimmed))
117 } else {
118 None
119 }
120 })
121 .collect()
122}
123
124#[derive(Debug, serde::Deserialize)]
128struct MavenInstallDependency {
129 coord: String,
131 #[serde(default)]
133 file: Option<String>,
134}
135
136#[derive(Debug, serde::Deserialize)]
138struct MavenInstallJson {
139 dependency_tree: Option<DependencyTree>,
140}
141
142#[derive(Debug, serde::Deserialize)]
143struct DependencyTree {
144 dependencies: Vec<MavenInstallDependency>,
145}
146
147type CoordinatesMap = std::collections::HashMap<String, String>;
149
150fn load_maven_install_json(project_root: &Path) -> CoordinatesMap {
155 let candidates = [
156 project_root.join("maven_install.json"),
157 project_root.join("third_party/maven_install.json"),
158 ];
159
160 for path in &candidates {
161 if let Some(map) = try_parse_maven_install(path) {
162 info!(
163 "Loaded {} coordinate mappings from {}",
164 map.len(),
165 path.display()
166 );
167 return map;
168 }
169 }
170
171 debug!("No maven_install.json found; coordinate mapping unavailable");
172 CoordinatesMap::new()
173}
174
175fn try_parse_maven_install(path: &Path) -> Option<CoordinatesMap> {
177 let content = std::fs::read_to_string(path).ok()?;
178 let parsed: MavenInstallJson = serde_json::from_str(&content).ok()?;
179 let tree = parsed.dependency_tree?;
180
181 let mut map = CoordinatesMap::with_capacity(tree.dependencies.len());
182 for dep in &tree.dependencies {
183 if let Some(ref file_path) = dep.file
186 && let Some(basename) = Path::new(file_path).file_name()
187 {
188 map.insert(basename.to_string_lossy().to_string(), dep.coord.clone());
189 }
190 if let Some(derived) = derive_jar_filename_from_coord(&dep.coord) {
192 map.insert(derived, dep.coord.clone());
193 }
194 }
195 Some(map)
196}
197
198fn derive_jar_filename_from_coord(coord: &str) -> Option<String> {
200 let parts: Vec<&str> = coord.split(':').collect();
201 if parts.len() >= 3 {
202 Some(format!("{}-{}.jar", parts[1], parts[2]))
203 } else {
204 None
205 }
206}
207
208fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
215 let path_str = jar_path.to_str()?;
216
217 let maven2_idx = path_str.find("/maven2/")?;
219 let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
220
221 let components: Vec<&str> = after_maven2.split('/').collect();
223 if components.len() < 3 {
225 return None;
226 }
227
228 let filename = *components.last()?;
229 let version = components[components.len() - 2];
230 let artifact = components[components.len() - 3];
231 let group_parts = &components[..components.len() - 3];
232
233 if group_parts.is_empty() {
234 return None;
235 }
236
237 let expected_prefix = format!("{artifact}-{version}");
239 if !filename.starts_with(&expected_prefix) {
240 return None;
241 }
242
243 let group = group_parts.join(".");
244 Some(format!("{group}:{artifact}:{version}"))
245}
246
247fn build_entries(jar_paths: &[PathBuf], coordinates_map: &CoordinatesMap) -> Vec<ClasspathEntry> {
252 jar_paths
253 .iter()
254 .map(|jar_path| {
255 let coordinates = resolve_coordinates(jar_path, coordinates_map);
256 let source_jar = find_source_jar(jar_path);
257
258 ClasspathEntry {
259 jar_path: jar_path.clone(),
260 coordinates,
261 is_direct: false, source_jar,
263 }
264 })
265 .collect()
266}
267
268fn resolve_coordinates(jar_path: &Path, coordinates_map: &CoordinatesMap) -> Option<String> {
274 if let Some(filename) = jar_path.file_name() {
276 let filename_str = filename.to_string_lossy();
277 if let Some(coord) = coordinates_map.get(filename_str.as_ref()) {
278 return Some(coord.clone());
279 }
280 }
281
282 parse_coursier_coordinates(jar_path)
284}
285
286fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
292 let stem = jar_path.file_stem()?.to_string_lossy();
293 let parent = jar_path.parent()?;
294
295 let sources_jar = parent.join(format!("{stem}-sources.jar"));
297 if sources_jar.exists() {
298 return Some(sources_jar);
299 }
300
301 if let Some(coursier_sources) = find_coursier_source_jar(jar_path)
303 && coursier_sources.exists()
304 {
305 return Some(coursier_sources);
306 }
307
308 None
309}
310
311#[allow(clippy::case_sensitive_file_extension_comparisons)] fn find_coursier_source_jar(jar_path: &Path) -> Option<PathBuf> {
317 let path_str = jar_path.to_str()?;
318 if path_str.ends_with(".jar") && !path_str.ends_with("-sources.jar") {
319 let sources_path = format!("{}-sources.jar", &path_str[..path_str.len() - 4]);
320 Some(PathBuf::from(sources_path))
321 } else {
322 None
323 }
324}
325
326fn try_cache_fallback(
330 config: &ResolveConfig,
331 original_error: &ClasspathError,
332) -> ClasspathResult<Vec<ResolvedClasspath>> {
333 if let Some(ref cache_path) = config.cache_path {
334 if cache_path.exists() {
335 info!("Loading cached classpath from {}", cache_path.display());
336 let content = std::fs::read_to_string(cache_path).map_err(|e| {
337 ClasspathError::CacheError(format!(
338 "Failed to read cache file {}: {e}",
339 cache_path.display()
340 ))
341 })?;
342 let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
343 ClasspathError::CacheError(format!(
344 "Failed to parse cache file {}: {e}",
345 cache_path.display()
346 ))
347 })?;
348 return Ok(cached);
349 }
350 warn!(
351 "Cache file {} does not exist; cannot fall back",
352 cache_path.display()
353 );
354 }
355
356 Err(ClasspathError::ResolutionFailed(format!(
357 "Bazel resolution failed and no cache available. Original error: {original_error}"
358 )))
359}
360
361fn which_binary(name: &str) -> Option<PathBuf> {
365 let path_var = std::env::var_os("PATH")?;
367 for dir in std::env::split_paths(&path_var) {
368 let candidate = dir.join(name);
369 if candidate.is_file() {
370 return Some(candidate);
371 }
372 }
373 None
374}
375
376fn run_command_with_timeout(
378 cmd: &mut Command,
379 timeout_secs: u64,
380) -> ClasspathResult<std::process::Output> {
381 let mut child = cmd
382 .stdout(std::process::Stdio::piped())
383 .spawn()
384 .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
385
386 let timeout = Duration::from_secs(timeout_secs);
387
388 let start = std::time::Instant::now();
390 loop {
391 match child.try_wait() {
392 Ok(Some(_status)) => {
393 return child.wait_with_output().map_err(|e| {
395 ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
396 });
397 }
398 Ok(None) => {
399 if start.elapsed() >= timeout {
400 let _ = child.kill();
402 let _ = child.wait();
403 return Err(ClasspathError::ResolutionFailed(format!(
404 "Command timed out after {timeout_secs}s"
405 )));
406 }
407 std::thread::sleep(Duration::from_millis(100));
408 }
409 Err(e) => {
410 return Err(ClasspathError::ResolutionFailed(format!(
411 "Failed to check process status: {e}"
412 )));
413 }
414 }
415 }
416}
417
418fn infer_module_name(project_root: &Path) -> String {
420 project_root
421 .file_name()
422 .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
423}
424
425#[allow(dead_code)]
427fn coursier_cache_dir() -> Option<PathBuf> {
428 dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
429}
430
431fn dirs_path_home() -> Option<PathBuf> {
433 std::env::var_os("HOME").map(PathBuf::from)
434}
435
436#[cfg(test)]
439mod tests {
440 use super::*;
441 use tempfile::TempDir;
442
443 #[test]
446 fn test_parse_cquery_output_filters_jars() {
447 let output = b"\
448bazel-out/k8-fastbuild/bin/external/maven/com/google/guava/guava/33.0.0/guava-33.0.0.jar
449bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp.jar
450bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp-class.jar
451some/path/to/resource.txt
452another/path/to/data.proto
453";
454
455 let result = parse_cquery_output(output);
456 assert_eq!(result.len(), 3);
457 assert!(
458 result
459 .iter()
460 .all(|p| p.extension().is_some_and(|e| e == "jar"))
461 );
462 }
463
464 #[test]
465 fn test_parse_cquery_output_empty() {
466 let result = parse_cquery_output(b"");
467 assert!(result.is_empty());
468 }
469
470 #[test]
471 fn test_parse_cquery_output_filters_non_jar() {
472 let output = b"\
473/path/to/classes/
474/path/to/resource.xml
475/path/to/source.srcjar
476/path/to/real.jar
477";
478 let result = parse_cquery_output(output);
479 assert_eq!(result.len(), 1);
480 assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
481 }
482
483 #[test]
484 fn test_parse_cquery_output_blank_lines_ignored() {
485 let output = b"\
486/path/a.jar
487
488/path/b.jar
489
490";
491 let result = parse_cquery_output(output);
492 assert_eq!(result.len(), 2);
493 }
494
495 #[test]
498 fn test_maven_install_json_parsing() {
499 let tmp = TempDir::new().unwrap();
500 let json = serde_json::json!({
501 "dependency_tree": {
502 "dependencies": [
503 {
504 "coord": "com.google.guava:guava:33.0.0",
505 "file": "v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
506 },
507 {
508 "coord": "org.slf4j:slf4j-api:2.0.9",
509 "file": "v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
510 }
511 ]
512 }
513 });
514
515 let path = tmp.path().join("maven_install.json");
516 std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
517
518 let map = load_maven_install_json(tmp.path());
519 assert!(map.contains_key("guava-33.0.0.jar"));
520 assert_eq!(map["guava-33.0.0.jar"], "com.google.guava:guava:33.0.0");
521 assert!(map.contains_key("slf4j-api-2.0.9.jar"));
522 assert_eq!(map["slf4j-api-2.0.9.jar"], "org.slf4j:slf4j-api:2.0.9");
523 }
524
525 #[test]
526 fn test_maven_install_json_missing_returns_empty() {
527 let tmp = TempDir::new().unwrap();
528 let map = load_maven_install_json(tmp.path());
529 assert!(map.is_empty());
530 }
531
532 #[test]
533 fn test_maven_install_json_malformed_returns_empty() {
534 let tmp = TempDir::new().unwrap();
535 let path = tmp.path().join("maven_install.json");
536 std::fs::write(&path, "{ invalid json }}}").unwrap();
537
538 let map = load_maven_install_json(tmp.path());
539 assert!(map.is_empty());
540 }
541
542 #[test]
543 fn test_maven_install_json_no_dependency_tree() {
544 let tmp = TempDir::new().unwrap();
545 let path = tmp.path().join("maven_install.json");
546 std::fs::write(&path, r#"{"version": "1.0"}"#).unwrap();
547
548 let map = load_maven_install_json(tmp.path());
549 assert!(map.is_empty());
550 }
551
552 #[test]
553 fn test_maven_install_json_third_party_location() {
554 let tmp = TempDir::new().unwrap();
555 let third_party = tmp.path().join("third_party");
556 std::fs::create_dir_all(&third_party).unwrap();
557 let json = serde_json::json!({
558 "dependency_tree": {
559 "dependencies": [
560 {
561 "coord": "junit:junit:4.13.2",
562 "file": "v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar"
563 }
564 ]
565 }
566 });
567 let path = third_party.join("maven_install.json");
568 std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
569
570 let map = load_maven_install_json(tmp.path());
571 assert!(map.contains_key("junit-4.13.2.jar"));
572 }
573
574 #[test]
577 fn test_derive_jar_filename_from_coord() {
578 assert_eq!(
579 derive_jar_filename_from_coord("com.google.guava:guava:33.0.0"),
580 Some("guava-33.0.0.jar".to_string())
581 );
582 assert_eq!(
583 derive_jar_filename_from_coord("org.slf4j:slf4j-api:2.0.9"),
584 Some("slf4j-api-2.0.9.jar".to_string())
585 );
586 assert_eq!(derive_jar_filename_from_coord("invalid"), None);
587 assert_eq!(derive_jar_filename_from_coord("group:artifact"), None);
588 }
589
590 #[test]
591 fn test_parse_coursier_coordinates() {
592 let path = PathBuf::from(
593 "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
594 );
595 let coords = parse_coursier_coordinates(&path);
596 assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
597 }
598
599 #[test]
600 fn test_parse_coursier_coordinates_single_group() {
601 let path = PathBuf::from(
602 "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar",
603 );
604 let coords = parse_coursier_coordinates(&path);
605 assert_eq!(coords, Some("junit:junit:4.13.2".to_string()));
606 }
607
608 #[test]
609 fn test_parse_coursier_coordinates_not_coursier_path() {
610 let path = PathBuf::from("/usr/local/lib/some.jar");
611 let coords = parse_coursier_coordinates(&path);
612 assert_eq!(coords, None);
613 }
614
615 #[test]
618 fn test_missing_bazel_binary_error() {
619 let tmp = TempDir::new().unwrap();
621 let original_path = std::env::var_os("PATH");
622
623 unsafe { std::env::set_var("PATH", tmp.path()) };
627 let result = find_bazel_binary();
628 if let Some(p) = original_path {
630 unsafe { std::env::set_var("PATH", p) };
631 }
632
633 assert!(result.is_err());
634 let err_msg = result.unwrap_err().to_string();
635 assert!(
636 err_msg.contains("not found"),
637 "Error should mention 'not found': {err_msg}"
638 );
639 }
640
641 #[test]
644 fn test_resolve_no_bazel_no_cache_returns_error() {
645 let tmp = TempDir::new().unwrap();
646 let config = ResolveConfig {
647 project_root: tmp.path().to_path_buf(),
648 timeout_secs: 5,
649 cache_path: None,
650 };
651
652 let result = resolve_bazel_classpath(&config);
654 assert!(result.is_err());
656 }
657
658 #[test]
661 fn test_cache_fallback_loads_cached_classpath() {
662 let tmp = TempDir::new().unwrap();
663 let cache_path = tmp.path().join("classpath_cache.json");
664
665 let cached = vec![ResolvedClasspath {
667 module_name: "cached-project".to_string(),
668 entries: vec![ClasspathEntry {
669 jar_path: PathBuf::from("/cached/guava.jar"),
670 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
671 is_direct: false,
672 source_jar: None,
673 }],
674 }];
675 std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
676
677 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
678 let config = ResolveConfig {
679 project_root: tmp.path().to_path_buf(),
680 timeout_secs: 5,
681 cache_path: Some(cache_path),
682 };
683
684 let result = try_cache_fallback(&config, &original_error);
685 assert!(result.is_ok());
686 let resolved = result.unwrap();
687 assert_eq!(resolved.len(), 1);
688 assert_eq!(resolved[0].module_name, "cached-project");
689 assert_eq!(resolved[0].entries.len(), 1);
690 assert_eq!(
691 resolved[0].entries[0].coordinates,
692 Some("com.google.guava:guava:33.0.0".to_string())
693 );
694 }
695
696 #[test]
697 fn test_cache_fallback_missing_cache_file() {
698 let tmp = TempDir::new().unwrap();
699 let cache_path = tmp.path().join("nonexistent.json");
700 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
701 let config = ResolveConfig {
702 project_root: tmp.path().to_path_buf(),
703 timeout_secs: 5,
704 cache_path: Some(cache_path),
705 };
706
707 let result = try_cache_fallback(&config, &original_error);
708 assert!(result.is_err());
709 }
710
711 #[test]
712 fn test_cache_fallback_no_cache_configured() {
713 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
714 let config = ResolveConfig {
715 project_root: PathBuf::from("/tmp"),
716 timeout_secs: 5,
717 cache_path: None,
718 };
719
720 let result = try_cache_fallback(&config, &original_error);
721 assert!(result.is_err());
722 let err_msg = result.unwrap_err().to_string();
723 assert!(err_msg.contains("no cache available"));
724 }
725
726 #[test]
729 fn test_find_source_jar_same_directory() {
730 let tmp = TempDir::new().unwrap();
731 let main_jar = tmp.path().join("guava-33.0.0.jar");
732 let sources_jar = tmp.path().join("guava-33.0.0-sources.jar");
733 std::fs::write(&main_jar, b"").unwrap();
734 std::fs::write(&sources_jar, b"").unwrap();
735
736 let result = find_source_jar(&main_jar);
737 assert_eq!(result, Some(sources_jar));
738 }
739
740 #[test]
741 fn test_find_source_jar_not_present() {
742 let tmp = TempDir::new().unwrap();
743 let main_jar = tmp.path().join("guava-33.0.0.jar");
744 std::fs::write(&main_jar, b"").unwrap();
745
746 let result = find_source_jar(&main_jar);
747 assert_eq!(result, None);
748 }
749
750 #[test]
753 fn test_build_entries_with_coordinates() {
754 let jar_paths = vec![
755 PathBuf::from("/some/path/guava-33.0.0.jar"),
756 PathBuf::from("/some/path/unknown.jar"),
757 ];
758 let mut coords = CoordinatesMap::new();
759 coords.insert(
760 "guava-33.0.0.jar".to_string(),
761 "com.google.guava:guava:33.0.0".to_string(),
762 );
763
764 let entries = build_entries(&jar_paths, &coords);
765 assert_eq!(entries.len(), 2);
766 assert_eq!(
767 entries[0].coordinates,
768 Some("com.google.guava:guava:33.0.0".to_string())
769 );
770 assert_eq!(entries[1].coordinates, None);
771 assert!(!entries[0].is_direct);
773 assert!(!entries[1].is_direct);
774 }
775
776 #[test]
779 fn test_infer_module_name() {
780 assert_eq!(
781 infer_module_name(Path::new("/home/user/my-project")),
782 "my-project"
783 );
784 assert_eq!(infer_module_name(Path::new("/")), "root");
785 }
786
787 #[test]
790 fn test_find_coursier_source_jar_derivation() {
791 let jar = PathBuf::from("/cache/v1/guava-33.0.0.jar");
792 let result = find_coursier_source_jar(&jar);
793 assert_eq!(
794 result,
795 Some(PathBuf::from("/cache/v1/guava-33.0.0-sources.jar"))
796 );
797 }
798
799 #[test]
800 fn test_find_coursier_source_jar_already_sources() {
801 let jar = PathBuf::from("/cache/v1/guava-33.0.0-sources.jar");
802 let result = find_coursier_source_jar(&jar);
803 assert_eq!(result, None);
804 }
805}