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