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
27#[allow(clippy::missing_errors_doc)] pub fn resolve_bazel_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
38 info!(
39 "Resolving Bazel classpath in {}",
40 config.project_root.display()
41 );
42
43 match run_bazel_cquery(config) {
45 Ok(jar_paths) => {
46 info!("Bazel cquery returned {} JAR paths", jar_paths.len());
47 let coordinates_map = load_maven_install_json(&config.project_root);
48 let entries = build_entries(&jar_paths, &coordinates_map);
49 let resolved = ResolvedClasspath {
50 module_name: infer_module_name(&config.project_root),
51 module_root: config.project_root.clone(),
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 let cache_path = if cache_path.is_dir() {
335 cache_path.join(BAZEL_CACHE_FILE)
336 } else {
337 cache_path.clone()
338 };
339 if cache_path.exists() {
340 info!("Loading cached classpath from {}", cache_path.display());
341 let content = std::fs::read_to_string(&cache_path).map_err(|e| {
342 ClasspathError::CacheError(format!(
343 "Failed to read cache file {}: {e}",
344 cache_path.display()
345 ))
346 })?;
347 let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
348 ClasspathError::CacheError(format!(
349 "Failed to parse cache file {}: {e}",
350 cache_path.display()
351 ))
352 })?;
353 return Ok(cached);
354 }
355 warn!(
356 "Cache file {} does not exist; cannot fall back",
357 cache_path.display()
358 );
359 }
360
361 Err(ClasspathError::ResolutionFailed(format!(
362 "Bazel resolution failed and no cache available. Original error: {original_error}"
363 )))
364}
365
366fn which_binary(name: &str) -> Option<PathBuf> {
370 let path_var = std::env::var_os("PATH")?;
372 for dir in std::env::split_paths(&path_var) {
373 let candidate = dir.join(name);
374 if candidate.is_file() {
375 return Some(candidate);
376 }
377 }
378 None
379}
380
381fn run_command_with_timeout(
383 cmd: &mut Command,
384 timeout_secs: u64,
385) -> ClasspathResult<std::process::Output> {
386 let mut child = cmd
387 .stdout(std::process::Stdio::piped())
388 .spawn()
389 .map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
390
391 let timeout = Duration::from_secs(timeout_secs);
392
393 let start = std::time::Instant::now();
395 loop {
396 match child.try_wait() {
397 Ok(Some(_status)) => {
398 return child.wait_with_output().map_err(|e| {
400 ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
401 });
402 }
403 Ok(None) => {
404 if start.elapsed() >= timeout {
405 let _ = child.kill();
407 let _ = child.wait();
408 return Err(ClasspathError::ResolutionFailed(format!(
409 "Command timed out after {timeout_secs}s"
410 )));
411 }
412 std::thread::sleep(Duration::from_millis(100));
413 }
414 Err(e) => {
415 return Err(ClasspathError::ResolutionFailed(format!(
416 "Failed to check process status: {e}"
417 )));
418 }
419 }
420 }
421}
422
423fn infer_module_name(project_root: &Path) -> String {
425 project_root
426 .file_name()
427 .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
428}
429
430#[cfg(test)]
433mod tests {
434 use super::*;
435 use tempfile::TempDir;
436
437 #[test]
440 fn test_parse_cquery_output_filters_jars() {
441 let output = b"\
442bazel-out/k8-fastbuild/bin/external/maven/com/google/guava/guava/33.0.0/guava-33.0.0.jar
443bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp.jar
444bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp-class.jar
445some/path/to/resource.txt
446another/path/to/data.proto
447";
448
449 let result = parse_cquery_output(output);
450 assert_eq!(result.len(), 3);
451 assert!(
452 result
453 .iter()
454 .all(|p| p.extension().is_some_and(|e| e == "jar"))
455 );
456 }
457
458 #[test]
459 fn test_parse_cquery_output_empty() {
460 let result = parse_cquery_output(b"");
461 assert!(result.is_empty());
462 }
463
464 #[test]
465 fn test_parse_cquery_output_filters_non_jar() {
466 let output = b"\
467/path/to/classes/
468/path/to/resource.xml
469/path/to/source.srcjar
470/path/to/real.jar
471";
472 let result = parse_cquery_output(output);
473 assert_eq!(result.len(), 1);
474 assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
475 }
476
477 #[test]
478 fn test_parse_cquery_output_blank_lines_ignored() {
479 let output = b"\
480/path/a.jar
481
482/path/b.jar
483
484";
485 let result = parse_cquery_output(output);
486 assert_eq!(result.len(), 2);
487 }
488
489 #[test]
492 fn test_maven_install_json_parsing() {
493 let tmp = TempDir::new().unwrap();
494 let json = serde_json::json!({
495 "dependency_tree": {
496 "dependencies": [
497 {
498 "coord": "com.google.guava:guava:33.0.0",
499 "file": "v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
500 },
501 {
502 "coord": "org.slf4j:slf4j-api:2.0.9",
503 "file": "v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
504 }
505 ]
506 }
507 });
508
509 let path = tmp.path().join("maven_install.json");
510 std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
511
512 let map = load_maven_install_json(tmp.path());
513 assert!(map.contains_key("guava-33.0.0.jar"));
514 assert_eq!(map["guava-33.0.0.jar"], "com.google.guava:guava:33.0.0");
515 assert!(map.contains_key("slf4j-api-2.0.9.jar"));
516 assert_eq!(map["slf4j-api-2.0.9.jar"], "org.slf4j:slf4j-api:2.0.9");
517 }
518
519 #[test]
520 fn test_maven_install_json_missing_returns_empty() {
521 let tmp = TempDir::new().unwrap();
522 let map = load_maven_install_json(tmp.path());
523 assert!(map.is_empty());
524 }
525
526 #[test]
527 fn test_maven_install_json_malformed_returns_empty() {
528 let tmp = TempDir::new().unwrap();
529 let path = tmp.path().join("maven_install.json");
530 std::fs::write(&path, "{ invalid json }}}").unwrap();
531
532 let map = load_maven_install_json(tmp.path());
533 assert!(map.is_empty());
534 }
535
536 #[test]
537 fn test_maven_install_json_no_dependency_tree() {
538 let tmp = TempDir::new().unwrap();
539 let path = tmp.path().join("maven_install.json");
540 std::fs::write(&path, r#"{"version": "1.0"}"#).unwrap();
541
542 let map = load_maven_install_json(tmp.path());
543 assert!(map.is_empty());
544 }
545
546 #[test]
547 fn test_maven_install_json_third_party_location() {
548 let tmp = TempDir::new().unwrap();
549 let third_party = tmp.path().join("third_party");
550 std::fs::create_dir_all(&third_party).unwrap();
551 let json = serde_json::json!({
552 "dependency_tree": {
553 "dependencies": [
554 {
555 "coord": "junit:junit:4.13.2",
556 "file": "v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar"
557 }
558 ]
559 }
560 });
561 let path = third_party.join("maven_install.json");
562 std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
563
564 let map = load_maven_install_json(tmp.path());
565 assert!(map.contains_key("junit-4.13.2.jar"));
566 }
567
568 #[test]
571 fn test_derive_jar_filename_from_coord() {
572 assert_eq!(
573 derive_jar_filename_from_coord("com.google.guava:guava:33.0.0"),
574 Some("guava-33.0.0.jar".to_string())
575 );
576 assert_eq!(
577 derive_jar_filename_from_coord("org.slf4j:slf4j-api:2.0.9"),
578 Some("slf4j-api-2.0.9.jar".to_string())
579 );
580 assert_eq!(derive_jar_filename_from_coord("invalid"), None);
581 assert_eq!(derive_jar_filename_from_coord("group:artifact"), None);
582 }
583
584 #[test]
585 fn test_parse_coursier_coordinates() {
586 let path = PathBuf::from(
587 "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
588 );
589 let coords = parse_coursier_coordinates(&path);
590 assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
591 }
592
593 #[test]
594 fn test_parse_coursier_coordinates_single_group() {
595 let path = PathBuf::from(
596 "/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar",
597 );
598 let coords = parse_coursier_coordinates(&path);
599 assert_eq!(coords, Some("junit:junit:4.13.2".to_string()));
600 }
601
602 #[test]
603 fn test_parse_coursier_coordinates_not_coursier_path() {
604 let path = PathBuf::from("/usr/local/lib/some.jar");
605 let coords = parse_coursier_coordinates(&path);
606 assert_eq!(coords, None);
607 }
608
609 #[test]
612 fn test_missing_bazel_binary_error() {
613 let tmp = TempDir::new().unwrap();
615 let original_path = std::env::var_os("PATH");
616
617 unsafe { std::env::set_var("PATH", tmp.path()) };
621 let result = find_bazel_binary();
622 if let Some(p) = original_path {
624 unsafe { std::env::set_var("PATH", p) };
625 }
626
627 assert!(result.is_err());
628 let err_msg = result.unwrap_err().to_string();
629 assert!(
630 err_msg.contains("not found"),
631 "Error should mention 'not found': {err_msg}"
632 );
633 }
634
635 #[test]
638 fn test_resolve_no_bazel_no_cache_returns_error() {
639 let tmp = TempDir::new().unwrap();
640 let config = ResolveConfig {
641 project_root: tmp.path().to_path_buf(),
642 timeout_secs: 5,
643 cache_path: None,
644 };
645
646 let result = resolve_bazel_classpath(&config);
648 assert!(result.is_err());
650 }
651
652 #[test]
655 fn test_cache_fallback_loads_cached_classpath() {
656 let tmp = TempDir::new().unwrap();
657 let cache_path = tmp.path().join("classpath_cache.json");
658
659 let cached = vec![ResolvedClasspath {
661 module_name: "cached-project".to_string(),
662 module_root: tmp.path().to_path_buf(),
663 entries: vec![ClasspathEntry {
664 jar_path: PathBuf::from("/cached/guava.jar"),
665 coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
666 is_direct: false,
667 source_jar: None,
668 }],
669 }];
670 std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
671
672 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
673 let config = ResolveConfig {
674 project_root: tmp.path().to_path_buf(),
675 timeout_secs: 5,
676 cache_path: Some(cache_path),
677 };
678
679 let result = try_cache_fallback(&config, &original_error);
680 assert!(result.is_ok());
681 let resolved = result.unwrap();
682 assert_eq!(resolved.len(), 1);
683 assert_eq!(resolved[0].module_name, "cached-project");
684 assert_eq!(resolved[0].entries.len(), 1);
685 assert_eq!(
686 resolved[0].entries[0].coordinates,
687 Some("com.google.guava:guava:33.0.0".to_string())
688 );
689 }
690
691 #[test]
692 fn test_cache_fallback_missing_cache_file() {
693 let tmp = TempDir::new().unwrap();
694 let cache_path = tmp.path().join("nonexistent.json");
695 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
696 let config = ResolveConfig {
697 project_root: tmp.path().to_path_buf(),
698 timeout_secs: 5,
699 cache_path: Some(cache_path),
700 };
701
702 let result = try_cache_fallback(&config, &original_error);
703 assert!(result.is_err());
704 }
705
706 #[test]
707 fn test_cache_fallback_no_cache_configured() {
708 let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
709 let config = ResolveConfig {
710 project_root: PathBuf::from("/tmp"),
711 timeout_secs: 5,
712 cache_path: None,
713 };
714
715 let result = try_cache_fallback(&config, &original_error);
716 assert!(result.is_err());
717 let err_msg = result.unwrap_err().to_string();
718 assert!(err_msg.contains("no cache available"));
719 }
720
721 #[test]
724 fn test_find_source_jar_same_directory() {
725 let tmp = TempDir::new().unwrap();
726 let main_jar = tmp.path().join("guava-33.0.0.jar");
727 let sources_jar = tmp.path().join("guava-33.0.0-sources.jar");
728 std::fs::write(&main_jar, b"").unwrap();
729 std::fs::write(&sources_jar, b"").unwrap();
730
731 let result = find_source_jar(&main_jar);
732 assert_eq!(result, Some(sources_jar));
733 }
734
735 #[test]
736 fn test_find_source_jar_not_present() {
737 let tmp = TempDir::new().unwrap();
738 let main_jar = tmp.path().join("guava-33.0.0.jar");
739 std::fs::write(&main_jar, b"").unwrap();
740
741 let result = find_source_jar(&main_jar);
742 assert_eq!(result, None);
743 }
744
745 #[test]
748 fn test_build_entries_with_coordinates() {
749 let jar_paths = vec![
750 PathBuf::from("/some/path/guava-33.0.0.jar"),
751 PathBuf::from("/some/path/unknown.jar"),
752 ];
753 let mut coords = CoordinatesMap::new();
754 coords.insert(
755 "guava-33.0.0.jar".to_string(),
756 "com.google.guava:guava:33.0.0".to_string(),
757 );
758
759 let entries = build_entries(&jar_paths, &coords);
760 assert_eq!(entries.len(), 2);
761 assert_eq!(
762 entries[0].coordinates,
763 Some("com.google.guava:guava:33.0.0".to_string())
764 );
765 assert_eq!(entries[1].coordinates, None);
766 assert!(!entries[0].is_direct);
768 assert!(!entries[1].is_direct);
769 }
770
771 #[test]
774 fn test_infer_module_name() {
775 assert_eq!(
776 infer_module_name(Path::new("/home/user/my-project")),
777 "my-project"
778 );
779 assert_eq!(infer_module_name(Path::new("/")), "root");
780 }
781
782 #[test]
785 fn test_find_coursier_source_jar_derivation() {
786 let jar = PathBuf::from("/cache/v1/guava-33.0.0.jar");
787 let result = find_coursier_source_jar(&jar);
788 assert_eq!(
789 result,
790 Some(PathBuf::from("/cache/v1/guava-33.0.0-sources.jar"))
791 );
792 }
793
794 #[test]
795 fn test_find_coursier_source_jar_already_sources() {
796 let jar = PathBuf::from("/cache/v1/guava-33.0.0-sources.jar");
797 let result = find_coursier_source_jar(&jar);
798 assert_eq!(result, None);
799 }
800}