use anyhow::{anyhow, Context, Result};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct Module {
pub org: String,
pub name: String,
}
impl fmt::Display for Module {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.org, self.name)
}
}
fn sanitize_path_segment(s: &str) -> String {
s.replace("..", "_dotdot_").replace(['/', '\\', '\0'], "_")
}
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub enum Scope {
Compile,
Runtime,
Provided,
Test,
System,
Import,
}
impl Scope {
pub fn parse_scope(s: &str) -> Scope {
match s {
"runtime" => Scope::Runtime,
"provided" => Scope::Provided,
"test" => Scope::Test,
"system" => Scope::System,
"import" => Scope::Import,
_ => Scope::Compile, }
}
pub fn is_in_classpath(&self) -> bool {
matches!(self, Scope::Compile | Scope::Runtime)
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct Exclusion {
pub org: String,
pub name: String,
}
impl Exclusion {
pub fn matches(&self, module: &Module) -> bool {
(self.org == "*" || self.org == module.org)
&& (self.name == "*" || self.name == module.name)
}
}
#[derive(Debug, Clone)]
pub struct Dependency {
pub module: Module,
pub version: String,
pub scope: Scope,
pub optional: bool,
pub exclusions: HashSet<Exclusion>,
pub classifier: Option<String>,
}
impl fmt::Display for Dependency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.module, self.version)
}
}
#[derive(Debug, Clone)]
pub struct Project {
pub module: Module,
pub version: String,
pub packaging: String,
pub parent: Option<(Module, String)>, pub dependencies: Vec<Dependency>,
pub dependency_management: Vec<Dependency>,
pub properties: HashMap<String, String>,
pub relocation: Option<Dependency>,
}
pub fn parse_pom(xml: &str) -> Result<Project> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut group_id = String::new();
let mut artifact_id = String::new();
let mut version = String::new();
let mut packaging = String::new();
let mut parent_group_id = String::new();
let mut parent_artifact_id = String::new();
let mut parent_version = String::new();
let mut deps: Vec<Dependency> = Vec::new();
let mut dep_mgmt: Vec<Dependency> = Vec::new();
let mut properties: HashMap<String, String> = HashMap::new();
let mut relocation_group_id = String::new();
let mut relocation_artifact_id = String::new();
let mut relocation_version = String::new();
let mut in_dep = false;
let mut in_dep_mgmt = false;
let mut in_relocation = false;
let mut dep_group_id = String::new();
let mut dep_artifact_id = String::new();
let mut dep_version = String::new();
let mut dep_scope = String::new();
let mut dep_optional = false;
let mut dep_exclusions: HashSet<Exclusion> = HashSet::new();
let mut in_exclusion = false;
let mut excl_group_id = String::new();
let mut excl_artifact_id = String::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
path.push(tag.clone());
match tag.as_str() {
"dependency" => {
let in_project_deps = (path.iter().any(|p| p == "dependencies")
|| path.iter().any(|p| p == "dependencyManagement"))
&& !path
.iter()
.any(|p| p == "plugins" || p == "plugin" || p == "build");
if in_project_deps {
in_dep = true;
dep_group_id.clear();
dep_artifact_id.clear();
dep_version.clear();
dep_scope.clear();
dep_optional = false;
dep_exclusions.clear();
}
}
"dependencyManagement" => in_dep_mgmt = true,
"exclusion" => {
in_exclusion = true;
excl_group_id.clear();
excl_artifact_id.clear();
}
"relocation" => in_relocation = true,
_ => {}
}
}
Ok(Event::End(e)) => {
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
match tag.as_str() {
"dependency" => {
if in_dep && !dep_group_id.is_empty() && !dep_artifact_id.is_empty() {
let dep = Dependency {
module: Module {
org: dep_group_id.clone(),
name: dep_artifact_id.clone(),
},
version: dep_version.clone(),
scope: if dep_scope.is_empty() {
Scope::Compile
} else {
Scope::parse_scope(&dep_scope)
},
optional: dep_optional,
exclusions: dep_exclusions.clone(),
classifier: None,
};
if in_dep_mgmt {
dep_mgmt.push(dep);
} else {
deps.push(dep);
}
}
in_dep = false;
}
"dependencyManagement" => in_dep_mgmt = false,
"exclusion" => {
if in_exclusion {
dep_exclusions.insert(Exclusion {
org: excl_group_id.clone(),
name: excl_artifact_id.clone(),
});
}
in_exclusion = false;
}
"relocation" => in_relocation = false,
_ => {}
}
path.pop();
}
Ok(Event::Text(e)) => {
let text = e.unescape().unwrap_or_default().trim().to_string();
if text.is_empty() {
continue;
}
let full_path = path.join("/");
match full_path.as_str() {
"project/groupId" => group_id = text,
"project/artifactId" => artifact_id = text,
"project/version" => version = text,
"project/packaging" => packaging = text,
"project/parent/groupId" => parent_group_id = text,
"project/parent/artifactId" => parent_artifact_id = text,
"project/parent/version" => parent_version = text,
p if in_dep && p.ends_with("/groupId") && !in_exclusion => {
dep_group_id = text;
}
p if in_dep && p.ends_with("/artifactId") && !in_exclusion => {
dep_artifact_id = text;
}
p if in_dep && p.ends_with("/version") && !in_exclusion => {
dep_version = text;
}
p if in_dep && p.ends_with("/scope") => dep_scope = text,
p if in_dep && p.ends_with("/optional") => {
dep_optional = text == "true";
}
p if in_exclusion && p.ends_with("/groupId") => excl_group_id = text,
p if in_exclusion && p.ends_with("/artifactId") => excl_artifact_id = text,
p if in_relocation && p.ends_with("/groupId") => relocation_group_id = text,
p if in_relocation && p.ends_with("/artifactId") => {
relocation_artifact_id = text;
}
p if in_relocation && p.ends_with("/version") => relocation_version = text,
p if p.starts_with("project/properties/") => {
let key = p.strip_prefix("project/properties/").unwrap();
properties.insert(key.to_string(), text);
}
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => {
return Err(anyhow!(
"XML parse error at {}: {e}",
reader.error_position()
))
}
_ => {}
}
}
if !group_id.is_empty() {
properties.insert("project.groupId".to_string(), group_id.clone());
properties.insert("project.version".to_string(), version.clone());
properties.insert("groupId".to_string(), group_id.clone());
properties.insert("version".to_string(), version.clone());
}
if !artifact_id.is_empty() {
properties.insert("project.artifactId".to_string(), artifact_id.clone());
properties.insert("artifactId".to_string(), artifact_id.clone());
}
if !parent_group_id.is_empty() {
properties.insert(
"project.parent.groupId".to_string(),
parent_group_id.clone(),
);
}
if !parent_artifact_id.is_empty() {
properties.insert(
"project.parent.artifactId".to_string(),
parent_artifact_id.clone(),
);
}
if !parent_version.is_empty() {
properties.insert("project.parent.version".to_string(), parent_version.clone());
}
let substitute = |s: &str, props: &HashMap<String, String>| -> String {
let mut result = s.to_string();
for _ in 0..10 {
let mut changed = false;
for (key, value) in props {
let pattern = format!("${{{key}}}");
if result.contains(&pattern) {
result = result.replace(&pattern, value);
changed = true;
}
}
if !changed {
break;
}
}
result
};
for dep in &mut deps {
dep.module.org = substitute(&dep.module.org, &properties);
dep.module.name = substitute(&dep.module.name, &properties);
dep.version = substitute(&dep.version, &properties);
}
for dep in &mut dep_mgmt {
dep.module.org = substitute(&dep.module.org, &properties);
dep.module.name = substitute(&dep.module.name, &properties);
dep.version = substitute(&dep.version, &properties);
}
let parent = if !parent_group_id.is_empty() && !parent_version.is_empty() {
Some((
Module {
org: parent_group_id,
name: parent_artifact_id,
},
parent_version,
))
} else {
None
};
let relocation = if !relocation_group_id.is_empty() {
Some(Dependency {
module: Module {
org: relocation_group_id,
name: relocation_artifact_id,
},
version: relocation_version,
scope: Scope::Compile,
optional: false,
exclusions: HashSet::new(),
classifier: None,
})
} else {
None
};
if packaging.is_empty() {
packaging = "jar".to_string();
}
Ok(Project {
module: Module {
org: group_id,
name: artifact_id,
},
version,
packaging,
parent,
dependencies: deps,
dependency_management: dep_mgmt,
properties,
relocation,
})
}
#[derive(Debug, Clone)]
pub struct Repository {
pub id: String,
pub url: String,
}
impl Repository {
pub fn central() -> Repository {
Repository {
id: "central".to_string(),
url: "https://repo1.maven.org/maven2".to_string(),
}
}
pub fn metadata_url(&self, module: &Module) -> String {
let group_path = module.org.replace('.', "/");
format!(
"{}/{}/{}/maven-metadata.xml",
self.url.trim_end_matches('/'),
group_path,
module.name
)
}
pub fn pom_url(&self, module: &Module, version: &str) -> String {
let group_path = module.org.replace('.', "/");
format!(
"{}/{}/{}/{}/{}-{}.pom",
self.url.trim_end_matches('/'),
group_path,
module.name,
version,
module.name,
version
)
}
pub fn jar_url(&self, module: &Module, version: &str, classifier: Option<&str>) -> String {
let group_path = module.org.replace('.', "/");
let jar_filename = match classifier {
Some(c) => format!("{}-{}-{}.jar", module.name, version, c),
None => format!("{}-{}.jar", module.name, version),
};
format!(
"{}/{}/{}/{}/{}",
self.url.trim_end_matches('/'),
group_path,
module.name,
version,
jar_filename
)
}
}
pub fn fetch_maven_metadata(module: &Module, repos: &[Repository]) -> Result<MavenMetadata> {
for repo in repos {
let url = repo.metadata_url(module);
match ureq::get(&url).call() {
Ok(response) => {
let body = response
.into_string()
.context("failed to read Maven metadata body")?;
return parse_maven_metadata(&body)
.context(format!("failed to parse Maven metadata for {module}"));
}
Err(_) => continue,
}
}
Err(anyhow!(
"Maven metadata for {module} not found in any repository"
))
}
#[derive(Debug, Clone, Default)]
pub struct MavenMetadata {
pub latest: Option<String>,
pub release: Option<String>,
pub versions: Vec<String>,
}
fn parse_maven_metadata(xml: &str) -> Result<MavenMetadata> {
use quick_xml::events::Event;
use quick_xml::Reader;
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut metadata = MavenMetadata::default();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
path.push(String::from_utf8_lossy(e.name().as_ref()).to_string());
}
Ok(Event::End(_)) => {
path.pop();
}
Ok(Event::Text(e)) => {
let text = e.unescape().unwrap_or_default().trim().to_string();
if text.is_empty() {
continue;
}
match path.join("/").as_str() {
"metadata/versioning/latest" => metadata.latest = Some(text),
"metadata/versioning/release" => metadata.release = Some(text),
"metadata/versioning/versions/version" => metadata.versions.push(text),
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => {
return Err(anyhow!(
"XML parse error at {}: {e}",
reader.error_position()
))
}
_ => {}
}
}
Ok(metadata)
}
pub fn resolve_latest_version(module: &Module, repos: &[Repository]) -> Result<String> {
let metadata = fetch_maven_metadata(module, repos)?;
metadata
.release
.or(metadata.latest)
.or_else(|| highest_version(&metadata.versions))
.ok_or_else(|| anyhow!("Maven metadata for {module} does not list any versions"))
}
fn is_version_range(version: &str) -> bool {
(version.starts_with('[') || version.starts_with('('))
&& (version.ends_with(']') || version.ends_with(')'))
}
fn resolve_version_spec(module: &Module, version: &str, repos: &[Repository]) -> Result<String> {
if is_version_range(version) {
let metadata = fetch_maven_metadata(module, repos)?;
select_version_from_range(version, &metadata.versions)
.ok_or_else(|| anyhow!("no version of {module} matches range {version}"))
} else {
Ok(version.to_string())
}
}
fn select_version_from_range(range: &str, versions: &[String]) -> Option<String> {
let include_lower = range.starts_with('[');
let include_upper = range.ends_with(']');
let body = range.strip_prefix(['[', '('])?.strip_suffix([']', ')'])?;
let (lower, upper) = match body.split_once(',') {
Some((lower, upper)) => (empty_to_none(lower.trim()), empty_to_none(upper.trim())),
None => (empty_to_none(body.trim()), empty_to_none(body.trim())),
};
versions
.iter()
.filter(|version| {
let lower_ok = lower.is_none_or(|lower| {
if include_lower {
compare_versions(version, lower) != std::cmp::Ordering::Less
} else {
compare_versions(version, lower) == std::cmp::Ordering::Greater
}
});
let upper_ok = upper.is_none_or(|upper| {
if include_upper {
compare_versions(version, upper) != std::cmp::Ordering::Greater
} else {
compare_versions(version, upper) == std::cmp::Ordering::Less
}
});
lower_ok && upper_ok
})
.cloned()
.reduce(|a, b| pick_higher_version(&a, &b))
}
fn empty_to_none(value: &str) -> Option<&str> {
if value.is_empty() {
None
} else {
Some(value)
}
}
pub fn fetch_pom(module: &Module, version: &str, repos: &[Repository]) -> Result<Project> {
for repo in repos {
let url = repo.pom_url(module, version);
match ureq::get(&url).call() {
Ok(response) => {
let body = response.into_string().context("failed to read POM body")?;
return parse_pom(&body)
.context(format!("failed to parse POM for {module}:{version}"));
}
Err(_) => continue,
}
}
Err(anyhow!(
"POM for {module}:{version} not found in any repository"
))
}
#[derive(Debug, Clone)]
pub struct ResolvedArtifact {
pub module: Module,
pub version: String,
pub classifier: Option<String>,
}
impl fmt::Display for ResolvedArtifact {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.module, self.version)
}
}
pub fn resolve(
coordinates: &[String],
repos: &[Repository],
_cache_dir: &Path,
) -> Result<Vec<ResolvedArtifact>> {
let mut root_deps: Vec<Dependency> = Vec::new();
for coord in coordinates {
let dep = parse_coordinate(coord)?;
root_deps.push(dep);
}
if root_deps.is_empty() {
return Ok(Vec::new());
}
let mut project_cache: HashMap<(Module, String), Project> = HashMap::new();
let mut resolved: HashMap<Module, (String, Option<String>)> = HashMap::new(); let mut queue: Vec<Dependency> = root_deps.clone();
let mut iterations = 0;
let max_iterations = 2000;
while !queue.is_empty() && iterations < max_iterations {
iterations += 1;
let mut next_queue: Vec<Dependency> = Vec::new();
for dep in &queue {
let requested_version = if let Some((chosen, _)) = resolved.get(&dep.module) {
chosen.clone()
} else {
resolve_version_spec(&dep.module, &dep.version, repos)?
};
let version = requested_version;
let cache_key = (dep.module.clone(), version.clone());
if project_cache.contains_key(&cache_key) {
} else {
let project = fetch_pom(&dep.module, &version, repos)?;
let effective = resolve_parent_chain(&project, repos, &mut project_cache)?;
if let Some(ref reloc) = effective.relocation {
resolved.remove(&dep.module);
resolved.insert(reloc.module.clone(), (reloc.version.clone(), None));
let reloc_key = (reloc.module.clone(), reloc.version.clone());
if !project_cache.contains_key(&reloc_key) {
next_queue.push(reloc.clone());
}
continue;
}
project_cache.insert(cache_key.clone(), effective);
}
resolved.insert(dep.module.clone(), (version, dep.classifier.clone()));
}
for dep in &queue {
let (version, _) = resolved
.get(&dep.module)
.cloned()
.unwrap_or_else(|| (dep.version.clone(), dep.classifier.clone()));
let cache_key = (dep.module.clone(), version.clone());
if let Some(project) = project_cache.get(&cache_key) {
let transitive = extract_dependencies(project, dep);
for t in transitive {
let effective_version = match resolved.get(&t.module) {
Some((existing, _)) => {
let requested = resolve_version_spec(&t.module, &t.version, repos)?;
let higher = pick_higher_version(existing, &requested);
resolved
.insert(t.module.clone(), (higher.clone(), t.classifier.clone()));
higher
}
None => {
let requested = resolve_version_spec(&t.module, &t.version, repos)?;
resolved.insert(
t.module.clone(),
(requested.clone(), t.classifier.clone()),
);
requested
}
};
let t_key = (t.module.clone(), effective_version.clone());
if !project_cache.contains_key(&t_key) {
next_queue.push(Dependency {
module: t.module.clone(),
version: effective_version,
scope: t.scope,
optional: t.optional,
exclusions: t.exclusions,
classifier: t.classifier.clone(),
});
}
}
}
}
queue = next_queue;
}
if iterations >= max_iterations {
return Err(anyhow!(
"resolution did not converge after {max_iterations} iterations"
));
}
let mut artifacts: Vec<ResolvedArtifact> = resolved
.iter()
.map(|(module, (version, classifier))| ResolvedArtifact {
module: module.clone(),
version: version.clone(),
classifier: classifier.clone(),
})
.collect();
artifacts.sort_by(|a, b| {
a.module
.org
.cmp(&b.module.org)
.then_with(|| a.module.name.cmp(&b.module.name))
});
Ok(artifacts)
}
pub fn resolve_classpath(
coordinates: &[String],
repos: &[Repository],
cache_dir: &Path,
) -> Result<Vec<PathBuf>> {
let artifacts = resolve(coordinates, repos, cache_dir)?;
fs::create_dir_all(cache_dir).context("failed to create cache directory")?;
for artifact in &artifacts {
if let Err(e) = download_jar(artifact, repos, cache_dir) {
eprintln!("warning: could not download JAR for {artifact}: {e:#}");
}
}
let mut paths: Vec<PathBuf> = Vec::new();
for artifact in &artifacts {
let group_path = sanitize_path_segment(&artifact.module.org).replace('.', "/");
let artifact_name = sanitize_path_segment(&artifact.module.name);
let version = sanitize_path_segment(&artifact.version);
let jar_name = match &artifact.classifier {
Some(c) => format!(
"{}-{}-{}.jar",
artifact_name,
version,
sanitize_path_segment(c)
),
None => format!("{}-{}.jar", artifact_name, version),
};
let jar_path = cache_dir
.join(&group_path)
.join(&artifact_name)
.join(&jar_name);
if jar_path.exists() {
paths.push(jar_path);
}
}
Ok(paths)
}
pub fn parse_coordinate(coord: &str) -> Result<Dependency> {
let parts: Vec<&str> = coord.split(':').collect();
if parts.len() < 3 {
return Err(anyhow!(
"invalid Maven coordinate '{coord}' (expected groupId:artifactId[:classifier]:version)"
));
}
let (org, name, classifier, version) = if parts.len() >= 4 {
(
parts[0].to_string(),
parts[1].to_string(),
Some(parts[2].to_string()),
parts[3].to_string(),
)
} else {
(
parts[0].to_string(),
parts[1].to_string(),
None,
parts[2].to_string(),
)
};
Ok(Dependency {
module: Module { org, name },
version,
scope: Scope::Compile,
optional: false,
exclusions: HashSet::new(),
classifier,
})
}
fn extract_dependencies(project: &Project, from_dep: &Dependency) -> Vec<Dependency> {
let mut deps: Vec<Dependency> = Vec::new();
for dep in &project.dependencies {
if !dep.scope.is_in_classpath() {
continue;
}
if dep.optional {
continue;
}
let mut effective = dep.clone();
if effective.version.is_empty() {
if let Some(managed) = project
.dependency_management
.iter()
.find(|m| m.module == effective.module)
{
effective.version = managed.version.clone();
if effective.scope == Scope::Compile && managed.scope != Scope::Compile {
effective.scope = managed.scope;
}
}
}
if effective.version.is_empty() {
continue;
}
if from_dep
.exclusions
.iter()
.any(|ex| ex.matches(&effective.module))
{
continue;
}
effective.exclusions.extend(from_dep.exclusions.clone());
deps.push(effective);
}
deps
}
fn resolve_parent_chain(
project: &Project,
repos: &[Repository],
cache: &mut HashMap<(Module, String), Project>,
) -> Result<Project> {
resolve_parent_chain_inner(project, repos, cache, &mut HashSet::new(), 0)
}
fn resolve_parent_chain_inner(
project: &Project,
repos: &[Repository],
cache: &mut HashMap<(Module, String), Project>,
seen: &mut HashSet<(Module, String)>,
depth: usize,
) -> Result<Project> {
const MAX_DEPTH: usize = 50;
if depth > MAX_DEPTH {
return Err(anyhow!(
"parent POM chain exceeds {MAX_DEPTH} levels — possible circular reference"
));
}
let mut effective = project.clone();
if let Some((ref parent_module, ref parent_version)) = project.parent {
let cache_key = (parent_module.clone(), parent_version.clone());
if seen.contains(&cache_key) {
return Err(anyhow!(
"circular parent POM reference detected: {}:{}",
parent_module,
parent_version
));
}
seen.insert(cache_key.clone());
let parent = if let Some(p) = cache.get(&cache_key) {
p.clone()
} else {
let p = fetch_pom(parent_module, parent_version, repos)?;
let resolved = resolve_parent_chain_inner(&p, repos, cache, seen, depth + 1)?;
cache.insert(cache_key.clone(), resolved.clone());
resolved
};
if effective.module.org.is_empty() {
effective.module.org = parent.module.org.clone();
}
if effective.version.is_empty() {
effective.version = parent.version.clone();
}
for (key, value) in &parent.properties {
effective
.properties
.entry(key.clone())
.or_insert(value.clone());
}
for managed in &parent.dependency_management {
if !effective
.dependency_management
.iter()
.any(|m| m.module == managed.module)
{
effective.dependency_management.push(managed.clone());
}
}
}
let mut bom_imports: Vec<Dependency> = Vec::new();
for dep in &effective.dependency_management {
if dep.scope == Scope::Import {
bom_imports.push(dep.clone());
}
}
for bom in &bom_imports {
let cache_key = (bom.module.clone(), bom.version.clone());
let bom_project = if let Some(p) = cache.get(&cache_key) {
p.clone()
} else {
match fetch_pom(&bom.module, &bom.version, repos) {
Ok(p) => {
let resolved = resolve_parent_chain(&p, repos, cache)?;
cache.insert(cache_key.clone(), resolved.clone());
resolved
}
Err(_) => continue, }
};
for managed in &bom_project.dependency_management {
if !effective
.dependency_management
.iter()
.any(|m| m.module == managed.module)
{
effective.dependency_management.push(managed.clone());
}
}
}
if !effective.module.org.is_empty() {
effective
.properties
.insert("project.groupId".to_string(), effective.module.org.clone());
effective
.properties
.insert("groupId".to_string(), effective.module.org.clone());
}
if !effective.version.is_empty() {
effective
.properties
.insert("project.version".to_string(), effective.version.clone());
effective
.properties
.insert("version".to_string(), effective.version.clone());
}
if !effective.module.name.is_empty() {
effective.properties.insert(
"project.artifactId".to_string(),
effective.module.name.clone(),
);
effective
.properties
.insert("artifactId".to_string(), effective.module.name.clone());
}
let substitute = |s: &str, props: &HashMap<String, String>| -> String {
let mut result = s.to_string();
for _ in 0..10 {
let mut changed = false;
for (key, value) in props {
let pattern = format!("${{{key}}}");
if result.contains(&pattern) {
result = result.replace(&pattern, value);
changed = true;
}
}
if !changed {
break;
}
}
result
};
for dep in &mut effective.dependencies {
dep.module.org = substitute(&dep.module.org, &effective.properties);
dep.module.name = substitute(&dep.module.name, &effective.properties);
dep.version = substitute(&dep.version, &effective.properties);
}
for dep in &mut effective.dependency_management {
dep.module.org = substitute(&dep.module.org, &effective.properties);
dep.module.name = substitute(&dep.module.name, &effective.properties);
dep.version = substitute(&dep.version, &effective.properties);
}
Ok(effective)
}
fn highest_version(versions: &[String]) -> Option<String> {
versions
.iter()
.cloned()
.reduce(|a, b| pick_higher_version(&a, &b))
}
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
if a == b {
return std::cmp::Ordering::Equal;
}
if pick_higher_version(a, b) == a {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Less
}
}
fn pick_higher_version(a: &str, b: &str) -> String {
if a == b {
return a.to_string();
}
let pa = parse_version_parts(a);
let pb = parse_version_parts(b);
for (sa, sb) in pa.iter().zip(pb.iter()) {
let oa = qualifier_order(sa);
let ob = qualifier_order(sb);
if oa != ob {
if oa > ob {
return a.to_string();
} else {
return b.to_string();
}
}
match (sa.parse::<u64>(), sb.parse::<u64>()) {
(Ok(na), Ok(nb)) => {
if na > nb {
return a.to_string();
} else if nb > na {
return b.to_string();
}
}
_ => {
if sa > sb {
return a.to_string();
} else if sb > sa {
return b.to_string();
}
}
}
}
let a_tail = pa.get(pb.len()).map(|s| qualifier_order(s));
let b_tail = pb.get(pa.len()).map(|s| qualifier_order(s));
match (a_tail, b_tail) {
(Some(qa), None) => {
if qa >= 10 {
a.to_string()
} else {
b.to_string()
}
}
(None, Some(qb)) => {
if qb >= 10 {
b.to_string()
} else {
a.to_string()
}
}
_ => {
if pa.len() >= pb.len() {
a.to_string()
} else {
b.to_string()
}
}
}
}
fn qualifier_order(part: &str) -> u32 {
let lower = part.to_lowercase();
if lower == "snapshot" || lower.ends_with("-snapshot") {
0
} else if lower.starts_with("alpha") || lower == "a" {
1
} else if lower.starts_with("beta") || lower == "b" {
2
} else if lower.starts_with("milestone") || lower == "m" {
3
} else if lower.starts_with("rc") || lower.starts_with("cr") {
4
} else {
10 }
}
fn parse_version_parts(v: &str) -> Vec<String> {
v.split(['.', '-']).map(|s| s.to_string()).collect()
}
fn download_jar(
artifact: &ResolvedArtifact,
repos: &[Repository],
cache_dir: &Path,
) -> Result<PathBuf> {
let group_path = sanitize_path_segment(&artifact.module.org).replace('.', "/");
let artifact_name = sanitize_path_segment(&artifact.module.name);
let version = sanitize_path_segment(&artifact.version);
let jar_name = match &artifact.classifier {
Some(c) => format!(
"{}-{}-{}.jar",
artifact_name,
version,
sanitize_path_segment(c)
),
None => format!("{}-{}.jar", artifact_name, version),
};
let jar_path = cache_dir
.join(&group_path)
.join(&artifact_name)
.join(&jar_name);
if jar_path.exists() {
return Ok(jar_path);
}
if let Some(cached) = probe_local_caches(&artifact.module, &artifact.version, &jar_name) {
if let Some(parent) = jar_path.parent() {
fs::create_dir_all(parent)?;
}
symlink_or_copy(&cached, &jar_path)?;
return Ok(jar_path);
}
for repo in repos {
let url = repo.jar_url(
&artifact.module,
&artifact.version,
artifact.classifier.as_deref(),
);
match ureq::get(&url).call() {
Ok(response) => {
if let Some(parent) = jar_path.parent() {
fs::create_dir_all(parent)?;
}
let mut body = response.into_reader();
let mut file = std::fs::File::create(&jar_path)
.context(format!("failed to create {jar_path:?}"))?;
std::io::copy(&mut body, &mut file)?;
if let Err(e) = verify_sha1(&url, &jar_path) {
let _ = fs::remove_file(&jar_path);
eprintln!("warning: SHA1 verification failed for {artifact}: {e}");
continue;
}
return Ok(jar_path);
}
Err(_) => continue,
}
}
Err(anyhow!("JAR for {artifact} not found in any repository"))
}
fn verify_sha1(jar_url: &str, jar_path: &Path) -> Result<()> {
use sha1::Digest;
let sha1_url = format!("{jar_url}.sha1");
let expected = match ureq::get(&sha1_url).call() {
Ok(response) => {
let mut text = String::new();
response.into_reader().read_to_string(&mut text)?;
text.split_whitespace()
.next()
.ok_or_else(|| anyhow!("empty SHA1 response for {sha1_url}"))?
.to_string()
}
Err(_) => {
return Ok(());
}
};
let mut hasher = sha1::Sha1::new();
let mut file = std::fs::File::open(jar_path)?;
std::io::copy(&mut file, &mut hasher)?;
let actual = format!("{:x}", hasher.finalize());
if actual != expected {
anyhow::bail!(
"SHA1 mismatch: expected {expected}, got {actual} for {}",
jar_path.display()
);
}
Ok(())
}
fn symlink_or_copy(src: &Path, dst: &Path) -> Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(src, dst)
.context(format!("failed to symlink {:?} → {:?}", src, dst))?;
}
#[cfg(not(unix))]
{
fs::copy(src, dst).context(format!("failed to copy {:?} → {:?}", src, dst))?;
}
Ok(())
}
pub fn probe_local_caches(module: &Module, version: &str, jar_name: &str) -> Option<PathBuf> {
dirs::home_dir().and_then(|home| probe_local_caches_with_home(module, version, jar_name, &home))
}
pub fn probe_local_caches_with_home(
module: &Module,
version: &str,
jar_name: &str,
home: &Path,
) -> Option<PathBuf> {
let group_path = module.org.replace('.', "/");
let maven_path = home
.join(".m2/repository")
.join(&group_path)
.join(&module.name)
.join(version)
.join(jar_name);
if maven_path.exists() {
return Some(maven_path);
}
let gradle_base = home
.join(".gradle/caches/modules-2/files-2.1")
.join(&module.org)
.join(&module.name)
.join(version);
if gradle_base.is_dir() {
if let Ok(entries) = fs::read_dir(&gradle_base) {
for hash_dir in entries.flatten() {
if let Ok(files) = fs::read_dir(hash_dir.path()) {
for file in files.flatten() {
if file.file_name() == jar_name {
return Some(file.path());
}
}
}
}
}
}
let coursier_path = home
.join(".cache/coursier/v1/https/repo1.maven.org/maven2")
.join(&group_path)
.join(&module.name)
.join(version)
.join(jar_name);
if coursier_path.exists() {
return Some(coursier_path);
}
None
}
use std::fs;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_coordinate() {
let dep = parse_coordinate("com.google:guava:33.0.0").unwrap();
assert_eq!(dep.module.org, "com.google");
assert_eq!(dep.module.name, "guava");
assert_eq!(dep.version, "33.0.0");
}
#[test]
fn rejects_invalid_coordinate() {
assert!(parse_coordinate("com.google:guava").is_err());
assert!(parse_coordinate("").is_err());
}
#[test]
fn parses_pom_basic() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.module.org, "com.example");
assert_eq!(project.module.name, "my-app");
assert_eq!(project.version, "1.0.0");
assert_eq!(project.packaging, "jar");
assert_eq!(project.dependencies.len(), 2);
assert_eq!(project.dependencies[0].module.org, "org.slf4j");
assert_eq!(project.dependencies[0].scope, Scope::Compile);
assert_eq!(project.dependencies[1].module.org, "junit");
assert_eq!(project.dependencies[1].scope, Scope::Test);
}
#[test]
fn parses_pom_with_exclusions() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.google</groupId>
<artifactId>guava</artifactId>
<version>33.0.0</version>
<exclusions>
<exclusion>
<groupId>com.google</groupId>
<artifactId>failureaccess</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.dependencies.len(), 1);
assert_eq!(project.dependencies[0].exclusions.len(), 1);
let excl = project.dependencies[0].exclusions.iter().next().unwrap();
assert_eq!(excl.org, "com.google");
assert_eq!(excl.name, "failureaccess");
}
#[test]
fn extracts_only_compile_and_runtime_deps() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.13</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.1</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
let parent_dep = Dependency {
module: Module {
org: "test".to_string(),
name: "parent".to_string(),
},
version: "1.0".to_string(),
scope: Scope::Compile,
optional: false,
exclusions: HashSet::new(),
classifier: None,
};
let deps = extract_dependencies(&project, &parent_dep);
assert_eq!(deps.len(), 2);
assert_eq!(deps[0].module.name, "slf4j-api");
assert_eq!(deps[1].module.name, "slf4j-simple");
}
#[test]
fn skips_optional_deps() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
let parent_dep = Dependency {
module: Module {
org: "test".to_string(),
name: "parent".to_string(),
},
version: "1.0".to_string(),
scope: Scope::Compile,
optional: false,
exclusions: HashSet::new(),
classifier: None,
};
let deps = extract_dependencies(&project, &parent_dep);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].module.name, "slf4j-api");
}
#[test]
fn applies_exclusions_from_parent() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>com.google</groupId>
<artifactId>failureaccess</artifactId>
<version>1.0.2</version>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
let parent_dep = Dependency {
module: Module {
org: "test".to_string(),
name: "parent".to_string(),
},
version: "1.0".to_string(),
scope: Scope::Compile,
optional: false,
exclusions: HashSet::from([Exclusion {
org: "com.google".to_string(),
name: "failureaccess".to_string(),
}]),
classifier: None,
};
let deps = extract_dependencies(&project, &parent_dep);
assert!(deps.is_empty(), "excluded dep should be filtered out");
}
#[test]
fn parses_pom_with_properties() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<slf4j.version>2.0.13</slf4j.version>
</properties>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.dependencies[0].version, "2.0.13");
}
#[test]
fn substitutes_project_parent_version_property_in_dependencies() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.2.3</version>
</parent>
<artifactId>child</artifactId>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>service</artifactId>
<version>${project.parent.version}</version>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.dependencies[0].version, "1.2.3");
}
#[test]
fn resolves_chained_properties() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<jackson.version>${jackson.core.version}</jackson.version>
<jackson.core.version>2.17.0</jackson.core.version>
</properties>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.dependencies[0].version, "2.17.0");
}
#[test]
fn sanitize_path_segment_blocks_traversal() {
assert_eq!(sanitize_path_segment("../../etc"), "_dotdot___dotdot__etc");
assert_eq!(sanitize_path_segment("foo/bar"), "foo_bar");
assert_eq!(sanitize_path_segment("foo\\bar"), "foo_bar");
assert_eq!(sanitize_path_segment("normal"), "normal");
}
#[test]
fn parses_dependency_management() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<dependencyManagement>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version></version>
</dependency>
</dependencies>
</project>"#;
let project = parse_pom(xml).unwrap();
assert_eq!(project.dependency_management.len(), 1);
let parent_dep = Dependency {
module: Module {
org: "test".to_string(),
name: "parent".to_string(),
},
version: "1.0".to_string(),
scope: Scope::Compile,
optional: false,
exclusions: HashSet::new(),
classifier: None,
};
let deps = extract_dependencies(&project, &parent_dep);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].version, "2.0.13");
}
#[test]
fn version_comparison() {
assert_eq!(pick_higher_version("1.0", "2.0"), "2.0");
assert_eq!(pick_higher_version("2.0", "1.0"), "2.0");
assert_eq!(pick_higher_version("1.0.0", "1.0.1"), "1.0.1");
assert_eq!(pick_higher_version("1.0.0", "1.0.0"), "1.0.0");
assert_eq!(
pick_higher_version("33.3.1-jre", "33.3.0-jre"),
"33.3.1-jre"
);
assert_eq!(pick_higher_version("2.0.0-SNAPSHOT", "2.0.0"), "2.0.0");
assert_eq!(pick_higher_version("2.0.0", "2.0.0-SNAPSHOT"), "2.0.0");
assert_eq!(pick_higher_version("1.0-alpha1", "1.0"), "1.0");
assert_eq!(pick_higher_version("1.0", "1.0-beta1"), "1.0");
assert_eq!(pick_higher_version("1.0-RC1", "1.0"), "1.0");
assert_eq!(pick_higher_version("1.0-alpha1", "1.0-beta1"), "1.0-beta1");
assert_eq!(pick_higher_version("1.0-beta1", "1.0-RC1"), "1.0-RC1");
assert_eq!(pick_higher_version("1.0-RC1", "1.0-SNAPSHOT"), "1.0-RC1");
assert_eq!(pick_higher_version("1.0", "1.0.0"), "1.0.0");
}
#[test]
fn version_range_selects_highest_matching_metadata_version() {
let versions = vec![
"1.79".to_string(),
"1.80".to_string(),
"1.80.1".to_string(),
"1.81".to_string(),
];
assert_eq!(
select_version_from_range("[1.80,1.81)", &versions).unwrap(),
"1.80.1"
);
}
#[test]
fn parses_pom_with_relocation() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<groupId>com.example</groupId>
<artifactId>old-artifact</artifactId>
<version>1.0.0</version>
<distributionManagement>
<relocation>
<groupId>com.newexample</groupId>
<artifactId>new-artifact</artifactId>
<version>2.0.0</version>
</relocation>
</distributionManagement>
</project>"#;
let project = parse_pom(xml).unwrap();
assert!(project.relocation.is_some());
let rel = project.relocation.unwrap();
assert_eq!(rel.module.org, "com.newexample");
assert_eq!(rel.module.name, "new-artifact");
assert_eq!(rel.version, "2.0.0");
}
#[test]
fn classifier_is_parsed_from_four_segment_coordinate() {
let dep = parse_coordinate("org.example:lib:sources:1.0").unwrap();
assert_eq!(dep.module.org, "org.example");
assert_eq!(dep.module.name, "lib");
assert_eq!(dep.classifier, Some("sources".to_string()));
assert_eq!(dep.version, "1.0");
}
#[test]
fn classifier_is_none_from_three_segment_coordinate() {
let dep = parse_coordinate("org.example:lib:1.0").unwrap();
assert_eq!(dep.classifier, None);
}
#[test]
fn probes_maven_local_repo() {
let tmp = tempfile::tempdir().unwrap();
let maven_repo = tmp.path().join(".m2/repository/com/example/lib/1.0");
fs::create_dir_all(&maven_repo).unwrap();
let jar = maven_repo.join("lib-1.0.jar");
fs::write(&jar, "fake").unwrap();
let module = Module {
org: "com.example".to_string(),
name: "lib".to_string(),
};
let result = probe_local_caches_with_home(&module, "1.0", "lib-1.0.jar", tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), jar);
}
#[test]
fn probes_gradle_cache() {
let tmp = tempfile::tempdir().unwrap();
let hash_dir = tmp
.path()
.join(".gradle/caches/modules-2/files-2.1/com.example/lib/1.0/abcdef1234");
fs::create_dir_all(&hash_dir).unwrap();
let jar = hash_dir.join("lib-1.0.jar");
fs::write(&jar, "fake").unwrap();
let module = Module {
org: "com.example".to_string(),
name: "lib".to_string(),
};
let result = probe_local_caches_with_home(&module, "1.0", "lib-1.0.jar", tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), jar);
}
#[test]
fn probes_coursier_cache() {
let tmp = tempfile::tempdir().unwrap();
let coursier_path = tmp
.path()
.join(".cache/coursier/v1/https/repo1.maven.org/maven2/com/example/lib/1.0");
fs::create_dir_all(&coursier_path).unwrap();
let jar = coursier_path.join("lib-1.0.jar");
fs::write(&jar, "fake").unwrap();
let module = Module {
org: "com.example".to_string(),
name: "lib".to_string(),
};
let result = probe_local_caches_with_home(&module, "1.0", "lib-1.0.jar", tmp.path());
assert!(result.is_some());
assert_eq!(result.unwrap(), jar);
}
#[test]
fn returns_none_when_not_cached() {
let tmp = tempfile::tempdir().unwrap();
let module = Module {
org: "com.nonexistent".to_string(),
name: "nothing".to_string(),
};
let result = probe_local_caches_with_home(&module, "9.9", "nothing-9.9.jar", tmp.path());
assert!(result.is_none());
}
}