use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use log::{debug, info, warn};
use crate::{ClasspathError, ClasspathResult};
use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
const BAZEL_CQUERY_KIND_PATTERN: &str =
r#"kind("java_library|java_import|jvm_import", deps(//...))"#;
const COURSIER_CACHE_REL: &str = ".cache/coursier/v1";
#[allow(clippy::missing_errors_doc)] pub fn resolve_bazel_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
info!(
"Resolving Bazel classpath in {}",
config.project_root.display()
);
match run_bazel_cquery(config) {
Ok(jar_paths) => {
info!("Bazel cquery returned {} JAR paths", jar_paths.len());
let coordinates_map = load_maven_install_json(&config.project_root);
let entries = build_entries(&jar_paths, &coordinates_map);
let resolved = ResolvedClasspath {
module_name: infer_module_name(&config.project_root),
module_root: config.project_root.clone(),
entries,
};
Ok(vec![resolved])
}
Err(e) => {
warn!("Bazel cquery failed: {e}. Attempting cache fallback.");
try_cache_fallback(config, &e)
}
}
}
fn run_bazel_cquery(config: &ResolveConfig) -> ClasspathResult<Vec<PathBuf>> {
let bazel_bin = find_bazel_binary()?;
let mut cmd = Command::new(&bazel_bin);
cmd.arg("cquery")
.arg(BAZEL_CQUERY_KIND_PATTERN)
.arg("--output=files")
.current_dir(&config.project_root)
.stderr(std::process::Stdio::null());
debug!("Running: {} cquery ... --output=files", bazel_bin.display());
let output = run_command_with_timeout(&mut cmd, config.timeout_secs)?;
if !output.status.success() {
return Err(ClasspathError::ResolutionFailed(format!(
"bazel cquery exited with status {}",
output.status
)));
}
let jars = parse_cquery_output(&output.stdout);
Ok(jars)
}
fn find_bazel_binary() -> ClasspathResult<PathBuf> {
which_binary("bazel").ok_or_else(|| {
ClasspathError::ResolutionFailed(
"bazel binary not found on PATH. Install Bazel to resolve classpath.".to_string(),
)
})
}
fn parse_cquery_output(stdout: &[u8]) -> Vec<PathBuf> {
stdout
.lines()
.filter_map(|line| {
let line = line.ok()?;
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.to_ascii_lowercase().ends_with(".jar") {
Some(PathBuf::from(trimmed))
} else {
None
}
})
.collect()
}
#[derive(Debug, serde::Deserialize)]
struct MavenInstallDependency {
coord: String,
#[serde(default)]
file: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct MavenInstallJson {
dependency_tree: Option<DependencyTree>,
}
#[derive(Debug, serde::Deserialize)]
struct DependencyTree {
dependencies: Vec<MavenInstallDependency>,
}
type CoordinatesMap = std::collections::HashMap<String, String>;
fn load_maven_install_json(project_root: &Path) -> CoordinatesMap {
let candidates = [
project_root.join("maven_install.json"),
project_root.join("third_party/maven_install.json"),
];
for path in &candidates {
if let Some(map) = try_parse_maven_install(path) {
info!(
"Loaded {} coordinate mappings from {}",
map.len(),
path.display()
);
return map;
}
}
debug!("No maven_install.json found; coordinate mapping unavailable");
CoordinatesMap::new()
}
fn try_parse_maven_install(path: &Path) -> Option<CoordinatesMap> {
let content = std::fs::read_to_string(path).ok()?;
let parsed: MavenInstallJson = serde_json::from_str(&content).ok()?;
let tree = parsed.dependency_tree?;
let mut map = CoordinatesMap::with_capacity(tree.dependencies.len());
for dep in &tree.dependencies {
if let Some(ref file_path) = dep.file
&& let Some(basename) = Path::new(file_path).file_name()
{
map.insert(basename.to_string_lossy().to_string(), dep.coord.clone());
}
if let Some(derived) = derive_jar_filename_from_coord(&dep.coord) {
map.insert(derived, dep.coord.clone());
}
}
Some(map)
}
fn derive_jar_filename_from_coord(coord: &str) -> Option<String> {
let parts: Vec<&str> = coord.split(':').collect();
if parts.len() >= 3 {
Some(format!("{}-{}.jar", parts[1], parts[2]))
} else {
None
}
}
fn parse_coursier_coordinates(jar_path: &Path) -> Option<String> {
let path_str = jar_path.to_str()?;
let maven2_idx = path_str.find("/maven2/")?;
let after_maven2 = &path_str[maven2_idx + "/maven2/".len()..];
let components: Vec<&str> = after_maven2.split('/').collect();
if components.len() < 3 {
return None;
}
let filename = *components.last()?;
let version = components[components.len() - 2];
let artifact = components[components.len() - 3];
let group_parts = &components[..components.len() - 3];
if group_parts.is_empty() {
return None;
}
let expected_prefix = format!("{artifact}-{version}");
if !filename.starts_with(&expected_prefix) {
return None;
}
let group = group_parts.join(".");
Some(format!("{group}:{artifact}:{version}"))
}
fn build_entries(jar_paths: &[PathBuf], coordinates_map: &CoordinatesMap) -> Vec<ClasspathEntry> {
jar_paths
.iter()
.map(|jar_path| {
let coordinates = resolve_coordinates(jar_path, coordinates_map);
let source_jar = find_source_jar(jar_path);
ClasspathEntry {
jar_path: jar_path.clone(),
coordinates,
is_direct: false, source_jar,
}
})
.collect()
}
fn resolve_coordinates(jar_path: &Path, coordinates_map: &CoordinatesMap) -> Option<String> {
if let Some(filename) = jar_path.file_name() {
let filename_str = filename.to_string_lossy();
if let Some(coord) = coordinates_map.get(filename_str.as_ref()) {
return Some(coord.clone());
}
}
parse_coursier_coordinates(jar_path)
}
fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
let stem = jar_path.file_stem()?.to_string_lossy();
let parent = jar_path.parent()?;
let sources_jar = parent.join(format!("{stem}-sources.jar"));
if sources_jar.exists() {
return Some(sources_jar);
}
if let Some(coursier_sources) = find_coursier_source_jar(jar_path)
&& coursier_sources.exists()
{
return Some(coursier_sources);
}
None
}
#[allow(clippy::case_sensitive_file_extension_comparisons)] fn find_coursier_source_jar(jar_path: &Path) -> Option<PathBuf> {
let path_str = jar_path.to_str()?;
if path_str.ends_with(".jar") && !path_str.ends_with("-sources.jar") {
let sources_path = format!("{}-sources.jar", &path_str[..path_str.len() - 4]);
Some(PathBuf::from(sources_path))
} else {
None
}
}
fn try_cache_fallback(
config: &ResolveConfig,
original_error: &ClasspathError,
) -> ClasspathResult<Vec<ResolvedClasspath>> {
if let Some(ref cache_path) = config.cache_path {
if cache_path.exists() {
info!("Loading cached classpath from {}", cache_path.display());
let content = std::fs::read_to_string(cache_path).map_err(|e| {
ClasspathError::CacheError(format!(
"Failed to read cache file {}: {e}",
cache_path.display()
))
})?;
let cached: Vec<ResolvedClasspath> = serde_json::from_str(&content).map_err(|e| {
ClasspathError::CacheError(format!(
"Failed to parse cache file {}: {e}",
cache_path.display()
))
})?;
return Ok(cached);
}
warn!(
"Cache file {} does not exist; cannot fall back",
cache_path.display()
);
}
Err(ClasspathError::ResolutionFailed(format!(
"Bazel resolution failed and no cache available. Original error: {original_error}"
)))
}
fn which_binary(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn run_command_with_timeout(
cmd: &mut Command,
timeout_secs: u64,
) -> ClasspathResult<std::process::Output> {
let mut child = cmd
.stdout(std::process::Stdio::piped())
.spawn()
.map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn command: {e}")))?;
let timeout = Duration::from_secs(timeout_secs);
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_status)) => {
return child.wait_with_output().map_err(|e| {
ClasspathError::ResolutionFailed(format!("Failed to collect output: {e}"))
});
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(ClasspathError::ResolutionFailed(format!(
"Command timed out after {timeout_secs}s"
)));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(ClasspathError::ResolutionFailed(format!(
"Failed to check process status: {e}"
)));
}
}
}
}
fn infer_module_name(project_root: &Path) -> String {
project_root
.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string())
}
#[allow(dead_code)]
fn coursier_cache_dir() -> Option<PathBuf> {
dirs_path_home().map(|home| home.join(COURSIER_CACHE_REL))
}
fn dirs_path_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_cquery_output_filters_jars() {
let output = b"\
bazel-out/k8-fastbuild/bin/external/maven/com/google/guava/guava/33.0.0/guava-33.0.0.jar
bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp.jar
bazel-out/k8-fastbuild/bin/src/main/java/com/example/libapp-class.jar
some/path/to/resource.txt
another/path/to/data.proto
";
let result = parse_cquery_output(output);
assert_eq!(result.len(), 3);
assert!(
result
.iter()
.all(|p| p.extension().is_some_and(|e| e == "jar"))
);
}
#[test]
fn test_parse_cquery_output_empty() {
let result = parse_cquery_output(b"");
assert!(result.is_empty());
}
#[test]
fn test_parse_cquery_output_filters_non_jar() {
let output = b"\
/path/to/classes/
/path/to/resource.xml
/path/to/source.srcjar
/path/to/real.jar
";
let result = parse_cquery_output(output);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("/path/to/real.jar"));
}
#[test]
fn test_parse_cquery_output_blank_lines_ignored() {
let output = b"\
/path/a.jar
/path/b.jar
";
let result = parse_cquery_output(output);
assert_eq!(result.len(), 2);
}
#[test]
fn test_maven_install_json_parsing() {
let tmp = TempDir::new().unwrap();
let json = serde_json::json!({
"dependency_tree": {
"dependencies": [
{
"coord": "com.google.guava:guava:33.0.0",
"file": "v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
},
{
"coord": "org.slf4j:slf4j-api:2.0.9",
"file": "v1/https/repo1.maven.org/maven2/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
}
]
}
});
let path = tmp.path().join("maven_install.json");
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
let map = load_maven_install_json(tmp.path());
assert!(map.contains_key("guava-33.0.0.jar"));
assert_eq!(map["guava-33.0.0.jar"], "com.google.guava:guava:33.0.0");
assert!(map.contains_key("slf4j-api-2.0.9.jar"));
assert_eq!(map["slf4j-api-2.0.9.jar"], "org.slf4j:slf4j-api:2.0.9");
}
#[test]
fn test_maven_install_json_missing_returns_empty() {
let tmp = TempDir::new().unwrap();
let map = load_maven_install_json(tmp.path());
assert!(map.is_empty());
}
#[test]
fn test_maven_install_json_malformed_returns_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("maven_install.json");
std::fs::write(&path, "{ invalid json }}}").unwrap();
let map = load_maven_install_json(tmp.path());
assert!(map.is_empty());
}
#[test]
fn test_maven_install_json_no_dependency_tree() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("maven_install.json");
std::fs::write(&path, r#"{"version": "1.0"}"#).unwrap();
let map = load_maven_install_json(tmp.path());
assert!(map.is_empty());
}
#[test]
fn test_maven_install_json_third_party_location() {
let tmp = TempDir::new().unwrap();
let third_party = tmp.path().join("third_party");
std::fs::create_dir_all(&third_party).unwrap();
let json = serde_json::json!({
"dependency_tree": {
"dependencies": [
{
"coord": "junit:junit:4.13.2",
"file": "v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar"
}
]
}
});
let path = third_party.join("maven_install.json");
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
let map = load_maven_install_json(tmp.path());
assert!(map.contains_key("junit-4.13.2.jar"));
}
#[test]
fn test_derive_jar_filename_from_coord() {
assert_eq!(
derive_jar_filename_from_coord("com.google.guava:guava:33.0.0"),
Some("guava-33.0.0.jar".to_string())
);
assert_eq!(
derive_jar_filename_from_coord("org.slf4j:slf4j-api:2.0.9"),
Some("slf4j-api-2.0.9.jar".to_string())
);
assert_eq!(derive_jar_filename_from_coord("invalid"), None);
assert_eq!(derive_jar_filename_from_coord("group:artifact"), None);
}
#[test]
fn test_parse_coursier_coordinates() {
let path = PathBuf::from(
"/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
);
let coords = parse_coursier_coordinates(&path);
assert_eq!(coords, Some("com.google.guava:guava:33.0.0".to_string()));
}
#[test]
fn test_parse_coursier_coordinates_single_group() {
let path = PathBuf::from(
"/home/user/.cache/coursier/v1/https/repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar",
);
let coords = parse_coursier_coordinates(&path);
assert_eq!(coords, Some("junit:junit:4.13.2".to_string()));
}
#[test]
fn test_parse_coursier_coordinates_not_coursier_path() {
let path = PathBuf::from("/usr/local/lib/some.jar");
let coords = parse_coursier_coordinates(&path);
assert_eq!(coords, None);
}
#[test]
fn test_missing_bazel_binary_error() {
let tmp = TempDir::new().unwrap();
let original_path = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", tmp.path()) };
let result = find_bazel_binary();
if let Some(p) = original_path {
unsafe { std::env::set_var("PATH", p) };
}
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("not found"),
"Error should mention 'not found': {err_msg}"
);
}
#[test]
fn test_resolve_no_bazel_no_cache_returns_error() {
let tmp = TempDir::new().unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 5,
cache_path: None,
};
let result = resolve_bazel_classpath(&config);
assert!(result.is_err());
}
#[test]
fn test_cache_fallback_loads_cached_classpath() {
let tmp = TempDir::new().unwrap();
let cache_path = tmp.path().join("classpath_cache.json");
let cached = vec![ResolvedClasspath {
module_name: "cached-project".to_string(),
module_root: tmp.path().to_path_buf(),
entries: vec![ClasspathEntry {
jar_path: PathBuf::from("/cached/guava.jar"),
coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
is_direct: false,
source_jar: None,
}],
}];
std::fs::write(&cache_path, serde_json::to_string(&cached).unwrap()).unwrap();
let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 5,
cache_path: Some(cache_path),
};
let result = try_cache_fallback(&config, &original_error);
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].module_name, "cached-project");
assert_eq!(resolved[0].entries.len(), 1);
assert_eq!(
resolved[0].entries[0].coordinates,
Some("com.google.guava:guava:33.0.0".to_string())
);
}
#[test]
fn test_cache_fallback_missing_cache_file() {
let tmp = TempDir::new().unwrap();
let cache_path = tmp.path().join("nonexistent.json");
let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 5,
cache_path: Some(cache_path),
};
let result = try_cache_fallback(&config, &original_error);
assert!(result.is_err());
}
#[test]
fn test_cache_fallback_no_cache_configured() {
let original_error = ClasspathError::ResolutionFailed("bazel not found".to_string());
let config = ResolveConfig {
project_root: PathBuf::from("/tmp"),
timeout_secs: 5,
cache_path: None,
};
let result = try_cache_fallback(&config, &original_error);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("no cache available"));
}
#[test]
fn test_find_source_jar_same_directory() {
let tmp = TempDir::new().unwrap();
let main_jar = tmp.path().join("guava-33.0.0.jar");
let sources_jar = tmp.path().join("guava-33.0.0-sources.jar");
std::fs::write(&main_jar, b"").unwrap();
std::fs::write(&sources_jar, b"").unwrap();
let result = find_source_jar(&main_jar);
assert_eq!(result, Some(sources_jar));
}
#[test]
fn test_find_source_jar_not_present() {
let tmp = TempDir::new().unwrap();
let main_jar = tmp.path().join("guava-33.0.0.jar");
std::fs::write(&main_jar, b"").unwrap();
let result = find_source_jar(&main_jar);
assert_eq!(result, None);
}
#[test]
fn test_build_entries_with_coordinates() {
let jar_paths = vec![
PathBuf::from("/some/path/guava-33.0.0.jar"),
PathBuf::from("/some/path/unknown.jar"),
];
let mut coords = CoordinatesMap::new();
coords.insert(
"guava-33.0.0.jar".to_string(),
"com.google.guava:guava:33.0.0".to_string(),
);
let entries = build_entries(&jar_paths, &coords);
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0].coordinates,
Some("com.google.guava:guava:33.0.0".to_string())
);
assert_eq!(entries[1].coordinates, None);
assert!(!entries[0].is_direct);
assert!(!entries[1].is_direct);
}
#[test]
fn test_infer_module_name() {
assert_eq!(
infer_module_name(Path::new("/home/user/my-project")),
"my-project"
);
assert_eq!(infer_module_name(Path::new("/")), "root");
}
#[test]
fn test_find_coursier_source_jar_derivation() {
let jar = PathBuf::from("/cache/v1/guava-33.0.0.jar");
let result = find_coursier_source_jar(&jar);
assert_eq!(
result,
Some(PathBuf::from("/cache/v1/guava-33.0.0-sources.jar"))
);
}
#[test]
fn test_find_coursier_source_jar_already_sources() {
let jar = PathBuf::from("/cache/v1/guava-33.0.0-sources.jar");
let result = find_coursier_source_jar(&jar);
assert_eq!(result, None);
}
}