use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use log::{debug, info, warn};
use serde::{Deserialize, Serialize};
use super::{ClasspathEntry, ResolveConfig, ResolvedClasspath};
use crate::{ClasspathError, ClasspathResult};
const MAVEN_CACHE_FILE: &str = "maven-resolved-classpath.json";
#[cfg(unix)]
const CLASSPATH_SEPARATOR: char = ':';
#[cfg(windows)]
const CLASSPATH_SEPARATOR: char = ';';
#[allow(clippy::missing_errors_doc)] pub fn resolve_maven_classpath(config: &ResolveConfig) -> ClasspathResult<Vec<ResolvedClasspath>> {
let pom_path = config.project_root.join("pom.xml");
if !pom_path.exists() {
return Err(ClasspathError::ResolutionFailed(
"pom.xml not found in project root".to_string(),
));
}
let modules = detect_modules(&pom_path);
let maven_repo = default_maven_repo();
if modules.is_empty() {
resolve_single_project(config, &maven_repo)
} else {
resolve_multi_module(config, &modules, &maven_repo)
}
}
fn resolve_single_project(
config: &ResolveConfig,
maven_repo: &Path,
) -> ClasspathResult<Vec<ResolvedClasspath>> {
match resolve_via_subprocess(&config.project_root, config.timeout_secs, maven_repo) {
Ok(resolved) => {
write_maven_cache(config, std::slice::from_ref(&resolved));
Ok(vec![resolved])
}
Err(e) => {
warn!("Maven subprocess resolution failed: {e}");
try_cache_or_fallback(
config,
&[ModuleInfo::root(&config.project_root)],
maven_repo,
)
}
}
}
fn resolve_multi_module(
config: &ResolveConfig,
modules: &[String],
maven_repo: &Path,
) -> ClasspathResult<Vec<ResolvedClasspath>> {
let module_infos: Vec<ModuleInfo> = modules
.iter()
.map(|m| ModuleInfo {
name: m.clone(),
root: config.project_root.join(m),
})
.collect();
let mut results = Vec::new();
let mut any_failed = false;
for info in &module_infos {
if !info.root.join("pom.xml").exists() {
warn!("Module '{}' has no pom.xml, skipping", info.name);
continue;
}
match resolve_module_via_subprocess(info, config.timeout_secs, maven_repo) {
Ok(resolved) => results.push(resolved),
Err(e) => {
warn!("Maven resolution failed for module '{}': {e}", info.name);
any_failed = true;
}
}
}
if any_failed && results.is_empty() {
return try_cache_or_fallback(config, &module_infos, maven_repo);
}
if !results.is_empty() {
write_maven_cache(config, &results);
}
Ok(results)
}
struct ModuleInfo {
name: String,
root: PathBuf,
}
impl ModuleInfo {
fn root(project_root: &Path) -> Self {
Self {
name: String::new(),
root: project_root.to_path_buf(),
}
}
}
fn resolve_via_subprocess(
module_root: &Path,
timeout_secs: u64,
maven_repo: &Path,
) -> ClasspathResult<ResolvedClasspath> {
let classpath_output = run_maven_build_classpath(module_root, timeout_secs)?;
let entries = parse_classpath_string(&classpath_output, maven_repo);
Ok(ResolvedClasspath {
module_name: String::new(),
entries,
})
}
fn resolve_module_via_subprocess(
info: &ModuleInfo,
timeout_secs: u64,
maven_repo: &Path,
) -> ClasspathResult<ResolvedClasspath> {
let classpath_output = run_maven_build_classpath(&info.root, timeout_secs)?;
let entries = parse_classpath_string(&classpath_output, maven_repo);
Ok(ResolvedClasspath {
module_name: info.name.clone(),
entries,
})
}
fn run_maven_build_classpath(working_dir: &Path, timeout_secs: u64) -> ClasspathResult<String> {
let temp_dir = tempfile::tempdir()
.map_err(|e| ClasspathError::ResolutionFailed(format!("tempdir: {e}")))?;
let output_file = temp_dir.path().join("classpath.txt");
let mvn_cmd = find_mvn_command(working_dir);
let mut command = Command::new(&mvn_cmd);
command
.arg("dependency:build-classpath")
.arg("-DincludeScope=compile")
.arg(format!("-Dmdep.outputFile={}", output_file.display()))
.arg("-q")
.arg("--batch-mode")
.current_dir(working_dir);
debug!(
"Running Maven: {} dependency:build-classpath in {}",
mvn_cmd,
working_dir.display()
);
let output = run_command_with_timeout(&mut command, Duration::from_secs(timeout_secs))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ClasspathError::ResolutionFailed(format!(
"mvn dependency:build-classpath failed (exit {}): {}",
output.status,
stderr.chars().take(500).collect::<String>()
)));
}
let classpath = std::fs::read_to_string(&output_file).map_err(|e| {
ClasspathError::ResolutionFailed(format!(
"Failed to read Maven classpath output file {}: {e}",
output_file.display()
))
})?;
Ok(classpath.trim().to_string())
}
fn find_mvn_command(working_dir: &Path) -> String {
#[cfg(unix)]
let wrapper = working_dir.join("mvnw");
#[cfg(windows)]
let wrapper = working_dir.join("mvnw.cmd");
if wrapper.exists() {
wrapper.display().to_string()
} else {
"mvn".to_string()
}
}
fn run_command_with_timeout(
command: &mut Command,
timeout: Duration,
) -> ClasspathResult<std::process::Output> {
let mut child = command
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| ClasspathError::ResolutionFailed(format!("Failed to spawn mvn: {e}")))?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
return collect_child_output(child, status);
}
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return Err(ClasspathError::ResolutionFailed(format!(
"mvn timed out after {}s",
timeout.as_secs()
)));
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(ClasspathError::ResolutionFailed(format!(
"Failed to check mvn process status: {e}"
)));
}
}
}
}
#[allow(clippy::unnecessary_wraps)] fn collect_child_output(
mut child: std::process::Child,
status: std::process::ExitStatus,
) -> ClasspathResult<std::process::Output> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(ref mut out) = child.stdout {
let _ = out.read_to_end(&mut stdout);
}
if let Some(ref mut err) = child.stderr {
let _ = err.read_to_end(&mut stderr);
}
Ok(std::process::Output {
status,
stdout,
stderr,
})
}
#[must_use]
pub fn parse_classpath_string(classpath: &str, maven_repo: &Path) -> Vec<ClasspathEntry> {
if classpath.is_empty() {
return Vec::new();
}
classpath
.split(CLASSPATH_SEPARATOR)
.filter(|p| !p.is_empty())
.map(|p| {
let jar_path = PathBuf::from(p.trim());
let coordinate = extract_coordinates_from_repo_path(&jar_path, maven_repo);
let source_jar = find_source_jar(&jar_path);
ClasspathEntry {
jar_path,
coordinates: coordinate,
is_direct: true, source_jar,
}
})
.collect()
}
#[must_use]
pub fn extract_coordinates_from_repo_path(jar_path: &Path, maven_repo: &Path) -> Option<String> {
let jar_path_str = normalize_path(jar_path);
let repo_str = normalize_path(maven_repo);
let relative = jar_path_str
.strip_prefix(&repo_str)?
.trim_start_matches('/');
if relative.is_empty() {
return None;
}
let parts: Vec<&str> = relative.split('/').collect();
if parts.len() < 4 {
return None;
}
let version = parts[parts.len() - 2];
let artifact_id = parts[parts.len() - 3];
let group_parts = &parts[..parts.len() - 3];
if group_parts.is_empty() {
return None;
}
let group_id = group_parts.join(".");
Some(format!("{group_id}:{artifact_id}:{version}"))
}
fn normalize_path(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
#[allow(clippy::case_sensitive_file_extension_comparisons)] fn find_source_jar(jar_path: &Path) -> Option<PathBuf> {
let file_name = jar_path.file_name()?.to_str()?;
if !file_name.ends_with(".jar") {
return None;
}
let stem = file_name.strip_suffix(".jar")?;
let source_name = format!("{stem}-sources.jar");
let source_path = jar_path.with_file_name(source_name);
if source_path.exists() {
Some(source_path)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PomDependency {
pub group_id: String,
pub artifact_id: String,
pub version: Option<String>,
pub scope: Option<String>,
}
#[must_use]
pub fn parse_pom_dependencies(pom_content: &str) -> Vec<PomDependency> {
let mut deps = Vec::new();
let mut search_from = 0;
loop {
let Some(start) = pom_content[search_from..].find("<dependency>") else {
break;
};
let abs_start = search_from + start;
let Some(end) = pom_content[abs_start..].find("</dependency>") else {
break;
};
let abs_end = abs_start + end + "</dependency>".len();
let block = &pom_content[abs_start..abs_end];
search_from = abs_end;
let Some(group_id) = extract_xml_element(block, "groupId") else {
continue;
};
let Some(artifact_id) = extract_xml_element(block, "artifactId") else {
continue;
};
if group_id.contains("${") || artifact_id.contains("${") {
continue;
}
let version = extract_xml_element(block, "version");
let scope = extract_xml_element(block, "scope");
if scope.as_deref() == Some("test") {
continue;
}
deps.push(PomDependency {
group_id,
artifact_id,
version,
scope,
});
}
deps
}
fn extract_xml_element(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)?;
let content_start = start + open.len();
let end = xml[content_start..].find(&close)?;
let content = xml[content_start..content_start + end].trim();
if content.is_empty() {
None
} else {
Some(content.to_string())
}
}
fn resolve_from_pom_fallback(
module_root: &Path,
module_name: &str,
maven_repo: &Path,
) -> ClasspathResult<ResolvedClasspath> {
let pom_path = module_root.join("pom.xml");
let pom_content = std::fs::read_to_string(&pom_path).map_err(|e| {
ClasspathError::ResolutionFailed(format!(
"Failed to read pom.xml at {}: {e}",
pom_path.display()
))
})?;
let deps = parse_pom_dependencies(&pom_content);
let mut entries = Vec::new();
let display_name = if module_name.is_empty() {
"<root>"
} else {
module_name
};
for dep in &deps {
let Some(version) = &dep.version else {
warn!(
"Skipping {}:{} in {} — no version (may be from dependencyManagement)",
dep.group_id, dep.artifact_id, display_name
);
continue;
};
if version.contains("${") {
warn!(
"Skipping {}:{}:{} in {} — version contains property placeholder",
dep.group_id, dep.artifact_id, version, display_name
);
continue;
}
let jar_path =
construct_maven_jar_path(maven_repo, &dep.group_id, &dep.artifact_id, version);
if jar_path.exists() {
let source_jar = find_source_jar(&jar_path);
let coordinates = format!("{}:{}:{}", dep.group_id, dep.artifact_id, version);
entries.push(ClasspathEntry {
jar_path,
coordinates: Some(coordinates),
is_direct: true,
source_jar,
});
} else {
warn!(
"JAR not found in local repo for {}: {}:{}:{} (expected at {})",
display_name,
dep.group_id,
dep.artifact_id,
version,
jar_path.display()
);
}
}
info!(
"POM fallback for '{}': {} entries resolved",
display_name,
entries.len()
);
Ok(ResolvedClasspath {
module_name: module_name.to_string(),
entries,
})
}
#[must_use]
pub fn construct_maven_jar_path(
maven_repo: &Path,
group_id: &str,
artifact_id: &str,
version: &str,
) -> PathBuf {
let group_path = group_id.replace('.', "/");
maven_repo
.join(group_path)
.join(artifact_id)
.join(version)
.join(format!("{artifact_id}-{version}.jar"))
}
#[must_use]
pub fn detect_modules(pom_path: &Path) -> Vec<String> {
let content = match std::fs::read_to_string(pom_path) {
Ok(c) => c,
Err(e) => {
warn!("Could not read pom.xml at {}: {e}", pom_path.display());
return Vec::new();
}
};
let Some(modules_start) = content.find("<modules>") else {
return Vec::new();
};
let Some(modules_end) = content[modules_start..].find("</modules>") else {
return Vec::new();
};
let modules_block = &content[modules_start..modules_start + modules_end];
let mut modules = Vec::new();
let mut search_from = 0;
loop {
let Some(start) = modules_block[search_from..].find("<module>") else {
break;
};
let abs_start = search_from + start + "<module>".len();
let Some(end) = modules_block[abs_start..].find("</module>") else {
break;
};
let module_name = modules_block[abs_start..abs_start + end].trim();
if !module_name.is_empty() {
modules.push(module_name.to_string());
}
search_from = abs_start + end + "</module>".len();
}
debug!("Detected Maven modules: {modules:?}");
modules
}
fn write_maven_cache(config: &ResolveConfig, entries: &[ResolvedClasspath]) {
let dir = cache_dir(config);
if let Err(e) = std::fs::create_dir_all(&dir) {
warn!("Could not create Maven cache dir: {e}");
return;
}
let cache_path = dir.join(MAVEN_CACHE_FILE);
match serde_json::to_string_pretty(entries) {
Ok(json) => {
if let Err(e) = std::fs::write(&cache_path, &json) {
warn!("Could not write Maven cache: {e}");
} else {
debug!("Wrote Maven cache to {}", cache_path.display());
}
}
Err(e) => warn!("Could not serialize Maven cache: {e}"),
}
}
fn read_maven_cache(config: &ResolveConfig) -> Option<Vec<ResolvedClasspath>> {
let cache_path = cache_dir(config).join(MAVEN_CACHE_FILE);
let data = std::fs::read_to_string(&cache_path).ok()?;
serde_json::from_str(&data).ok()
}
fn cache_dir(config: &ResolveConfig) -> PathBuf {
config
.cache_path
.clone()
.unwrap_or_else(|| config.project_root.join(".sqry").join("classpath"))
}
#[allow(clippy::unnecessary_wraps)] fn try_cache_or_fallback(
config: &ResolveConfig,
module_infos: &[ModuleInfo],
maven_repo: &Path,
) -> ClasspathResult<Vec<ResolvedClasspath>> {
if let Some(cached) = read_maven_cache(config) {
info!("Using cached Maven classpath ({} modules)", cached.len());
return Ok(cached);
}
info!("Falling back to pom.xml parsing (lossy, no transitive deps)");
let mut results = Vec::new();
for info in module_infos {
let pom_path = info.root.join("pom.xml");
if !pom_path.exists() {
continue;
}
match resolve_from_pom_fallback(&info.root, &info.name, maven_repo) {
Ok(resolved) => results.push(resolved),
Err(e) => {
warn!("POM fallback failed for '{}': {e}", info.name);
}
}
}
if results.is_empty() {
warn!("All Maven resolution strategies exhausted; returning empty classpath");
}
Ok(results)
}
fn default_maven_repo() -> PathBuf {
#[cfg(unix)]
let home = std::env::var_os("HOME").map(PathBuf::from);
#[cfg(windows)]
let home = std::env::var_os("USERPROFILE").map(PathBuf::from);
#[cfg(not(any(unix, windows)))]
let home: Option<PathBuf> = None;
home.map_or_else(
|| PathBuf::from(".m2").join("repository"),
|h| h.join(".m2").join("repository"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_classpath_string_basic() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = "/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar\
:/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 2);
assert_eq!(
entries[0].jar_path,
PathBuf::from(
"/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
)
);
assert_eq!(
entries[0].coordinates.as_deref(),
Some("com.google.guava:guava:33.0.0")
);
assert_eq!(
entries[1].jar_path,
PathBuf::from(
"/home/user/.m2/repository/org/slf4j/slf4j-api/2.0.9/slf4j-api-2.0.9.jar"
)
);
assert_eq!(
entries[1].coordinates.as_deref(),
Some("org.slf4j:slf4j-api:2.0.9")
);
}
#[test]
fn test_parse_classpath_string_empty() {
let repo = PathBuf::from("/home/user/.m2/repository");
let entries = parse_classpath_string("", &repo);
assert!(entries.is_empty());
}
#[test]
fn test_parse_classpath_string_single_entry() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].coordinates.as_deref(),
Some("junit:junit:4.13.2")
);
}
#[test]
fn test_parse_classpath_non_repo_path() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = "/opt/custom/lib/some.jar";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0].jar_path,
PathBuf::from("/opt/custom/lib/some.jar")
);
assert!(entries[0].coordinates.is_none());
}
#[test]
fn test_extract_coordinates_guava() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from(
"/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar",
);
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert_eq!(coords.as_deref(), Some("com.google.guava:guava:33.0.0"));
}
#[test]
fn test_extract_coordinates_simple_group() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar");
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert_eq!(coords.as_deref(), Some("junit:junit:4.13.2"));
}
#[test]
fn test_extract_coordinates_outside_repo() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from("/opt/lib/foo.jar");
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert!(coords.is_none());
}
#[test]
fn test_extract_coordinates_too_short_path() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from("/home/user/.m2/repository/foo/bar");
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert!(coords.is_none());
}
#[test]
fn test_extract_coordinates_deep_group() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from(
"/home/user/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar",
);
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert_eq!(
coords.as_deref(),
Some("org.apache.commons:commons-lang3:3.14.0")
);
}
#[test]
fn test_extract_coordinates_repo_root_itself() {
let repo = PathBuf::from("/home/user/.m2/repository");
let jar = PathBuf::from("/home/user/.m2/repository");
let coords = extract_coordinates_from_repo_path(&jar, &repo);
assert!(coords.is_none());
}
#[test]
fn test_detect_modules_multi() {
let tmp = TempDir::new().unwrap();
let pom = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<modules>
<module>core</module>
<module>web</module>
<module>api</module>
</modules>
</project>"#;
let pom_path = tmp.path().join("pom.xml");
std::fs::write(&pom_path, pom).unwrap();
let modules = detect_modules(&pom_path);
assert_eq!(modules, vec!["core", "web", "api"]);
}
#[test]
fn test_detect_modules_none() {
let tmp = TempDir::new().unwrap();
let pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>single</artifactId>
<version>1.0.0</version>
</project>"#;
let pom_path = tmp.path().join("pom.xml");
std::fs::write(&pom_path, pom).unwrap();
let modules = detect_modules(&pom_path);
assert!(modules.is_empty());
}
#[test]
fn test_detect_modules_missing_pom() {
let modules = detect_modules(Path::new("/nonexistent/pom.xml"));
assert!(modules.is_empty());
}
#[test]
fn test_detect_modules_whitespace_handling() {
let tmp = TempDir::new().unwrap();
let pom = r"<project>
<modules>
<module> core </module>
<module>
api
</module>
</modules>
</project>";
let pom_path = tmp.path().join("pom.xml");
std::fs::write(&pom_path, pom).unwrap();
let modules = detect_modules(&pom_path);
assert_eq!(modules, vec!["core", "api"]);
}
#[test]
fn test_construct_maven_jar_path() {
let repo = PathBuf::from("/home/user/.m2/repository");
let path = construct_maven_jar_path(&repo, "com.google.guava", "guava", "33.0.0");
assert_eq!(
path,
PathBuf::from(
"/home/user/.m2/repository/com/google/guava/guava/33.0.0/guava-33.0.0.jar"
)
);
}
#[test]
fn test_construct_maven_jar_path_simple_group() {
let repo = PathBuf::from("/home/user/.m2/repository");
let path = construct_maven_jar_path(&repo, "junit", "junit", "4.13.2");
assert_eq!(
path,
PathBuf::from("/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar")
);
}
#[test]
fn test_parse_pom_dependencies_basic() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].group_id, "com.google.guava");
assert_eq!(deps[0].artifact_id, "guava");
assert_eq!(deps[0].version.as_deref(), Some("33.0.0"));
assert_eq!(deps[1].group_id, "org.slf4j");
assert_eq!(deps[1].artifact_id, "slf4j-api");
assert_eq!(deps[1].version.as_deref(), Some("2.0.9"));
}
#[test]
fn test_parse_pom_dependencies_skips_test_scope() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.0.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].artifact_id, "guava");
}
#[test]
fn test_parse_pom_dependencies_skips_property_placeholders() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>internal-lib</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.example</groupId>
<artifactId>real-dep</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].group_id, "org.example");
}
#[test]
fn test_parse_pom_dependencies_no_version() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>managed-dep</artifactId>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 1);
assert!(deps[0].version.is_none());
}
#[test]
fn test_parse_pom_dependencies_with_compile_scope() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>dep</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].scope.as_deref(), Some("compile"));
}
#[test]
fn test_parse_pom_empty_dependencies() {
let pom = r"<project>
<dependencies>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert!(deps.is_empty());
}
#[test]
fn test_parse_pom_no_dependencies_element() {
let pom = r"<project>
<groupId>com.example</groupId>
</project>";
let deps = parse_pom_dependencies(pom);
assert!(deps.is_empty());
}
#[test]
fn test_parse_classpath_string_trailing_separator() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = "/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar:";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 1);
}
#[test]
fn test_parse_classpath_string_leading_separator() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = ":/home/user/.m2/repository/junit/junit/4.13.2/junit-4.13.2.jar";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 1);
}
#[test]
fn test_parse_classpath_string_double_separator() {
let repo = PathBuf::from("/home/user/.m2/repository");
let classpath = "/a.jar::/b.jar";
let entries = parse_classpath_string(classpath, &repo);
assert_eq!(entries.len(), 2);
}
#[test]
fn test_extract_xml_element_missing() {
let xml = "<dependency><groupId>g</groupId></dependency>";
assert!(extract_xml_element(xml, "artifactId").is_none());
}
#[test]
fn test_extract_xml_element_empty() {
let xml = "<dependency><groupId></groupId></dependency>";
assert!(extract_xml_element(xml, "groupId").is_none());
}
#[test]
fn test_cache_roundtrip() {
let tmp = TempDir::new().unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 60,
cache_path: Some(tmp.path().join("cache")),
};
let entries = vec![ResolvedClasspath {
module_name: "core".to_string(),
entries: vec![ClasspathEntry {
jar_path: PathBuf::from("/repo/guava/guava/33.0.0/guava-33.0.0.jar"),
coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
is_direct: true,
source_jar: None,
}],
}];
write_maven_cache(&config, &entries);
let loaded = read_maven_cache(&config);
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].module_name, "core");
assert_eq!(loaded[0].entries.len(), 1);
assert_eq!(
loaded[0].entries[0].coordinates.as_deref(),
Some("com.google.guava:guava:33.0.0")
);
}
#[test]
fn test_cache_read_missing_returns_none() {
let tmp = TempDir::new().unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 60,
cache_path: Some(tmp.path().join("nonexistent-cache")),
};
assert!(read_maven_cache(&config).is_none());
}
#[test]
fn test_source_jar_found() {
let tmp = TempDir::new().unwrap();
let jar = tmp.path().join("guava-33.0.0.jar");
let source = tmp.path().join("guava-33.0.0-sources.jar");
std::fs::write(&jar, b"").unwrap();
std::fs::write(&source, b"").unwrap();
let result = find_source_jar(&jar);
assert_eq!(result, Some(source));
}
#[test]
fn test_source_jar_not_present() {
let tmp = TempDir::new().unwrap();
let jar = tmp.path().join("guava-33.0.0.jar");
std::fs::write(&jar, b"").unwrap();
let result = find_source_jar(&jar);
assert!(result.is_none());
}
#[test]
fn test_source_jar_non_jar_file() {
let result = find_source_jar(Path::new("/some/file.txt"));
assert!(result.is_none());
}
#[test]
fn test_resolve_from_pom_fallback_with_local_jars() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let jar_dir = repo.join("com/example/mylib/1.0.0");
std::fs::create_dir_all(&jar_dir).unwrap();
std::fs::write(jar_dir.join("mylib-1.0.0.jar"), b"fake jar").unwrap();
let pom = r"<project>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>mylib</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.missing</groupId>
<artifactId>nolib</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
</project>";
std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
let result = resolve_from_pom_fallback(tmp.path(), "", &repo).unwrap();
assert_eq!(result.entries.len(), 1);
assert_eq!(
result.entries[0].coordinates.as_deref(),
Some("com.example:mylib:1.0.0")
);
}
#[test]
fn test_resolve_maven_classpath_no_pom() {
let tmp = TempDir::new().unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 10,
cache_path: None,
};
let result = resolve_maven_classpath(&config);
assert!(result.is_err());
}
#[test]
fn test_resolve_maven_classpath_falls_back_to_pom_when_mvn_missing() {
let tmp = TempDir::new().unwrap();
let pom = r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>dep</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>";
std::fs::write(tmp.path().join("pom.xml"), pom).unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 5,
cache_path: None,
};
let result = resolve_maven_classpath(&config);
assert!(result.is_ok());
}
#[test]
fn test_resolve_maven_classpath_multimodule_falls_back() {
let tmp = TempDir::new().unwrap();
let root_pom = r"<project>
<modules>
<module>core</module>
<module>web</module>
</modules>
</project>";
std::fs::write(tmp.path().join("pom.xml"), root_pom).unwrap();
let core_dir = tmp.path().join("core");
std::fs::create_dir_all(&core_dir).unwrap();
std::fs::write(
core_dir.join("pom.xml"),
r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>core-dep</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>",
)
.unwrap();
let web_dir = tmp.path().join("web");
std::fs::create_dir_all(&web_dir).unwrap();
std::fs::write(
web_dir.join("pom.xml"),
r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>web-dep</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
</project>",
)
.unwrap();
let config = ResolveConfig {
project_root: tmp.path().to_path_buf(),
timeout_secs: 5,
cache_path: None,
};
let result = resolve_maven_classpath(&config);
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.len(), 2);
}
#[test]
fn test_find_mvn_command_no_wrapper() {
let tmp = TempDir::new().unwrap();
let cmd = find_mvn_command(tmp.path());
assert_eq!(cmd, "mvn");
}
#[test]
fn test_find_mvn_command_with_wrapper() {
let tmp = TempDir::new().unwrap();
#[cfg(unix)]
let wrapper_name = "mvnw";
#[cfg(windows)]
let wrapper_name = "mvnw.cmd";
std::fs::write(tmp.path().join(wrapper_name), b"#!/bin/sh\nexec mvn \"$@\"").unwrap();
let cmd = find_mvn_command(tmp.path());
assert!(cmd.contains("mvnw"), "Expected wrapper path, got: {cmd}");
}
#[test]
fn test_parse_pom_dependencies_version_with_property() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>org.example</groupId>
<artifactId>lib</artifactId>
<version>${lib.version}</version>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].version.as_deref(), Some("${lib.version}"));
}
#[test]
fn test_parse_pom_dependencies_multiple_scopes() {
let pom = r"<project>
<dependencies>
<dependency>
<groupId>a</groupId>
<artifactId>compile-dep</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>b</groupId>
<artifactId>runtime-dep</artifactId>
<version>1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>c</groupId>
<artifactId>provided-dep</artifactId>
<version>1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>d</groupId>
<artifactId>test-dep</artifactId>
<version>1.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>";
let deps = parse_pom_dependencies(pom);
assert_eq!(deps.len(), 3);
assert_eq!(deps[0].artifact_id, "compile-dep");
assert_eq!(deps[1].artifact_id, "runtime-dep");
assert_eq!(deps[2].artifact_id, "provided-dep");
}
}