use anyhow::{bail, Context, Result};
use console::style;
use rayon::prelude::*;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, atomic::{AtomicUsize, Ordering}};
use crate::config::schema::{Lockfile, ResolvedDependency};
fn format_bytes(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.0} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
fn format_eta(secs: u64) -> String {
if secs == 0 { return String::new(); }
if secs >= 60 {
format!("ETA {}m{}s", secs / 60, secs % 60)
} else {
format!("ETA {}s", secs)
}
}
fn resolver_progress(msg: &str) {
if crate::SPINNER_ACTIVE.load(std::sync::atomic::Ordering::Relaxed) {
crate::set_spinner_msg(msg);
} else if crate::is_progress_quiet() {
eprintln!("{} {}", style(format!("{:>12}", "Resolving")).green().bold(), msg);
} else {
eprint!("\r{} {} ", style(format!("{:>12}", "Resolving")).green().bold(), msg);
}
}
fn is_pom_only_cached(jar_path: &Path) -> bool {
let marker = jar_path.with_extension("jar.pom-only");
match std::fs::read_to_string(&marker) {
Ok(content) => {
let created: u64 = content.trim().parse().unwrap_or(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
now.saturating_sub(created) < 7 * 24 * 3600
}
Err(_) => false,
}
}
fn platform_classifier() -> &'static str {
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
"linux-x86_64"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
"linux-arm64"
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
"macosx-x86_64"
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
"macosx-arm64"
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
"windows-x86_64"
} else {
""
}
}
fn append_native_jars(jars: &mut Vec<PathBuf>) {
let classifier = platform_classifier();
if classifier.is_empty() { return; }
let mut extras = Vec::new();
for jar in jars.iter() {
if let Some(name) = jar.file_name().and_then(|n| n.to_str()) {
if let Some(stem) = name.strip_suffix(".jar") {
if stem.ends_with(classifier) { continue; }
let native_name = format!("{}-{}.jar", stem, classifier);
let native_jar = jar.with_file_name(&native_name);
if native_jar.exists() {
extras.push(native_jar);
}
}
}
}
jars.extend(extras);
}
#[derive(Clone, Debug)]
pub struct RegistryEntry {
pub url: String,
pub scope: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Clone)]
pub struct MavenCoord {
pub group_id: String,
pub artifact_id: String,
pub version: String,
pub classifier: Option<String>,
pub exclusions: Vec<String>,
pub scope: Option<String>,
}
impl MavenCoord {
pub fn parse(coord: &str, version: &str) -> Result<Self> {
let parts: Vec<&str> = coord.split(':').collect();
if parts.len() < 2 || parts.len() > 3 {
bail!(
"Invalid dependency coordinate: '{}'. Expected format: groupId:artifactId[:classifier]",
coord
);
}
let clean_version = version.trim_start_matches('^').trim_start_matches('~');
let classifier = if parts.len() == 3 && !parts[2].is_empty() {
Some(parts[2].to_string())
} else {
None
};
Ok(MavenCoord {
group_id: parts[0].to_string(),
artifact_id: parts[1].to_string(),
version: clean_version.to_string(),
classifier,
exclusions: Vec::new(),
scope: None,
})
}
pub fn from_versioned_key(key: &str) -> Option<MavenCoord> {
let parts: Vec<&str> = key.split(':').collect();
if parts.len() >= 3 {
Some(MavenCoord {
group_id: parts[0].to_string(),
artifact_id: parts[1].to_string(),
version: parts[2].to_string(),
classifier: parts.get(3).map(|s| s.to_string()),
exclusions: Vec::new(),
scope: None,
})
} else {
None
}
}
#[allow(dead_code)]
pub fn key(&self) -> String {
format!("{}:{}", self.group_id, self.artifact_id)
}
pub fn versioned_key(&self) -> String {
match &self.classifier {
Some(c) => format!("{}:{}:{}:{}", self.group_id, self.artifact_id, self.version, c),
None => format!("{}:{}:{}", self.group_id, self.artifact_id, self.version),
}
}
fn group_path(&self) -> String {
self.group_id.replace('.', "/")
}
pub fn is_snapshot(&self) -> bool {
self.version.ends_with("-SNAPSHOT")
}
pub fn jar_url(&self, repo: &str) -> String {
let classifier_suffix = self.classifier.as_ref()
.map(|c| format!("-{}", c))
.unwrap_or_default();
format!(
"{}/{}/{}/{}/{}-{}{}.jar",
repo,
self.group_path(),
self.artifact_id,
self.version,
self.artifact_id,
self.version,
classifier_suffix
)
}
pub fn pom_url(&self, repo: &str) -> String {
format!(
"{}/{}/{}/{}/{}-{}.pom",
repo,
self.group_path(),
self.artifact_id,
self.version,
self.artifact_id,
self.version
)
}
fn snapshot_jar_url(&self, repo: &str, timestamp: &str, build_number: &str) -> String {
let base_version = self.version.trim_end_matches("-SNAPSHOT");
let classifier_suffix = self.classifier.as_ref()
.map(|c| format!("-{}", c))
.unwrap_or_default();
format!(
"{}/{}/{}/{}/{}-{}-{}-{}{}.jar",
repo,
self.group_path(),
self.artifact_id,
self.version,
self.artifact_id,
base_version,
timestamp,
build_number,
classifier_suffix
)
}
fn snapshot_pom_url(&self, repo: &str, timestamp: &str, build_number: &str) -> String {
let base_version = self.version.trim_end_matches("-SNAPSHOT");
format!(
"{}/{}/{}/{}/{}-{}-{}-{}.pom",
repo,
self.group_path(),
self.artifact_id,
self.version,
self.artifact_id,
base_version,
timestamp,
build_number
)
}
fn metadata_url(&self, repo: &str) -> String {
format!(
"{}/{}/{}/{}/maven-metadata.xml",
repo,
self.group_path(),
self.artifact_id,
self.version
)
}
pub fn jar_path(&self, cache: &Path) -> PathBuf {
let classifier_suffix = self.classifier.as_ref()
.map(|c| format!("-{}", c))
.unwrap_or_default();
cache
.join(&self.group_id)
.join(&self.artifact_id)
.join(&self.version)
.join(format!("{}-{}{}.jar", self.artifact_id, self.version, classifier_suffix))
}
pub fn pom_path(&self, cache: &Path) -> PathBuf {
cache
.join(&self.group_id)
.join(&self.artifact_id)
.join(&self.version)
.join(format!("{}-{}.pom", self.artifact_id, self.version))
}
}
pub(crate) fn version_compare(a: &str, b: &str) -> i32 {
let (base_a, qual_a) = if let Some(pos) = a.find('-') {
(&a[..pos], Some(&a[pos + 1..]))
} else {
(a, None)
};
let (base_b, qual_b) = if let Some(pos) = b.find('-') {
(&b[..pos], Some(&b[pos + 1..]))
} else {
(b, None)
};
let parse_base = |s: &str| -> Vec<i64> {
s.split('.').map(|seg| seg.parse::<i64>().unwrap_or(0)).collect()
};
let va = parse_base(base_a);
let vb = parse_base(base_b);
let len = va.len().max(vb.len());
for i in 0..len {
let sa = va.get(i).copied().unwrap_or(0);
let sb = vb.get(i).copied().unwrap_or(0);
if sa < sb { return -1; }
if sa > sb { return 1; }
}
match (qual_a, qual_b) {
(None, None) => 0,
(None, Some(_)) => 1, (Some(_), None) => -1, (Some(qa), Some(qb)) => {
let parse_qual = |s: &str| -> Vec<i64> {
s.split(|c: char| c == '.' || c == '-')
.map(|seg| seg.parse::<i64>().unwrap_or(0))
.collect()
};
let vqa = parse_qual(qa);
let vqb = parse_qual(qb);
let qlen = vqa.len().max(vqb.len());
for i in 0..qlen {
let sa = vqa.get(i).copied().unwrap_or(0);
let sb = vqb.get(i).copied().unwrap_or(0);
if sa < sb { return -1; }
if sa > sb { return 1; }
}
0
}
}
}
fn scope_strength(scope: &str) -> u8 {
match scope {
"compile" => 0,
"provided" => 1,
"runtime" => 2,
"test" => 3,
_ => 0, }
}
fn stronger_scope(a: &str, b: &str) -> String {
if scope_strength(a) <= scope_strength(b) { a.to_string() } else { b.to_string() }
}
fn weaker_scope(a: &str, b: &str) -> String {
if scope_strength(a) >= scope_strength(b) {
a.to_string()
} else {
b.to_string()
}
}
const DEFAULT_REPO: &str = "https://repo1.maven.org/maven2";
const MAVEN_CENTRAL_ALT: &str = "https://repo.maven.apache.org/maven2";
const IMMUTABLE_REGISTRIES: &[&str] = &[DEFAULT_REPO, MAVEN_CENTRAL_ALT];
fn is_immutable_registry(url: &str) -> bool {
IMMUTABLE_REGISTRIES.contains(&url.trim_end_matches('/'))
}
fn build_io_pool(num_threads: usize) -> rayon::ThreadPool {
rayon::ThreadPoolBuilder::new()
.num_threads(num_threads)
.build()
.unwrap_or_else(|_| {
rayon::ThreadPoolBuilder::new()
.build()
.expect("rayon default thread pool failed to build")
})
}
pub fn resolve_and_download(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
) -> Result<Vec<PathBuf>> {
resolve_and_download_full(dependencies, cache_dir, lock, &[], &[])
}
pub fn resolve_and_download_full(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
) -> Result<Vec<PathBuf>> {
resolve_and_download_with_resolutions(dependencies, cache_dir, lock, registries, exclusions, &BTreeMap::new())
}
pub fn resolve_and_download_with_resolutions(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
) -> Result<Vec<PathBuf>> {
resolve_and_download_with_scopes(
dependencies, cache_dir, lock, registries, exclusions, resolutions, &HashMap::new(),
)
}
pub fn resolve_and_download_with_scopes(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
dep_scopes: &HashMap<String, String>,
) -> Result<Vec<PathBuf>> {
resolve_inner(dependencies, cache_dir, lock, registries, exclusions, resolutions, &BTreeMap::new(), dep_scopes, true)
}
pub fn resolve_and_download_with_constraints(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
constraints: &BTreeMap<String, String>,
dep_scopes: &HashMap<String, String>,
) -> Result<Vec<PathBuf>> {
resolve_inner(dependencies, cache_dir, lock, registries, exclusions, resolutions, constraints, dep_scopes, true)
}
pub fn resolve_no_download(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
dep_scopes: &HashMap<String, String>,
) -> Result<Vec<PathBuf>> {
resolve_inner(dependencies, cache_dir, lock, registries, exclusions, resolutions, &BTreeMap::new(), dep_scopes, false)
}
fn resolve_inner(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
constraints: &BTreeMap<String, String>,
dep_scopes: &HashMap<String, String>,
download: bool,
) -> Result<Vec<PathBuf>> {
let exclusion_set: HashSet<String> = exclusions.iter().cloned().collect();
if resolutions.is_empty() {
if let Some(jars) = try_resolve_from_lock(dependencies, cache_dir, lock, &exclusion_set, resolutions, registries, download)? {
return Ok(jars);
}
} else {
let mut resolved_deps = dependencies.clone();
for (k, v) in resolutions {
if resolved_deps.contains_key(k) {
resolved_deps.insert(k.clone(), v.clone());
}
}
if let Some(jars) = try_resolve_from_lock(&resolved_deps, cache_dir, lock, &exclusion_set, resolutions, registries, download)? {
return Ok(jars);
}
}
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
let mut ordered_keys = Vec::new();
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("ym/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_secs(30))
.build()?;
let mut coords_to_download: Vec<(String, MavenCoord)> = Vec::new();
let mut dep_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut resolved_versions: HashMap<String, String> = HashMap::new();
let mut explicit_deps: HashSet<String> = HashSet::new();
let mut depth_map: HashMap<String, usize> = HashMap::new();
let pom_cache = PomCache::new();
let pom_failures: std::sync::Arc<Mutex<Vec<(MavenCoord, String)>>> =
std::sync::Arc::new(Mutex::new(Vec::new()));
let mut accumulated_exclusions: HashMap<String, HashSet<String>> = HashMap::new();
let mut scope_map: HashMap<String, String> = HashMap::new();
for (coord, version) in dependencies {
let mut mc = MavenCoord::parse(coord, version)?;
let ga_key = format!("{}:{}", mc.group_id, mc.artifact_id);
let direct_scope = dep_scopes.get(&ga_key).cloned().unwrap_or_else(|| "compile".to_string());
mc.scope = Some(direct_scope.clone());
scope_map.insert(mc.versioned_key(), direct_scope);
depth_map.insert(mc.versioned_key(), 0);
resolved_versions.insert(ga_key.clone(), mc.version.clone());
explicit_deps.insert(ga_key);
queue.push_back(mc);
}
let resolved_count = AtomicUsize::new(0);
let show_resolve_progress = !crate::is_json_quiet() && !crate::RESOLVER_QUIET.load(Ordering::Relaxed);
while !queue.is_empty() {
let mut current_level: Vec<MavenCoord> = Vec::new();
while let Some(coord) = queue.pop_front() {
let key = coord.versioned_key();
let ga_key = format!("{}:{}", coord.group_id, coord.artifact_id);
let current_depth = depth_map.get(&key).copied().unwrap_or(0);
if visited.contains(&key) {
continue;
}
if let Some(resolved_ver) = resolved_versions.get(&ga_key) {
if version_compare(&coord.version, resolved_ver) < 0 {
continue;
}
}
visited.insert(key.clone());
ordered_keys.push(key.clone());
resolved_versions
.entry(ga_key)
.and_modify(|v| {
if version_compare(&coord.version, v) > 0 {
*v = coord.version.clone();
}
})
.or_insert_with(|| coord.version.clone());
let jar_path = coord.jar_path(cache_dir);
if !jar_path.exists() && !is_pom_only_cached(&jar_path) {
coords_to_download.push((key, coord.clone()));
} else {
let _ = &key;
}
current_level.push(coord);
}
if current_level.is_empty() {
break;
}
let pom_failures_ref = &pom_failures;
let level_results: Vec<(MavenCoord, Vec<MavenCoord>)> = network_io_pool().install(|| {
current_level
.par_iter()
.map(|coord| {
let transitive = match resolve_transitive_cached(
&client, coord, cache_dir, registries, Some(&pom_cache),
) {
Ok(t) => t,
Err(e) => {
pom_failures_ref
.lock()
.unwrap()
.push((coord.clone(), e.to_string()));
Vec::new()
}
};
let n = resolved_count.fetch_add(1, Ordering::Relaxed) + 1;
let interval = if crate::is_progress_quiet() { 100 } else { 20 };
if show_resolve_progress && n % interval == 0 {
resolver_progress(&format!("dependency graph ({} artifacts)...", n));
}
(coord.clone(), transitive)
})
.collect()
});
if show_resolve_progress && resolved_count.load(Ordering::Relaxed) >= 20 && !crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
eprint!("\r{}\r", " ".repeat(60));
}
for (coord, transitive) in level_results {
let key = coord.versioned_key();
let current_depth = depth_map.get(&key).copied().unwrap_or(0);
let coord_excl = accumulated_exclusions
.get(&key)
.cloned()
.unwrap_or_default();
let transitive: Vec<MavenCoord> = transitive
.into_iter()
.filter(|dep| {
let dep_key = format!("{}:{}", dep.group_id, dep.artifact_id);
!exclusion_set.contains(&dep_key) && !coord_excl.contains(&dep_key)
})
.collect();
let dep_keys: Vec<String> = transitive.iter().map(|c| c.versioned_key()).collect();
let parent_scope = scope_map.get(&key).cloned().unwrap_or_else(|| "compile".to_string());
dep_map.insert(key, dep_keys);
let child_depth = current_depth + 1;
for mut dep in transitive {
let ga_key = format!("{}:{}", dep.group_id, dep.artifact_id);
if let Some(forced_version) = resolutions.get(&ga_key) {
dep.version = forced_version.clone();
}
else if !explicit_deps.contains(&ga_key) {
if let Some(constraint_version) = constraints.get(&ga_key) {
if version_compare(&dep.version, constraint_version) < 0 {
dep.version = constraint_version.clone();
}
}
}
let dep_vk = dep.versioned_key();
depth_map.entry(dep_vk.clone()).or_insert(child_depth);
let pom_scope = dep.scope.as_deref().unwrap_or("compile");
let effective_scope = weaker_scope(&parent_scope, pom_scope);
if let Some(existing) = scope_map.get(&dep_vk) {
let merged = stronger_scope(existing, &effective_scope);
scope_map.insert(dep_vk.clone(), merged.to_string());
} else {
scope_map.insert(dep_vk.clone(), effective_scope.to_string());
}
dep.scope = Some(scope_map.get(&dep_vk).cloned().unwrap_or_else(|| "compile".to_string()));
let mut child_excl = coord_excl.clone();
for e in &dep.exclusions {
child_excl.insert(e.clone());
}
if !child_excl.is_empty() {
accumulated_exclusions
.entry(dep_vk)
.or_default()
.extend(child_excl);
}
queue.push_back(dep);
}
}
}
{
let failures = pom_failures.lock().unwrap();
if !failures.is_empty() {
let mut fatal: Vec<&(MavenCoord, String)> = Vec::new();
let mut stale: Vec<&(MavenCoord, String)> = Vec::new();
for entry in failures.iter() {
let (coord, _reason) = entry;
let ga_key = format!("{}:{}", coord.group_id, coord.artifact_id);
match resolved_versions.get(&ga_key) {
Some(winner) if version_compare(winner, &coord.version) > 0 => {
stale.push(entry);
}
_ => fatal.push(entry),
}
}
for (coord, reason) in &stale {
let ga_key = format!("{}:{}", coord.group_id, coord.artifact_id);
let winner = resolved_versions.get(&ga_key).map(|s| s.as_str()).unwrap_or("?");
eprintln!(
"{} stale POM fetch failed (superseded by {}): {}:{}:{} — {}",
style(format!("{:>12}", "warning")).yellow().bold(),
winner,
coord.group_id, coord.artifact_id, coord.version,
reason
);
}
if !fatal.is_empty() {
let mut msg = String::from("\n ✗ Dependency resolution failed — POM fetch/parse errors:\n\n");
for (coord, reason) in &fatal {
msg.push_str(&format!(
" - {}:{}:{}\n reason: {}\n",
coord.group_id, coord.artifact_id, coord.version, reason
));
}
msg.push_str("\n Possible causes:\n");
msg.push_str(" - registry temporarily unreachable (retry; check network and credentials)\n");
msg.push_str(" - artifact does not exist at that version (typo in ym.json?)\n");
msg.push_str(" - cache corruption (run `ym cache clean -y` to retry from scratch)\n");
return Err(anyhow::anyhow!(msg));
}
}
}
if download && !coords_to_download.is_empty() {
let total = coords_to_download.len();
let is_tty = console::Term::stdout().is_term();
let show_progress = !crate::is_json_quiet() && is_tty;
let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let dl_total_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let dl_done_bytes = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let dl_start = std::time::Instant::now();
let last_print_ms = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
let pending = std::sync::Arc::new(Mutex::new(std::collections::BTreeSet::<String>::new()));
if !crate::is_json_quiet() {
let first = coords_to_download.first()
.map(|(_, c)| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
.unwrap_or_default();
if crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
crate::set_spinner_msg(format!("Downloading 0/{} {}", total, first));
} else if is_tty {
eprint!(
"{} 0/{} {}",
style(format!("{:>12}", "Downloading")).green().bold(), total, first
);
} else {
let dep_word = if total == 1 { "dependency" } else { "dependencies" };
println!(
"{} {} {}...",
style(format!("{:>12}", "Downloading")).green().bold(), total, dep_word
);
}
}
let download_results: Vec<(String, Result<String>)> = network_io_pool().install(|| {
coords_to_download
.into_par_iter()
.map(|(key, coord)| {
let short_name = format!("{}:{}:{}", coord.group_id, coord.artifact_id, coord.version);
if show_progress {
pending.lock().unwrap().insert(short_name.clone());
}
let jar_path = coord.jar_path(cache_dir);
let dl_total_ref = dl_total_bytes.clone();
let dl_done_ref = dl_done_bytes.clone();
let completed_ref = completed.clone();
let pending_ref = pending.clone();
let last_print_ref = last_print_ms.clone();
let dl_start_ref = dl_start;
let name_for_cb = short_name.clone();
let progress_cb: Option<Box<dyn Fn(u64, u64) + Send + Sync>> = if show_progress {
Some(Box::new(move |content_len: u64, chunk_bytes: u64| {
if chunk_bytes == 0 {
dl_total_ref.fetch_add(content_len, Ordering::Relaxed);
return;
}
let done = dl_done_ref.fetch_add(chunk_bytes, Ordering::Relaxed) + chunk_bytes;
let now_ms = dl_start_ref.elapsed().as_millis() as u64;
let prev = last_print_ref.load(Ordering::Relaxed);
if now_ms.saturating_sub(prev) < 100 {
return;
}
if last_print_ref.compare_exchange(prev, now_ms, Ordering::Relaxed, Ordering::Relaxed).is_err() {
return; }
let total_b = dl_total_ref.load(Ordering::Relaxed);
let elapsed = dl_start_ref.elapsed().as_secs_f64();
let speed = if elapsed > 0.1 { done as f64 / elapsed } else { 0.0 };
let eta = if speed > 0.0 && total_b > done { ((total_b - done) as f64 / speed) as u64 } else { 0 };
let done_count = completed_ref.load(Ordering::Relaxed);
let current = pending_ref.lock().unwrap().iter().next().cloned().unwrap_or_else(|| name_for_cb.clone());
if crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
crate::set_spinner_msg(format!(
"Downloading {}/{} {} {}/{} {}/s {}",
done_count, total, current,
format_bytes(done), format_bytes(total_b),
format_bytes(speed as u64), format_eta(eta),
));
} else {
eprint!(
"\r{} {}/{} {} {}/{} {}/s {}{}",
style(format!("{:>12}", "Downloading")).green().bold(),
done_count, total,
current,
format_bytes(done), format_bytes(total_b),
format_bytes(speed as u64),
format_eta(eta),
" ".repeat(10)
);
}
}))
} else {
None
};
let hash_result = if coord.is_snapshot() {
if let Some((ts, bn)) = resolve_snapshot_version(&client, &coord, registries) {
download_from_repos(&client, &coord, &jar_path, registries, progress_cb.as_deref(), |c, r| c.snapshot_jar_url(r, &ts, &bn))
} else {
download_from_repos(&client, &coord, &jar_path, registries, progress_cb.as_deref(), |c, r| c.jar_url(r))
}
} else {
download_from_repos(&client, &coord, &jar_path, registries, progress_cb.as_deref(), |c, r| c.jar_url(r))
};
if hash_result.is_ok() {
verify_gpg_signature(&client, &coord, &jar_path, registries);
}
if show_progress {
let done_count = completed.fetch_add(1, Ordering::Relaxed) + 1;
let mut pend = pending.lock().unwrap();
pend.remove(&short_name);
let current = pend.iter().next().cloned().unwrap_or_default();
let done = dl_done_bytes.load(Ordering::Relaxed);
let total_b = dl_total_bytes.load(Ordering::Relaxed);
let elapsed = dl_start.elapsed().as_secs_f64();
let speed = if elapsed > 0.1 { done as f64 / elapsed } else { 0.0 };
let eta = if speed > 0.0 && total_b > done { ((total_b - done) as f64 / speed) as u64 } else { 0 };
if crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
crate::set_spinner_msg(format!(
"Downloading {}/{} {} {}/{} {}/s {}",
done_count, total, current,
format_bytes(done), format_bytes(total_b),
format_bytes(speed as u64), format_eta(eta),
));
} else {
eprint!(
"\r{} {}/{} {} {}/{} {}/s {}{}",
style(format!("{:>12}", "Downloading")).green().bold(),
done_count, total,
current,
format_bytes(done), format_bytes(total_b),
format_bytes(speed as u64),
format_eta(eta),
" ".repeat(5)
);
}
}
(key, hash_result)
})
.collect()
});
if !crate::is_json_quiet() && is_tty && !crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
eprint!("\r{}\r", " ".repeat(60));
}
if !crate::is_json_quiet() && !crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
let ok_count = download_results.iter().filter(|(_, r)| r.is_ok()).count();
let done_word = if ok_count == 1 { "dependency" } else { "dependencies" };
println!(
"{} {} {}",
style(format!("{:>12}", "Downloaded")).green().bold(), ok_count, done_word
);
}
if !crate::SPINNER_ACTIVE.load(Ordering::Relaxed) {
for (key, result) in &download_results {
if let Err(e) = result {
let msg = e.to_string();
if !msg.contains("HTTP 404") {
eprintln!("{} {} — {}", style(format!("{:>12}", "error")).red().bold(), key, e);
}
}
}
print_gpg_summary();
}
let mut failures = Vec::new();
for (key, hash_result) in download_results {
match hash_result {
Ok(hash) => {
if let Some(entry) = lock.dependencies.get_mut(&key) {
entry.sha256 = Some(hash);
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("HTTP 404") {
if let Some(coord) = MavenCoord::from_versioned_key(&key) {
let jar_path = coord.jar_path(cache_dir);
let marker = jar_path.with_extension("jar.pom-only");
if let Some(parent) = marker.parent() {
let _ = std::fs::create_dir_all(parent);
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string();
let _ = std::fs::write(&marker, timestamp);
}
eprintln!("{} {} (pom-only, no JAR — skipped)", style(format!("{:>12}", "Skipping")).yellow().bold(), key);
} else {
failures.push(format!("{}: {}", key, e));
}
}
}
}
if !failures.is_empty() {
bail!("Failed to download {} artifact(s):\n {}", failures.len(), failures.join("\n "));
}
}
let mut ga_winners: HashMap<String, (MavenCoord, String)> = HashMap::new(); let mut ga_order: Vec<String> = Vec::new();
for key in &ordered_keys {
let Some(coord) = MavenCoord::from_versioned_key(key) else {
continue;
};
let ga = format!("{}:{}", coord.group_id, coord.artifact_id);
if let Some((existing, _)) = ga_winners.get(&ga) {
if version_compare(&coord.version, &existing.version) > 0 {
ga_winners.insert(ga, (coord, key.clone()));
}
} else {
ga_order.push(ga.clone());
ga_winners.insert(ga, (coord, key.clone()));
}
}
for ga in &ga_order {
let Some((_, winner_key)) = ga_winners.get(ga) else { continue };
let raw_deps = dep_map.remove(winner_key).unwrap_or_default();
let normalized_deps: Vec<String> = raw_deps
.into_iter()
.map(|gav| {
let parts: Vec<&str> = gav.splitn(3, ':').collect();
if parts.len() == 3 {
let dep_ga = format!("{}:{}", parts[0], parts[1]);
if let Some((_, winner_gav)) = ga_winners.get(&dep_ga) {
return winner_gav.clone();
}
}
gav
})
.collect();
let effective_scope = scope_map.get(winner_key).cloned();
lock.dependencies.entry(winner_key.clone()).or_insert(ResolvedDependency {
sha256: None,
dependencies: if normalized_deps.is_empty() {
None
} else {
Some(normalized_deps)
},
scope: effective_scope,
});
}
let mut all_jars = Vec::new();
for ga in &ga_order {
let Some((coord, _)) = ga_winners.get(ga) else { continue };
let jar_path = coord.jar_path(cache_dir);
if jar_path.exists() {
all_jars.push(jar_path);
}
}
append_native_jars(&mut all_jars);
Ok(all_jars)
}
fn resolve_snapshot_version(
client: &reqwest::blocking::Client,
coord: &MavenCoord,
registries: &[RegistryEntry],
) -> Option<(String, String)> {
let repos = repos_for_group_id(registries, &coord.group_id);
for repo in &repos {
let url = coord.metadata_url(&repo.url);
let mut request = client.get(&url);
if let (Some(u), Some(p)) = (&repo.username, &repo.password) {
request = request.basic_auth(u, Some(p));
} else if let Some((username, password)) = load_credentials_for_url(&url) {
request = request.basic_auth(username, Some(password));
}
if let Ok(response) = request.send() {
if response.status().is_success() {
if let Ok(text) = response.text() {
if let (Some(ts), Some(bn)) = (
extract_xml_text(&text, "timestamp"),
extract_xml_text(&text, "buildNumber"),
) {
return Some((ts, bn));
}
}
}
}
}
None
}
fn try_resolve_from_lock(
dependencies: &BTreeMap<String, String>,
cache_dir: &Path,
lock: &Lockfile,
exclusion_set: &HashSet<String>,
resolutions: &BTreeMap<String, String>,
registries: &[RegistryEntry],
validate_sha1: bool,
) -> Result<Option<Vec<PathBuf>>> {
if lock.dependencies.is_empty() {
return Ok(None);
}
for version in dependencies.values() {
if version.ends_with("-SNAPSHOT") {
return Ok(None);
}
}
let mut ga_winners: HashMap<String, MavenCoord> = HashMap::new();
let mut ga_order: Vec<String> = Vec::new();
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
for (coord, version) in dependencies {
let mc = match MavenCoord::parse(coord, version) {
Ok(mc) => mc,
Err(_) => return Ok(None),
};
queue.push_back(mc);
}
while let Some(coord) = queue.pop_front() {
let ga_key = format!("{}:{}", coord.group_id, coord.artifact_id);
if exclusion_set.contains(&ga_key) {
continue;
}
let key = coord.versioned_key();
if visited.contains(&key) {
continue;
}
visited.insert(key.clone());
let jar_path = coord.jar_path(cache_dir);
let pom_only = is_pom_only_cached(&jar_path);
if !jar_path.exists() && !pom_only {
return Ok(None); }
let locked = match lock.dependencies.get(&key) {
Some(locked) => locked,
None => return Ok(None),
};
if !pom_only {
if let Some(existing) = ga_winners.get(&ga_key) {
if version_compare(&coord.version, &existing.version) > 0 {
ga_winners.insert(ga_key.clone(), coord.clone());
}
} else {
ga_order.push(ga_key.clone());
ga_winners.insert(ga_key.clone(), coord.clone());
}
}
if let Some(ref dep_keys) = locked.dependencies {
for dep_key in dep_keys {
if let Some(mut mc) = MavenCoord::from_versioned_key(dep_key) {
let ga = format!("{}:{}", mc.group_id, mc.artifact_id);
if let Some(forced_version) = resolutions.get(&ga) {
mc.version = forced_version.clone();
}
queue.push_back(mc);
}
}
}
}
if validate_sha1 {
let validation_targets: Vec<ValidationTarget> = ga_winners
.values()
.filter_map(|coord| {
let repos = repos_for_group_id(registries, &coord.group_id);
let repo = repos.first()?;
if is_immutable_registry(&repo.url) {
return None;
}
Some(ValidationTarget {
coord: coord.clone(),
registry_url: repo.url.clone(),
})
})
.collect();
let republished = validate_sha1_remote(&validation_targets, cache_dir);
if !republished.is_empty() {
if crate::commands::build::is_frozen_lockfile() {
let mut msg = String::from(
"ym-lock.json is out of date — upstream release(s) were republished:",
);
for coord in &republished {
msg.push_str(&format!("\n ✗ {}", coord.versioned_key()));
}
msg.push_str(
"\n\n A release version was overwritten on a mutable registry, so the\n \
locked dependency graph no longer matches reality.\n \
Run `ym install` to re-resolve, then commit the updated ym-lock.json.",
);
bail!("{}", msg);
}
for coord in &republished {
purge_artifact_cache(coord, cache_dir);
}
if !crate::is_progress_quiet() {
eprintln!(
"{} dependency cache out of date (sha1 mismatch), re-resolving",
console::style(format!("{:>12}", "info")).cyan().bold()
);
}
return Ok(None);
}
}
let mut all_jars = Vec::new();
for ga in &ga_order {
if let Some(coord) = ga_winners.get(ga) {
let jar_path = coord.jar_path(cache_dir);
if jar_path.exists() {
all_jars.push(jar_path);
}
}
}
append_native_jars(&mut all_jars);
Ok(Some(all_jars))
}
struct PomCache {
entries: Mutex<HashMap<String, Vec<(String, String, String, Vec<String>)>>>,
}
impl PomCache {
fn new() -> Self {
PomCache {
entries: Mutex::new(HashMap::new()),
}
}
fn get(&self, key: &str) -> Option<Vec<MavenCoord>> {
let entries = self.entries.lock().unwrap();
entries.get(key).map(|v| {
v.iter()
.map(|(g, a, ver, excl)| MavenCoord {
group_id: g.clone(),
artifact_id: a.clone(),
version: ver.clone(),
classifier: None,
exclusions: excl.clone(),
scope: None,
})
.collect()
})
}
fn insert(&self, key: &str, deps: &[MavenCoord]) {
let mut entries = self.entries.lock().unwrap();
entries.insert(
key.to_string(),
deps.iter()
.map(|d| (d.group_id.clone(), d.artifact_id.clone(), d.version.clone(), d.exclusions.clone()))
.collect(),
);
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PomCacheEntry {
schema_version: u8,
pom_sha256: String,
resolved: Vec<(String, String, String, Vec<String>)>,
}
const POM_CACHE_SCHEMA_VERSION: u8 = 2;
fn purge_artifact_cache(coord: &MavenCoord, cache_dir: &Path) {
let _ = std::fs::remove_file(coord.jar_path(cache_dir));
let _ = std::fs::remove_file(coord.pom_path(cache_dir));
let pom_cache_file = crate::config::pom_cache_dir()
.join(&coord.group_id)
.join(&coord.artifact_id)
.join(format!("{}.json", coord.version));
let _ = std::fs::remove_file(pom_cache_file);
}
fn resolve_transitive_cached(
client: &reqwest::blocking::Client,
coord: &MavenCoord,
cache_dir: &Path,
registries: &[RegistryEntry],
pom_cache: Option<&PomCache>,
) -> Result<Vec<MavenCoord>> {
let cache_key = coord.versioned_key();
if let Some(cache) = pom_cache {
if let Some(cached) = cache.get(&cache_key) {
return Ok(cached);
}
}
let pom_cache_dir = crate::config::pom_cache_dir();
let pom_cache_file = pom_cache_dir
.join(&coord.group_id)
.join(&coord.artifact_id)
.join(format!("{}.json", coord.version));
let pom_path = coord.pom_path(cache_dir);
if pom_cache_file.exists() {
if let Ok(content) = std::fs::read_to_string(&pom_cache_file) {
let fresh = serde_json::from_str::<PomCacheEntry>(&content)
.ok()
.filter(|entry| entry.schema_version == POM_CACHE_SCHEMA_VERSION)
.filter(|entry| {
compute_sha256_file(&pom_path)
.map(|local| local == entry.pom_sha256)
.unwrap_or(false)
});
if let Some(entry) = fresh {
let deps: Vec<MavenCoord> = entry
.resolved
.iter()
.map(|(g, a, v, excl)| MavenCoord {
group_id: g.clone(),
artifact_id: a.clone(),
version: v.clone(),
classifier: None,
exclusions: excl.clone(),
scope: None,
})
.collect();
if let Some(cache) = pom_cache {
cache.insert(&cache_key, &deps);
}
return Ok(deps);
}
let _ = std::fs::remove_file(&pom_cache_file);
}
}
if !pom_path.exists() || coord.is_snapshot() {
let pom_result = if coord.is_snapshot() {
if let Some((ts, bn)) = resolve_snapshot_version(client, coord, registries) {
download_from_repos(client, coord, &pom_path, registries, None, |c, r| c.snapshot_pom_url(r, &ts, &bn))
} else {
download_from_repos(client, coord, &pom_path, registries, None, |c, r| c.pom_url(r))
}
} else {
download_from_repos(client, coord, &pom_path, registries, None, |c, r| c.pom_url(r))
};
if let Err(e) = pom_result {
return Err(anyhow::anyhow!(
"POM fetch failed for {}:{}:{} — {}",
coord.group_id, coord.artifact_id, coord.version, e
));
}
}
let pom_content = std::fs::read_to_string(&pom_path)
.with_context(|| format!("read raw POM at {}", pom_path.display()))?;
let outer_ctx = || format!(
"resolve transitive for {}:{}:{} (POM at {})",
coord.group_id, coord.artifact_id, coord.version, pom_path.display()
);
let mut all_properties = HashMap::new();
let mut visited_poms = HashSet::new();
resolve_parent_properties(client, &pom_content, cache_dir, registries, &mut all_properties, 0, &mut visited_poms)
.with_context(outer_ctx)?;
let mut deps = parse_pom_dependencies_with_props(&pom_content, &all_properties, client, cache_dir, registries)
.with_context(outer_ctx)?;
let mut visited_parent_deps = HashSet::new();
let parent_deps = collect_parent_dependencies(
client,
&pom_content,
cache_dir,
registries,
&all_properties,
0,
&mut visited_parent_deps,
).with_context(outer_ctx)?;
let mut existing_ga: HashSet<String> = deps
.iter()
.map(|d| format!("{}:{}", d.group_id, d.artifact_id))
.collect();
for pd in parent_deps {
let ga = format!("{}:{}", pd.group_id, pd.artifact_id);
if existing_ga.insert(ga) {
deps.push(pd);
}
}
if let Some(parent) = pom_cache_file.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(pom_sha256) = compute_sha256_file(&pom_path) {
let entry = PomCacheEntry {
schema_version: POM_CACHE_SCHEMA_VERSION,
pom_sha256,
resolved: deps
.iter()
.map(|d| (d.group_id.clone(), d.artifact_id.clone(), d.version.clone(), d.exclusions.clone()))
.collect(),
};
let _ = std::fs::write(&pom_cache_file, serde_json::to_string(&entry).unwrap_or_default());
}
if let Some(cache) = pom_cache {
cache.insert(&cache_key, &deps);
}
Ok(deps)
}
fn resolve_parent_properties(
client: &reqwest::blocking::Client,
pom_content: &str,
cache_dir: &Path,
registries: &[RegistryEntry],
properties: &mut HashMap<String, String>,
depth: u8,
visited_poms: &mut HashSet<String>,
) -> Result<()> {
if depth > 20 {
return Ok(());
}
let doc = roxmltree::Document::parse(pom_content)
.with_context(|| "parse POM body in resolve_parent_properties (current POM)")?;
for node in doc.root_element().children() {
if node.tag_name().name() == "parent" {
let mut pg = None;
let mut pa = None;
let mut pv = None;
for child in node.children() {
match child.tag_name().name() {
"groupId" => pg = child.text(),
"artifactId" => pa = child.text(),
"version" => pv = child.text(),
_ => {}
}
}
if let (Some(g), Some(a), Some(v)) = (pg, pa, pv) {
let parent_key = format!("{}:{}:{}", g, a, v);
if visited_poms.contains(&parent_key) {
break; }
visited_poms.insert(parent_key);
let parent_coord = MavenCoord {
group_id: g.to_string(),
artifact_id: a.to_string(),
version: v.to_string(),
classifier: None,
exclusions: Vec::new(),
scope: None,
};
let parent_pom_path = parent_coord.pom_path(cache_dir);
if !parent_pom_path.exists() {
let _ = download_from_repos(client, &parent_coord, &parent_pom_path, registries, None, |c, r| c.pom_url(r));
}
if parent_pom_path.exists() {
let parent_content = std::fs::read_to_string(&parent_pom_path)?;
resolve_parent_properties(client, &parent_content, cache_dir, registries, properties, depth + 1, visited_poms)?;
let parent_doc = roxmltree::Document::parse(&parent_content)
.with_context(|| format!("parse parent POM at {}", parent_pom_path.display()))?;
let parent_props = collect_pom_properties(&parent_doc);
for (k, v) in parent_props {
properties.entry(k).or_insert(v);
}
let managed = collect_managed_versions_with_bom(
&parent_doc, properties, client, cache_dir, registries, 0,
);
for (k, v) in managed {
properties.entry(format!("managed:{}", k)).or_insert(v);
}
}
}
break;
}
}
let current_props = collect_pom_properties(&doc);
for (k, v) in current_props {
properties.insert(k, v);
}
Ok(())
}
fn parse_pom_dependencies_with_props(
pom: &str,
extra_properties: &HashMap<String, String>,
client: &reqwest::blocking::Client,
cache_dir: &Path,
registries: &[RegistryEntry],
) -> Result<Vec<MavenCoord>> {
let doc = roxmltree::Document::parse(pom)
.with_context(|| "parse POM body in parse_pom_dependencies_with_props")?;
let mut properties = extra_properties.clone();
let local_props = collect_pom_properties(&doc);
for (k, v) in local_props {
properties.insert(k, v);
}
let managed = collect_managed_versions_with_bom(
&doc, &properties, client, cache_dir, registries, 0,
);
let mut deps = Vec::new();
for node in doc.descendants() {
if node.tag_name().name() != "dependencies" {
continue;
}
if let Some(parent) = node.parent() {
let parent_name = parent.tag_name().name();
match parent_name {
"project" | "profile" => {} _ => continue, }
}
for dep in node.children() {
if dep.tag_name().name() != "dependency" {
continue;
}
let mut group_id = None;
let mut artifact_id = None;
let mut version = None;
let mut scope = None;
let mut optional = false;
let mut dep_exclusions = Vec::new();
for child in dep.children() {
match child.tag_name().name() {
"groupId" => group_id = child.text().map(|s| s.to_string()),
"artifactId" => artifact_id = child.text().map(|s| s.to_string()),
"version" => version = child.text().map(|s| s.to_string()),
"scope" => scope = child.text().map(|s| s.to_string()),
"optional" => optional = child.text() == Some("true"),
"exclusions" => {
for excl in child.children() {
if excl.tag_name().name() != "exclusion" {
continue;
}
let mut eg = None;
let mut ea = None;
for ec in excl.children() {
match ec.tag_name().name() {
"groupId" => eg = ec.text().map(|s| s.to_string()),
"artifactId" => ea = ec.text().map(|s| s.to_string()),
_ => {}
}
}
if let (Some(g), Some(a)) = (eg, ea) {
dep_exclusions.push(format!("{}:{}", g, a));
}
}
}
_ => {}
}
}
if let Some(ref s) = scope {
if s == "test" || s == "provided" || s == "system" || s == "import" {
continue;
}
}
if optional {
continue;
}
if let (Some(g), Some(a)) = (group_id, artifact_id) {
let resolved_g = resolve_properties(&g, &properties);
let resolved_a = resolve_properties(&a, &properties);
let resolved_version = version
.map(|v| resolve_properties(&v, &properties))
.or_else(|| managed.get(&format!("{}:{}", resolved_g, resolved_a)).cloned())
.or_else(|| extra_properties.get(&format!("managed:{}:{}", resolved_g, resolved_a)).cloned());
if let Some(v) = resolved_version {
if !v.contains("${") {
deps.push(MavenCoord {
group_id: resolved_g,
artifact_id: resolved_a,
version: v,
classifier: None,
exclusions: dep_exclusions,
scope: scope.clone(),
});
}
}
}
}
}
Ok(deps)
}
fn collect_parent_dependencies(
client: &reqwest::blocking::Client,
pom_content: &str,
cache_dir: &Path,
registries: &[RegistryEntry],
extra_properties: &HashMap<String, String>,
depth: u8,
visited: &mut HashSet<String>,
) -> Result<Vec<MavenCoord>> {
if depth > 20 {
return Ok(vec![]);
}
let doc = roxmltree::Document::parse(pom_content)
.with_context(|| "parse POM body in collect_parent_dependencies")?;
let mut deps: Vec<MavenCoord> = Vec::new();
let mut seen_ga: HashSet<String> = HashSet::new();
for node in doc.root_element().children() {
if node.tag_name().name() != "parent" {
continue;
}
let mut pg = None;
let mut pa = None;
let mut pv = None;
for child in node.children() {
match child.tag_name().name() {
"groupId" => pg = child.text(),
"artifactId" => pa = child.text(),
"version" => pv = child.text(),
_ => {}
}
}
if let (Some(g), Some(a), Some(v)) = (pg, pa, pv) {
let parent_key = format!("{}:{}:{}", g, a, v);
if visited.contains(&parent_key) {
break;
}
visited.insert(parent_key);
let parent_coord = MavenCoord {
group_id: g.to_string(),
artifact_id: a.to_string(),
version: v.to_string(),
classifier: None,
exclusions: Vec::new(),
scope: None,
};
let parent_pom_path = parent_coord.pom_path(cache_dir);
if !parent_pom_path.exists() {
let _ = download_from_repos(
client,
&parent_coord,
&parent_pom_path,
registries,
None,
|c, r| c.pom_url(r),
);
}
if parent_pom_path.exists() {
if let Ok(parent_content) = std::fs::read_to_string(&parent_pom_path) {
if let Ok(parent_own_deps) = parse_pom_dependencies_with_props(
&parent_content,
extra_properties,
client,
cache_dir,
registries,
) {
for d in parent_own_deps {
let ga = format!("{}:{}", d.group_id, d.artifact_id);
if seen_ga.insert(ga) {
deps.push(d);
}
}
}
if let Ok(grandparent_deps) = collect_parent_dependencies(
client,
&parent_content,
cache_dir,
registries,
extra_properties,
depth + 1,
visited,
) {
for gd in grandparent_deps {
let ga = format!("{}:{}", gd.group_id, gd.artifact_id);
if seen_ga.insert(ga) {
deps.push(gd);
}
}
}
}
}
}
break;
}
Ok(deps)
}
pub fn collect_pom_properties(doc: &roxmltree::Document) -> HashMap<String, String> {
let mut props = HashMap::new();
let root = doc.root_element();
let mut project_group_id = None;
let mut project_artifact_id = None;
let mut project_version = None;
let mut parent_group_id = None;
let mut parent_version = None;
for node in root.children() {
match node.tag_name().name() {
"groupId" => project_group_id = node.text().map(|s| s.to_string()),
"artifactId" => project_artifact_id = node.text().map(|s| s.to_string()),
"version" => project_version = node.text().map(|s| s.to_string()),
"parent" => {
for child in node.children() {
match child.tag_name().name() {
"groupId" => parent_group_id = child.text().map(|s| s.to_string()),
"version" => parent_version = child.text().map(|s| s.to_string()),
_ => {}
}
}
}
_ => {}
}
}
if let Some(ref v) = project_version {
props.insert("project.version".to_string(), v.clone());
props.insert("pom.version".to_string(), v.clone());
} else if let Some(ref v) = parent_version {
props.insert("project.version".to_string(), v.clone());
props.insert("pom.version".to_string(), v.clone());
}
if let Some(ref g) = project_group_id {
props.insert("project.groupId".to_string(), g.clone());
props.insert("pom.groupId".to_string(), g.clone());
} else if let Some(ref g) = parent_group_id {
props.insert("project.groupId".to_string(), g.clone());
props.insert("pom.groupId".to_string(), g.clone());
}
if let Some(ref a) = project_artifact_id {
props.insert("project.artifactId".to_string(), a.clone());
props.insert("pom.artifactId".to_string(), a.clone());
}
if let Some(ref g) = parent_group_id {
props.insert("parent.groupId".to_string(), g.clone());
props.insert("project.parent.groupId".to_string(), g.clone());
}
if let Some(ref v) = parent_version {
props.insert("parent.version".to_string(), v.clone());
props.insert("project.parent.version".to_string(), v.clone());
}
for node in root.children() {
if node.tag_name().name() == "properties" {
for child in node.children() {
if child.is_element() {
if let Some(val) = child.text() {
props.insert(child.tag_name().name().to_string(), val.to_string());
}
}
}
}
}
props
}
pub fn collect_managed_versions_with_bom(
doc: &roxmltree::Document,
properties: &HashMap<String, String>,
client: &reqwest::blocking::Client,
cache_dir: &Path,
registries: &[RegistryEntry],
bom_depth: u8,
) -> HashMap<String, String> {
if bom_depth > 10 {
return HashMap::new(); }
let mut managed = HashMap::new();
for node in doc.descendants() {
if node.tag_name().name() != "dependencyManagement" {
continue;
}
for deps_node in node.children() {
if deps_node.tag_name().name() != "dependencies" {
continue;
}
for dep in deps_node.children() {
if dep.tag_name().name() != "dependency" {
continue;
}
let mut g = None;
let mut a = None;
let mut v = None;
let mut scope = None;
let mut dep_type = None;
for child in dep.children() {
match child.tag_name().name() {
"groupId" => g = child.text(),
"artifactId" => a = child.text(),
"version" => v = child.text(),
"scope" => scope = child.text(),
"type" => dep_type = child.text(),
_ => {}
}
}
if let (Some(g), Some(a)) = (g, a) {
let resolved_g = resolve_properties(g, properties);
let resolved_a = resolve_properties(a, properties);
if scope == Some("import") && dep_type == Some("pom") {
if let Some(v) = v {
let resolved_v = resolve_properties(v, properties);
if !resolved_v.contains("${") {
let bom_coord = MavenCoord {
group_id: resolved_g.clone(),
artifact_id: resolved_a.clone(),
version: resolved_v,
classifier: None,
exclusions: Vec::new(),
scope: None,
};
let bom_pom_path = bom_coord.pom_path(cache_dir);
if !bom_pom_path.exists() {
let _ = download_from_repos(
client, &bom_coord, &bom_pom_path, registries, None,
|c, r| c.pom_url(r),
);
}
if bom_pom_path.exists() {
if let Ok(bom_content) = std::fs::read_to_string(&bom_pom_path) {
if let Ok(bom_doc) = roxmltree::Document::parse(&bom_content) {
let mut bom_props = properties.clone();
let bom_local_props = collect_pom_properties(&bom_doc);
for (k, val) in bom_local_props {
bom_props.entry(k).or_insert(val);
}
let mut bom_visited = HashSet::new();
let _ = resolve_parent_properties(
client, &bom_content, cache_dir, registries,
&mut bom_props, 0, &mut bom_visited,
);
let bom_managed = collect_managed_versions_with_bom(
&bom_doc, &bom_props, client, cache_dir, registries,
bom_depth + 1,
);
for (k, val) in bom_managed {
managed.entry(k).or_insert(val);
}
}
}
}
}
}
continue;
}
if let Some(v) = v {
let resolved = resolve_properties(v, properties);
managed.insert(format!("{}:{}", resolved_g, resolved_a), resolved);
}
}
}
}
}
managed
}
fn resolve_properties(value: &str, properties: &HashMap<String, String>) -> String {
let mut result = value.to_string();
for _ in 0..10 {
let prev = result.clone();
for (key, val) in properties {
result = result.replace(&format!("${{{}}}", key), val);
}
if result == prev {
break;
}
}
result
}
fn repos_for_group_id(registries: &[RegistryEntry], group_id: &str) -> Vec<RegistryEntry> {
for entry in registries {
if let Some(ref scope_pattern) = entry.scope {
if matches_scope(group_id, scope_pattern) {
return vec![RegistryEntry {
url: entry.url.trim_end_matches('/').to_string(),
scope: entry.scope.clone(),
username: entry.username.clone(),
password: entry.password.clone(),
}];
}
}
}
let mut repos: Vec<RegistryEntry> = registries
.iter()
.filter(|e| e.scope.is_none())
.map(|e| RegistryEntry {
url: e.url.trim_end_matches('/').to_string(),
scope: None,
username: e.username.clone(),
password: e.password.clone(),
})
.collect();
let central = DEFAULT_REPO.to_string();
if !repos.iter().any(|r| r.url == central) {
repos.push(RegistryEntry {
url: central,
scope: None,
username: None,
password: None,
});
}
repos
}
fn matches_scope(group_id: &str, pattern: &str) -> bool {
pattern.split(',').any(|p| {
let p = p.trim();
if let Some(prefix) = p.strip_suffix(".*") {
group_id == prefix || group_id.starts_with(&format!("{}.", prefix))
} else {
group_id == p
}
})
}
fn download_from_repos(
client: &reqwest::blocking::Client,
coord: &MavenCoord,
path: &Path,
registries: &[RegistryEntry],
progress: Option<&(dyn Fn(u64, u64) + Send + Sync)>,
url_fn: impl Fn(&MavenCoord, &str) -> String,
) -> Result<String> {
let repos = repos_for_group_id(registries, &coord.group_id);
if repos.is_empty() {
bail!(
"download failed for {}:{}:{}: no repositories configured",
coord.group_id, coord.artifact_id, coord.version
);
}
let mut repo_errors: Vec<String> = Vec::new();
for repo in &repos {
let url = url_fn(coord, &repo.url);
let creds = match (&repo.username, &repo.password) {
(Some(u), Some(p)) => Some((u.as_str(), p.as_str())),
_ => None,
};
match download_file(client, &url, path, progress, creds) {
Ok(hash) => return Ok(hash),
Err(e) => repo_errors.push(e.to_string()),
}
}
let mut msg = format!(
"download failed for {}:{}:{} across {} repositor{}",
coord.group_id, coord.artifact_id, coord.version,
repo_errors.len(),
if repo_errors.len() == 1 { "y" } else { "ies" }
);
for (i, e) in repo_errors.iter().enumerate() {
let indented = e.replace('\n', "\n ");
msg.push_str(&format!("\n repo #{}: {}", i + 1, indented));
}
Err(anyhow::anyhow!(msg))
}
struct ValidationTarget {
coord: MavenCoord,
registry_url: String,
}
fn compute_sha1_file(path: &Path) -> Result<String> {
use sha1::{Digest, Sha1};
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha1::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn compute_sha256_file(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn fetch_remote_sha1(
client: &reqwest::blocking::Client,
url: &str,
creds: Option<&(String, String)>,
) -> Result<Option<String>> {
let mut request = client.get(url);
if let Some((username, password)) = creds {
request = request.basic_auth(username, Some(password));
}
let response = request.send()?;
if response.status().as_u16() == 404 {
return Ok(None);
}
if !response.status().is_success() {
bail!("HTTP {} for {}", response.status(), url);
}
let text = response.text()?;
let hash = text
.split_whitespace()
.next()
.ok_or_else(|| anyhow::anyhow!("empty sha1 response from {}", url))?;
Ok(Some(hash.to_lowercase()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Sha1CheckResult {
Match,
Mismatch,
NetworkError,
}
fn check_remote_sha1(
file_path: &Path,
sha1_url: &str,
client: &reqwest::blocking::Client,
creds: Option<&(String, String)>,
) -> Sha1CheckResult {
let local_sha1 = match compute_sha1_file(file_path) {
Ok(s) => s,
Err(_) => return Sha1CheckResult::Mismatch,
};
match fetch_remote_sha1(client, sha1_url, creds) {
Ok(None) => Sha1CheckResult::Match, Ok(Some(remote)) => {
if local_sha1 == remote {
Sha1CheckResult::Match
} else {
Sha1CheckResult::Mismatch
}
}
Err(_) => Sha1CheckResult::NetworkError,
}
}
const NETWORK_POOL_THREADS: usize = 32;
const SHA1_VALIDATION_CONNECT_MS: u64 = 800;
const SHA1_VALIDATION_REQUEST_MS: u64 = 1000;
static NETWORK_IO_POOL: std::sync::OnceLock<rayon::ThreadPool> =
std::sync::OnceLock::new();
static SHA1_VALIDATION_CLIENT: std::sync::OnceLock<reqwest::blocking::Client> =
std::sync::OnceLock::new();
fn network_io_pool() -> &'static rayon::ThreadPool {
NETWORK_IO_POOL.get_or_init(|| build_io_pool(NETWORK_POOL_THREADS))
}
fn sha1_validation_client() -> &'static reqwest::blocking::Client {
SHA1_VALIDATION_CLIENT.get_or_init(|| {
reqwest::blocking::Client::builder()
.user_agent(concat!("ym/", env!("CARGO_PKG_VERSION")))
.connect_timeout(std::time::Duration::from_millis(SHA1_VALIDATION_CONNECT_MS))
.timeout(std::time::Duration::from_millis(SHA1_VALIDATION_REQUEST_MS))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new())
})
}
fn check_target(
t: &ValidationTarget,
cache_dir: &Path,
client: &reqwest::blocking::Client,
creds: Option<&(String, String)>,
) -> Sha1CheckResult {
let jar_url = format!("{}.sha1", t.coord.jar_url(&t.registry_url));
match check_remote_sha1(&t.coord.jar_path(cache_dir), &jar_url, client, creds) {
Sha1CheckResult::Match => {}
other => return other,
}
let pom_url = format!("{}.sha1", t.coord.pom_url(&t.registry_url));
check_remote_sha1(&t.coord.pom_path(cache_dir), &pom_url, client, creds)
}
fn validate_sha1_remote(targets: &[ValidationTarget], cache_dir: &Path) -> Vec<MavenCoord> {
use rayon::prelude::*;
if targets.is_empty() {
return Vec::new();
}
let client = sha1_validation_client();
let pool = network_io_pool();
let mut creds_by_registry: Vec<(&str, Option<(String, String)>)> = Vec::new();
for t in targets {
if !creds_by_registry.iter().any(|(u, _)| *u == t.registry_url) {
creds_by_registry.push((
t.registry_url.as_str(),
load_credentials_for_url(&t.registry_url),
));
}
}
let lookup_creds = |registry_url: &str| -> Option<&(String, String)> {
creds_by_registry
.iter()
.find(|(u, _)| *u == registry_url)
.and_then(|(_, c)| c.as_ref())
};
let (mismatched, network_errors) = pool.install(|| {
targets
.par_iter()
.fold(
|| (Vec::<MavenCoord>::new(), 0usize),
|(mut m, n), t| {
let creds = lookup_creds(&t.registry_url);
match check_target(t, cache_dir, client, creds) {
Sha1CheckResult::Match => (m, n),
Sha1CheckResult::Mismatch => {
m.push(t.coord.clone());
(m, n)
}
Sha1CheckResult::NetworkError => (m, n + 1),
}
},
)
.reduce(
|| (Vec::new(), 0usize),
|(mut m1, n1), (m2, n2)| {
m1.extend(m2);
(m1, n1 + n2)
},
)
});
if !mismatched.is_empty() {
return mismatched;
}
if network_errors > 0 && !crate::is_progress_quiet() {
let total = targets.len();
if network_errors == total {
eprintln!(
" {} offline mode, using cache (may be stale)",
console::style("warning").yellow()
);
} else {
eprintln!(
" {} sha1 validation incomplete ({}/{} unreachable), using cache",
console::style("warning").yellow(),
network_errors,
total
);
}
}
Vec::new()
}
struct AttemptOutcome {
http_status: Option<u16>,
body_bytes: Option<u64>,
category: String,
}
fn verify_pom_body(body: &[u8]) -> std::result::Result<(), String> {
if body.is_empty() {
return Err("empty body".to_string());
}
let text = match std::str::from_utf8(body) {
Ok(s) => s,
Err(e) => return Err(format!("invalid UTF-8 in POM body: {}", e)),
};
let doc = match roxmltree::Document::parse(text) {
Ok(d) => d,
Err(e) => return Err(format!("parse error: {}", e)),
};
let root_name = doc.root_element().tag_name().name();
if root_name != "project" {
return Err(format!("unexpected root element <{}> (expected <project>)", root_name));
}
Ok(())
}
fn download_file(
client: &reqwest::blocking::Client,
url: &str,
path: &Path,
progress: Option<&(dyn Fn(u64, u64) + Send + Sync)>,
inline_creds: Option<(&str, &str)>,
) -> Result<String> {
let max_retries = 3;
let mut last_outcome: Option<AttemptOutcome> = None;
let is_pom = path.extension().and_then(|s| s.to_str()) == Some("pom");
for attempt in 0..max_retries {
if attempt > 0 {
let delay = std::time::Duration::from_secs(1 << attempt); std::thread::sleep(delay);
}
let mut request = client.get(url);
if let Some((username, password)) = inline_creds {
request = request.basic_auth(username, Some(password));
} else if let Some((username, password)) = load_credentials_for_url(url) {
request = request.basic_auth(username, Some(password));
}
match request.send() {
Ok(response) => {
if response.status().is_success() {
let status = response.status().as_u16();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content_len = response.content_length().unwrap_or(0);
if let Some(cb) = &progress {
cb(content_len, 0); }
let tmp_path = path.with_extension("part");
let stream_result = (|| -> Result<(String, u64)> {
use sha2::{Digest, Sha256};
use std::io::Read;
let mut reader = response;
let mut file = std::fs::File::create(&tmp_path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
let mut bytes_written: u64 = 0;
loop {
let n = reader.read(&mut buf)?;
if n == 0 { break; }
hasher.update(&buf[..n]);
std::io::Write::write_all(&mut file, &buf[..n])?;
bytes_written += n as u64;
if let Some(cb) = &progress {
cb(0, n as u64);
}
}
drop(file);
Ok((format!("{:x}", hasher.finalize()), bytes_written))
})();
match stream_result {
Ok((hash, bytes_written)) => {
if is_pom {
match std::fs::read(&tmp_path) {
Ok(body) => match verify_pom_body(&body) {
Ok(()) => {
std::fs::rename(&tmp_path, path)?;
return Ok(hash);
}
Err(reason) => {
let _ = std::fs::remove_file(&tmp_path);
last_outcome = Some(AttemptOutcome {
http_status: Some(status),
body_bytes: Some(bytes_written),
category: format!("truncated body ({})", reason),
});
continue; }
},
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
last_outcome = Some(AttemptOutcome {
http_status: Some(status),
body_bytes: Some(bytes_written),
category: format!("read tmp failed: {}", e),
});
continue;
}
}
}
std::fs::rename(&tmp_path, path)?;
return Ok(hash);
}
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
last_outcome = Some(AttemptOutcome {
http_status: Some(status),
body_bytes: None,
category: format!("download stream failed: {}", e),
});
}
}
} else if response.status().as_u16() == 404 {
bail!("HTTP 404 for {}", url);
} else {
last_outcome = Some(AttemptOutcome {
http_status: Some(response.status().as_u16()),
body_bytes: None,
category: format!("HTTP {}", response.status()),
});
}
}
Err(e) => {
last_outcome = Some(AttemptOutcome {
http_status: None,
body_bytes: None,
category: format!("request failed: {}", e),
});
}
}
}
let outcome = last_outcome.unwrap_or(AttemptOutcome {
http_status: None,
body_bytes: None,
category: "unknown failure".to_string(),
});
let status_str = outcome.http_status.map(|s| format!("HTTP {}", s)).unwrap_or_else(|| "no response".to_string());
let bytes_str = outcome.body_bytes.map(|b| format!("{} bytes", b)).unwrap_or_else(|| "n/a".to_string());
let hint = if outcome.category.starts_with("truncated body") {
"\n hint: registry may be returning incomplete responses (CDN edge node? proxy?)"
} else if outcome.http_status.map(|s| s >= 500).unwrap_or(false) {
"\n hint: registry returned 5xx — temporary outage, retry later"
} else if outcome.category.starts_with("request failed") {
"\n hint: network/DNS issue or registry unreachable; check credentials and connectivity"
} else {
""
};
Err(anyhow::anyhow!(
"download failed after {} retries\n URL: {}\n last attempt: {}, {}\n failure category: {}{}",
max_retries, url, status_str, bytes_str, outcome.category, hint
))
}
static GPG_FAIL_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static GPG_WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn verify_gpg_signature(
client: &reqwest::blocking::Client,
coord: &MavenCoord,
jar_path: &Path,
registries: &[RegistryEntry],
) {
if crate::is_progress_quiet() {
return;
}
let asc_path = jar_path.with_extension("jar.asc");
let asc_result = download_from_repos(client, coord, &asc_path, registries, None, |c, r| {
format!("{}.asc", c.jar_url(r))
});
if asc_result.is_err() {
return;
}
let gpg_check = std::process::Command::new("gpg")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if gpg_check.is_err() || !gpg_check.unwrap().success() {
let _ = std::fs::remove_file(&asc_path);
return;
}
let status = std::process::Command::new("gpg")
.arg("--batch")
.arg("--verify")
.arg(&asc_path)
.arg(jar_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match status {
Ok(s) if s.success() => {
let _ = std::fs::remove_file(&asc_path);
}
_ => {
GPG_FAIL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _ = std::fs::remove_file(&asc_path);
}
}
}
fn print_gpg_summary() {
let count = GPG_FAIL_COUNT.swap(0, std::sync::atomic::Ordering::Relaxed);
if count > 0 && !GPG_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!(
"{} {} artifact(s) failed GPG signature verification (missing public keys?)",
console::style(format!("{:>12}", "warning")).yellow().bold(),
count
);
}
}
fn extract_xml_text(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].trim().to_string())
}
fn load_credentials_for_url(url: &str) -> Option<(String, String)> {
let creds_path = crate::home_dir().join(".ym").join("credentials.json");
let content = std::fs::read_to_string(&creds_path).ok()?;
let creds: std::collections::BTreeMap<String, serde_json::Value> =
serde_json::from_str(&content).ok()?;
let normalized = url.trim_end_matches('/');
for (registry_url, value) in &creds {
let reg_normalized = registry_url.trim_end_matches('/');
if normalized.starts_with(reg_normalized) {
if let Some(token) = value.get("token").and_then(|t| t.as_str()) {
return Some((token.to_string(), String::new()));
}
let username = value.get("username")?.as_str()?.to_string();
let password = value.get("password")?.as_str()?.to_string();
return Some((username, password));
}
}
None
}
pub fn check_conflicts(lock: &Lockfile) -> Vec<(String, Vec<String>)> {
let mut versions_map: BTreeMap<String, Vec<String>> = BTreeMap::new();
for key in lock.dependencies.keys() {
if let Some(mc) = MavenCoord::from_versioned_key(key) {
if mc.classifier.is_none() {
let ga = format!("{}:{}", mc.group_id, mc.artifact_id);
versions_map
.entry(ga)
.or_default()
.push(mc.version);
}
}
}
versions_map
.into_iter()
.filter(|(_, versions)| versions.len() > 1)
.collect()
}
#[allow(dead_code)]
pub fn resolve_workspace_deps(
all_module_deps: &[(String, BTreeMap<String, String>)],
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
) -> Result<HashMap<String, Vec<PathBuf>>> {
resolve_workspace_deps_with_resolutions(all_module_deps, cache_dir, lock, registries, exclusions, &Default::default())
}
#[allow(clippy::too_many_arguments)]
pub fn resolve_workspace_deps_with_resolutions(
all_module_deps: &[(String, BTreeMap<String, String>)],
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
) -> Result<HashMap<String, Vec<PathBuf>>> {
let mut merged_deps = BTreeMap::new();
for (_name, deps) in all_module_deps {
for (coord, version) in deps {
merged_deps.entry(coord.clone()).or_insert(version.clone());
}
}
if merged_deps.is_empty() {
return Ok(all_module_deps.iter().map(|(name, _)| (name.clone(), vec![])).collect());
}
let _all_jars = resolve_and_download_with_resolutions(&merged_deps, cache_dir, lock, registries, exclusions, resolutions)?;
if !crate::is_json_quiet() && console::Term::stderr().is_term() {
resolver_progress("Distributing dependencies...");
}
Ok(distribute_jars_per_module(all_module_deps, cache_dir, lock))
}
fn distribute_jars_per_module(
all_module_deps: &[(String, BTreeMap<String, String>)],
cache_dir: &Path,
lock: &Lockfile,
) -> HashMap<String, Vec<PathBuf>> {
let mut ga_to_versioned: HashMap<String, String> = HashMap::new();
for key in lock.dependencies.keys() {
if let Some(mc) = MavenCoord::from_versioned_key(key) {
let ga = format!("{}:{}", mc.group_id, mc.artifact_id);
ga_to_versioned.entry(ga).or_insert(key.clone());
}
}
let mut per_module = HashMap::new();
for (name, deps) in all_module_deps {
let mut module_jars = Vec::new();
let mut visited_keys = HashSet::new();
let mut queue = VecDeque::new();
for (coord, version) in deps {
if let Ok(mc) = MavenCoord::parse(coord, version) {
let vk = mc.versioned_key();
if lock.dependencies.contains_key(&vk) {
queue.push_back(vk);
} else if let Some(resolved_key) = ga_to_versioned.get(coord) {
queue.push_back(resolved_key.clone());
}
}
}
while let Some(key) = queue.pop_front() {
if !visited_keys.insert(key.clone()) {
continue;
}
if let Some(coord) = MavenCoord::from_versioned_key(&key) {
let jar = coord.jar_path(cache_dir);
if jar.exists() {
module_jars.push(jar);
} else {
let ga = format!("{}:{}", coord.group_id, coord.artifact_id);
if let Some(resolved_key) = ga_to_versioned.get(&ga) {
if let Some(rc) = MavenCoord::from_versioned_key(resolved_key) {
let rjar = rc.jar_path(cache_dir);
if rjar.exists() {
module_jars.push(rjar);
}
}
}
}
}
let locked_entry = lock.dependencies.get(&key).or_else(|| {
MavenCoord::from_versioned_key(&key).and_then(|mc| {
let ga = format!("{}:{}", mc.group_id, mc.artifact_id);
ga_to_versioned.get(&ga).and_then(|resolved_key| lock.dependencies.get(resolved_key))
})
});
if let Some(locked) = locked_entry {
if let Some(ref dep_keys) = locked.dependencies {
for dk in dep_keys {
if !visited_keys.contains(dk) {
queue.push_back(dk.clone());
}
}
}
}
}
append_native_jars(&mut module_jars);
per_module.insert(name.clone(), module_jars);
}
per_module
}
pub fn resolve_workspace_deps_no_download(
all_module_deps: &[(String, BTreeMap<String, String>)],
cache_dir: &Path,
lock: &mut Lockfile,
registries: &[RegistryEntry],
exclusions: &[String],
resolutions: &BTreeMap<String, String>,
) -> Result<HashMap<String, Vec<PathBuf>>> {
let mut merged_deps = BTreeMap::new();
for (_name, deps) in all_module_deps {
for (coord, version) in deps {
merged_deps.entry(coord.clone()).or_insert(version.clone());
}
}
if merged_deps.is_empty() {
return Ok(all_module_deps.iter().map(|(name, _)| (name.clone(), vec![])).collect());
}
let _all_jars = resolve_no_download(
&merged_deps, cache_dir, lock, registries, exclusions, resolutions, &HashMap::new(),
)?;
Ok(distribute_jars_per_module(all_module_deps, cache_dir, lock))
}
pub fn search_maven(query: &str) -> Result<Vec<(String, String, String)>> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("ym/", env!("CARGO_PKG_VERSION")))
.build()?;
let is_plain = !query.contains(':') && !query.contains('*') && !query.contains(" AND ") && !query.contains(" OR ");
let queries: Vec<String> = if is_plain {
vec![
format!("a:{}-*", query), query.to_string(), ]
} else {
vec![query.to_string()]
};
let mut results = Vec::new();
let mut seen = std::collections::HashSet::new();
for q in &queries {
let response = client
.get("https://search.maven.org/solrsearch/select")
.query(&[("q", q.as_str()), ("rows", "20"), ("wt", "json")])
.send()?;
let body: serde_json::Value = response.json()?;
if let Some(docs) = body["response"]["docs"].as_array() {
for doc in docs {
let g = doc["g"].as_str().unwrap_or("").to_string();
let a = doc["a"].as_str().unwrap_or("").to_string();
let v = doc["latestVersion"].as_str().unwrap_or("").to_string();
let key = format!("{}:{}", g, a);
if !g.is_empty() && !a.is_empty() && !v.is_empty() && seen.insert(key) {
results.push((g, a, v));
}
}
}
}
Ok(results)
}
pub fn fetch_latest_version(group_id: &str, artifact_id: &str) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("ym/", env!("CARGO_PKG_VERSION")))
.build()?;
let url = format!(
"https://search.maven.org/solrsearch/select?q=g:\"{}\" AND a:\"{}\"&rows=1&wt=json",
group_id, artifact_id
);
if let Ok(response) = client.get(&url).send() {
if let Ok(body) = response.json::<serde_json::Value>() {
if let Some(docs) = body["response"]["docs"].as_array() {
if let Some(doc) = docs.first() {
if let Some(v) = doc["latestVersion"].as_str() {
return Ok(v.to_string());
}
}
}
}
}
let metadata_url = format!(
"https://repo1.maven.org/maven2/{}/{}/maven-metadata.xml",
group_id.replace('.', "/"),
artifact_id
);
if let Ok(response) = client.get(&metadata_url).send() {
if response.status().is_success() {
if let Ok(text) = response.text() {
if let Some(v) = extract_xml_value(&text, "release") {
return Ok(v);
}
let mut last_version = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("<version>") && trimmed.ends_with("</version>") {
last_version = Some(
trimmed
.strip_prefix("<version>")
.unwrap()
.strip_suffix("</version>")
.unwrap()
.to_string(),
);
}
}
if let Some(v) = last_version {
return Ok(v);
}
}
}
}
bail!("Could not find {}:{} on Maven Central", group_id, artifact_id)
}
fn extract_xml_value(text: &str, tag: &str) -> Option<String> {
let open = format!("<{}>", tag);
let close = format!("</{}>", tag);
let start = text.find(&open)? + open.len();
let end = text[start..].find(&close)? + start;
let val = text[start..end].trim();
if val.is_empty() { None } else { Some(val.to_string()) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_maven_coord_parse_basic() {
let mc = MavenCoord::parse("org.springframework:spring-core", "5.3.0").unwrap();
assert_eq!(mc.group_id, "org.springframework");
assert_eq!(mc.artifact_id, "spring-core");
assert_eq!(mc.version, "5.3.0");
}
#[test]
fn test_maven_coord_parse_strips_prefix() {
let mc = MavenCoord::parse("com.example:lib", "^2.19.0").unwrap();
assert_eq!(mc.version, "2.19.0");
let mc2 = MavenCoord::parse("com.example:lib", "~1.5.0").unwrap();
assert_eq!(mc2.version, "1.5.0");
}
#[test]
fn test_maven_coord_parse_invalid() {
assert!(MavenCoord::parse("invalid", "1.0").is_err());
assert!(MavenCoord::parse("a:b:c:d", "1.0").is_err()); }
#[test]
fn test_maven_coord_parse_classifier() {
let mc = MavenCoord::parse("org.lwjgl:lwjgl:natives-linux", "3.3.3").unwrap();
assert_eq!(mc.group_id, "org.lwjgl");
assert_eq!(mc.artifact_id, "lwjgl");
assert_eq!(mc.version, "3.3.3");
assert_eq!(mc.classifier.as_deref(), Some("natives-linux"));
assert!(mc.jar_url("https://repo1.maven.org/maven2").contains("lwjgl-3.3.3-natives-linux.jar"));
}
#[test]
fn test_maven_coord_keys() {
let mc = MavenCoord::parse("org.example:lib", "1.0").unwrap();
assert_eq!(mc.key(), "org.example:lib");
assert_eq!(mc.versioned_key(), "org.example:lib:1.0");
}
#[test]
fn test_maven_coord_urls() {
let mc = MavenCoord::parse("org.example:lib", "1.0").unwrap();
let repo = "https://repo1.maven.org/maven2";
assert_eq!(mc.jar_url(repo), "https://repo1.maven.org/maven2/org/example/lib/1.0/lib-1.0.jar");
assert_eq!(mc.pom_url(repo), "https://repo1.maven.org/maven2/org/example/lib/1.0/lib-1.0.pom");
}
#[test]
fn test_maven_coord_paths() {
let mc = MavenCoord::parse("org.example:lib", "1.0").unwrap();
let cache = Path::new("/tmp/cache");
assert_eq!(mc.jar_path(cache), PathBuf::from("/tmp/cache/org.example/lib/1.0/lib-1.0.jar"));
assert_eq!(mc.pom_path(cache), PathBuf::from("/tmp/cache/org.example/lib/1.0/lib-1.0.pom"));
}
#[test]
fn test_resolve_properties_simple() {
let mut props = HashMap::new();
props.insert("spring.version".to_string(), "5.3.0".to_string());
assert_eq!(resolve_properties("${spring.version}", &props), "5.3.0");
}
#[test]
fn test_resolve_properties_no_placeholder() {
let props = HashMap::new();
assert_eq!(resolve_properties("plain-text", &props), "plain-text");
}
#[test]
fn test_resolve_properties_transitive() {
let mut props = HashMap::new();
props.insert("base".to_string(), "1.0".to_string());
props.insert("derived".to_string(), "${base}".to_string());
assert_eq!(resolve_properties("${derived}", &props), "1.0");
}
#[test]
fn test_resolve_properties_multiple() {
let mut props = HashMap::new();
props.insert("g".to_string(), "org.example".to_string());
props.insert("v".to_string(), "2.0".to_string());
assert_eq!(resolve_properties("${g}:lib:${v}", &props), "org.example:lib:2.0");
}
#[test]
fn test_resolve_properties_unresolved() {
let props = HashMap::new();
assert_eq!(resolve_properties("${unknown}", &props), "${unknown}");
}
#[test]
fn test_collect_pom_properties_basic() {
let pom = r#"<?xml version="1.0"?>
<project>
<groupId>org.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
<properties>
<spring.version>5.3.0</spring.version>
<java.version>17</java.version>
</properties>
</project>"#;
let doc = roxmltree::Document::parse(pom).unwrap();
let props = collect_pom_properties(&doc);
assert_eq!(props.get("spring.version").unwrap(), "5.3.0");
assert_eq!(props.get("java.version").unwrap(), "17");
assert_eq!(props.get("project.groupId").unwrap(), "org.example");
assert_eq!(props.get("project.version").unwrap(), "1.0.0");
assert_eq!(props.get("project.artifactId").unwrap(), "my-app");
}
#[test]
fn test_collect_pom_properties_inherits_parent() {
let pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>org.parent</groupId>
<artifactId>parent-pom</artifactId>
<version>2.0.0</version>
</parent>
<artifactId>child</artifactId>
</project>"#;
let doc = roxmltree::Document::parse(pom).unwrap();
let props = collect_pom_properties(&doc);
assert_eq!(props.get("project.groupId").unwrap(), "org.parent");
assert_eq!(props.get("project.version").unwrap(), "2.0.0");
assert_eq!(props.get("parent.groupId").unwrap(), "org.parent");
assert_eq!(props.get("parent.version").unwrap(), "2.0.0");
}
#[test]
fn test_collect_managed_versions_basic() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib-a</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib-b</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#;
let doc = roxmltree::Document::parse(pom).unwrap();
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let managed = collect_managed_versions_with_bom(&doc, &props, &client, Path::new("/tmp"), &[], 0);
assert_eq!(managed.get("com.example:lib-a").unwrap(), "1.0");
assert_eq!(managed.get("com.example:lib-b").unwrap(), "2.0");
}
#[test]
fn test_collect_managed_versions_with_property_resolution() {
let pom = r#"<?xml version="1.0"?>
<project>
<properties>
<lib.version>3.0</lib.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>${lib.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#;
let doc = roxmltree::Document::parse(pom).unwrap();
let mut props = HashMap::new();
props.insert("lib.version".to_string(), "3.0".to_string());
let client = reqwest::blocking::Client::new();
let managed = collect_managed_versions_with_bom(&doc, &props, &client, Path::new("/tmp"), &[], 0);
assert_eq!(managed.get("com.example:lib").unwrap(), "3.0");
}
#[test]
fn test_bom_depth_limit() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>bom</artifactId>
<version>1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#;
let doc = roxmltree::Document::parse(pom).unwrap();
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let managed = collect_managed_versions_with_bom(&doc, &props, &client, Path::new("/tmp"), &[], 11);
assert!(managed.is_empty());
}
#[test]
fn test_parse_pom_dependencies_basic() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].group_id, "com.example");
assert_eq!(deps[0].artifact_id, "lib");
assert_eq!(deps[0].version, "1.0");
}
#[test]
fn test_parse_pom_skips_test_scope() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_parse_pom_skips_optional() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>optional-lib</artifactId>
<version>1.0</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_parse_pom_skips_provided_scope() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>servlet-api</artifactId>
<version>3.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_parse_pom_uses_managed_version() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].version, "2.0");
}
#[test]
fn test_parse_pom_skips_dependency_management_section() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.managed</groupId>
<artifactId>lib</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_repos_for_group_id_no_registries() {
let repos = repos_for_group_id(&[], "com.example");
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].url, DEFAULT_REPO);
}
#[test]
fn test_repos_for_group_id_unscoped_registry() {
let entries = vec![
RegistryEntry { url: "https://custom.repo/maven".into(), scope: None, username: None, password: None },
];
let repos = repos_for_group_id(&entries, "com.example");
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].url, "https://custom.repo/maven");
assert_eq!(repos[1].url, DEFAULT_REPO);
}
#[test]
fn test_repos_for_group_id_scope_match() {
let entries = vec![
RegistryEntry { url: "https://private.repo/maven".into(), scope: Some("com.mycompany.*".into()), username: None, password: None },
RegistryEntry { url: "https://other.repo/maven".into(), scope: None, username: None, password: None },
];
let repos = repos_for_group_id(&entries, "com.mycompany.core");
assert_eq!(repos.len(), 1);
assert_eq!(repos[0].url, "https://private.repo/maven");
let repos = repos_for_group_id(&entries, "org.apache.commons");
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].url, "https://other.repo/maven");
assert_eq!(repos[1].url, DEFAULT_REPO);
}
#[test]
fn test_repos_for_group_id_no_duplicate_central() {
let entries = vec![
RegistryEntry { url: DEFAULT_REPO.into(), scope: None, username: None, password: None },
];
let repos = repos_for_group_id(&entries, "com.example");
assert_eq!(repos.len(), 1);
}
#[test]
fn test_repos_for_group_id_trims_trailing_slash() {
let entries = vec![
RegistryEntry { url: "https://custom.repo/maven/".into(), scope: None, username: None, password: None },
];
let repos = repos_for_group_id(&entries, "com.example");
assert_eq!(repos[0].url, "https://custom.repo/maven");
}
#[test]
fn test_matches_scope_wildcard() {
assert!(matches_scope("com.mycompany.core", "com.mycompany.*"));
assert!(matches_scope("com.mycompany.core.utils", "com.mycompany.*"));
assert!(matches_scope("com.mycompany", "com.mycompany.*"));
assert!(!matches_scope("com.mycompanyextras", "com.mycompany.*"));
assert!(!matches_scope("org.apache", "com.mycompany.*"));
}
#[test]
fn test_matches_scope_exact() {
assert!(matches_scope("com.mycompany", "com.mycompany"));
assert!(!matches_scope("com.mycompany.core", "com.mycompany"));
}
#[test]
fn test_check_conflicts_no_conflicts() {
let mut lock = Lockfile::default();
lock.dependencies.insert("com.example:lib:1.0".to_string(), ResolvedDependency::default());
let conflicts = check_conflicts(&lock);
assert!(conflicts.is_empty());
}
#[test]
fn test_check_conflicts_detects_multiple_versions() {
let mut lock = Lockfile::default();
lock.dependencies.insert("com.example:lib:1.0".to_string(), ResolvedDependency::default());
lock.dependencies.insert("com.example:lib:2.0".to_string(), ResolvedDependency::default());
let conflicts = check_conflicts(&lock);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].0, "com.example:lib");
assert!(conflicts[0].1.contains(&"1.0".to_string()));
assert!(conflicts[0].1.contains(&"2.0".to_string()));
}
#[test]
fn test_try_resolve_from_lock_empty() {
let deps = BTreeMap::new();
let lock = Lockfile::default();
let result = try_resolve_from_lock(&deps, Path::new("/tmp/cache"), &lock, &HashSet::new(), &BTreeMap::new(), &[], false);
assert!(result.unwrap().is_none());
}
#[test]
fn test_compute_sha256_file() {
let dir = std::env::temp_dir().join(format!("ym_sha256_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("x.txt");
std::fs::write(&f, b"hello").unwrap();
assert_eq!(
compute_sha256_file(&f).unwrap(),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_pom_cache_entry_roundtrip() {
let entry = PomCacheEntry {
schema_version: POM_CACHE_SCHEMA_VERSION,
pom_sha256: "abc123".to_string(),
resolved: vec![("g".into(), "a".into(), "1.0".into(), vec!["x:y".into()])],
};
let json = serde_json::to_string(&entry).unwrap();
let back: PomCacheEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back.schema_version, POM_CACHE_SCHEMA_VERSION);
assert_eq!(back.pom_sha256, "abc123");
assert_eq!(back.resolved.len(), 1);
let old = r#"[["g","a","1.0",[]]]"#;
assert!(serde_json::from_str::<PomCacheEntry>(old).is_err());
}
#[test]
fn test_validate_sha1_remote_empty() {
assert!(validate_sha1_remote(&[], Path::new("/tmp/cache")).is_empty());
}
#[test]
fn test_purge_artifact_cache() {
let dir = std::env::temp_dir().join(format!("ym_purge_test_{}", std::process::id()));
let coord = MavenCoord::parse("com.example:purge-lib", "9.9.9").unwrap();
let jar = coord.jar_path(&dir);
let pom = coord.pom_path(&dir);
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
std::fs::write(&jar, b"jar").unwrap();
std::fs::write(&pom, b"pom").unwrap();
assert!(jar.exists() && pom.exists());
purge_artifact_cache(&coord, &dir);
assert!(!jar.exists(), "jar should be purged");
assert!(!pom.exists(), "pom should be purged");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_pom_cache_insert_and_get() {
let cache = PomCache::new();
let deps = vec![
MavenCoord { group_id: "com.example".into(), artifact_id: "lib".into(), version: "1.0".into(), classifier: None, exclusions: Vec::new(), scope: None },
];
cache.insert("com.example:parent:1.0", &deps);
let cached = cache.get("com.example:parent:1.0");
assert!(cached.is_some());
let cached = cached.unwrap();
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].group_id, "com.example");
}
#[test]
fn test_pom_cache_miss() {
let cache = PomCache::new();
assert!(cache.get("nonexistent:key:1.0").is_none());
}
#[test]
fn test_resolve_workspace_deps_empty() {
let module_deps: Vec<(String, BTreeMap<String, String>)> = vec![
("mod-a".into(), BTreeMap::new()),
];
let mut lock = Lockfile::default();
let result = resolve_workspace_deps(
&module_deps, Path::new("/tmp/cache"), &mut lock, &[], &[],
).unwrap();
assert_eq!(result.get("mod-a").unwrap().len(), 0);
}
#[test]
fn test_version_compare_basic_ordering() {
assert!(version_compare("1.0", "2.0") < 0);
assert!(version_compare("2.0", "1.0") > 0);
assert_eq!(version_compare("1.0", "1.0"), 0);
}
#[test]
fn test_version_compare_semver_not_lexical() {
assert!(version_compare("1.10.0", "1.9.0") > 0);
assert!(version_compare("2.10", "2.2") > 0);
assert!(version_compare("4.0.10", "4.0.3") > 0);
}
#[test]
fn test_version_compare_qualifier_release_wins() {
assert!(version_compare("4.0.3", "4.0.3-5") > 0);
assert!(version_compare("1.0", "1.0-SNAPSHOT") > 0);
}
#[test]
fn test_version_compare_uneven_segment_count() {
assert_eq!(version_compare("1.0", "1.0.0"), 0);
assert!(version_compare("1.0.1", "1.0") > 0);
assert!(version_compare("2", "1.99.99") > 0);
}
#[test]
fn test_maven_coord_clone() {
let mc = MavenCoord::parse("org.example:lib", "1.0").unwrap();
let mc2 = mc.clone();
assert_eq!(mc.group_id, mc2.group_id);
assert_eq!(mc.artifact_id, mc2.artifact_id);
assert_eq!(mc.version, mc2.version);
}
#[test]
fn test_resolve_properties_deeply_nested() {
let mut props = HashMap::new();
props.insert("a".to_string(), "${b}".to_string());
props.insert("b".to_string(), "${c}".to_string());
props.insert("c".to_string(), "${d}".to_string());
props.insert("d".to_string(), "final_value".to_string());
assert_eq!(resolve_properties("${a}", &props), "final_value");
}
#[test]
fn test_resolve_properties_circular_stops() {
let mut props = HashMap::new();
props.insert("a".to_string(), "${b}".to_string());
props.insert("b".to_string(), "${a}".to_string());
let result = resolve_properties("${a}", &props);
assert!(result.contains("${"));
}
#[test]
fn test_parse_pom_skips_import_scope() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.0</version>
<scope>import</scope>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert!(deps.is_empty());
}
#[test]
fn test_parse_pom_uses_parent_managed_version() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
</dependency>
</dependencies>
</project>"#;
let mut props = HashMap::new();
props.insert("managed:com.example:lib".to_string(), "3.0".to_string());
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].version, "3.0");
}
#[test]
fn test_parse_pom_multiple_deps() {
let pom = r#"<?xml version="1.0"?>
<project>
<dependencies>
<dependency>
<groupId>com.a</groupId>
<artifactId>lib-a</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.b</groupId>
<artifactId>lib-b</artifactId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.c</groupId>
<artifactId>lib-c</artifactId>
<version>3.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let deps = parse_pom_dependencies_with_props(pom, &props, &client, Path::new("/tmp"), &[]).unwrap();
assert_eq!(deps.len(), 3);
}
#[test]
fn test_pom_cache_entry_v1_schema_rejected() {
let v1_json = r#"{"pom_sha256":"abc","resolved":[]}"#;
let result: std::result::Result<PomCacheEntry, _> = serde_json::from_str(v1_json);
assert!(result.is_err(), "v1 schema must fail to deserialize as v2");
}
#[test]
fn test_pom_cache_entry_v2_schema_roundtrip() {
let entry = PomCacheEntry {
schema_version: POM_CACHE_SCHEMA_VERSION,
pom_sha256: "deadbeef".to_string(),
resolved: vec![("g".to_string(), "a".to_string(), "1.0".to_string(), vec![])],
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"schema_version\":2"), "v2 must persist schema_version=2");
let parsed: PomCacheEntry = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.schema_version, 2);
assert_eq!(parsed.pom_sha256, "deadbeef");
assert_eq!(parsed.resolved.len(), 1);
}
fn write_pom_to_cache(cache_dir: &Path, g: &str, a: &str, v: &str, body: &str) {
let coord = MavenCoord {
group_id: g.to_string(),
artifact_id: a.to_string(),
version: v.to_string(),
classifier: None,
exclusions: Vec::new(),
scope: None,
};
let pom = coord.pom_path(cache_dir);
std::fs::create_dir_all(pom.parent().unwrap()).unwrap();
std::fs::write(&pom, body).unwrap();
}
#[test]
fn test_collect_parent_dependencies_inherits_parent_block() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_p021_inherit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let parent_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>services</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>sdk-core</artifactId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "services", "1.0", parent_pom);
let child_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>services</artifactId>
<version>1.0</version>
</parent>
<artifactId>ec2</artifactId>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>protocol-core</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let mut visited = HashSet::new();
let parent_deps = collect_parent_dependencies(
&client, child_pom, &cache_dir, &[], &props, 0, &mut visited,
).unwrap();
let gas: Vec<String> = parent_deps.iter()
.map(|d| format!("{}:{}", d.group_id, d.artifact_id))
.collect();
assert!(gas.contains(&"com.lib:sdk-core".to_string()),
"parent's sdk-core must be inherited; got {:?}", gas);
assert!(gas.contains(&"com.lib:auth".to_string()),
"parent's auth must be inherited; got {:?}", gas);
assert!(!gas.contains(&"com.lib:protocol-core".to_string()),
"child's own deps should not be in parent-chain returns");
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn test_collect_parent_dependencies_closer_parent_wins_over_grandparent() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_p021_precedence_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let grandparent_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>grandparent</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "grandparent", "1.0", grandparent_pom);
let parent_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>grandparent</artifactId>
<version>1.0</version>
</parent>
<artifactId>parent</artifactId>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>2.0</version>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>utils</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "parent", "1.0", parent_pom);
let child_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>child</artifactId>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let mut visited = HashSet::new();
let parent_deps = collect_parent_dependencies(
&client, child_pom, &cache_dir, &[], &props, 0, &mut visited,
).unwrap();
let auth = parent_deps.iter().find(|d| d.artifact_id == "auth")
.expect("auth must appear");
assert_eq!(auth.version, "2.0",
"closer parent (2.0) must win over grandparent (1.0)");
assert!(parent_deps.iter().any(|d| d.artifact_id == "utils"),
"grandparent-only dep not declared by closer parent should still merge");
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn test_collect_parent_dependencies_filters_test_provided_optional() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_p021_filter_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let parent_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>compile-dep</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>test-dep</artifactId>
<version>1.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>provided-dep</artifactId>
<version>1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>optional-dep</artifactId>
<version>1.0</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "parent", "1.0", parent_pom);
let child_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>child</artifactId>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let mut visited = HashSet::new();
let parent_deps = collect_parent_dependencies(
&client, child_pom, &cache_dir, &[], &props, 0, &mut visited,
).unwrap();
let ids: Vec<String> = parent_deps.iter().map(|d| d.artifact_id.clone()).collect();
assert!(ids.contains(&"compile-dep".to_string()), "compile dep must inherit");
assert!(!ids.contains(&"test-dep".to_string()), "test-scope must be filtered");
assert!(!ids.contains(&"provided-dep".to_string()), "provided-scope must be filtered");
assert!(!ids.contains(&"optional-dep".to_string()), "optional=true must be filtered");
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn test_resolve_transitive_cached_merges_child_and_parent_deps() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_p021_e2e_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let child_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>services</artifactId>
<version>1.0</version>
</parent>
<artifactId>ec2</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>protocol-core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "ec2", "1.0", child_pom);
let parent_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>services</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.lib</groupId>
<artifactId>sdk-core</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "services", "1.0", parent_pom);
let coord = MavenCoord {
group_id: "com.example".into(),
artifact_id: "ec2".into(),
version: "1.0".into(),
classifier: None,
exclusions: Vec::new(),
scope: None,
};
let client = reqwest::blocking::Client::new();
let deps = resolve_transitive_cached(&client, &coord, &cache_dir, &[], None).unwrap();
let gas: Vec<String> = deps.iter()
.map(|d| format!("{}:{}", d.group_id, d.artifact_id))
.collect();
assert!(gas.contains(&"com.lib:protocol-core".to_string()),
"child's own dep must be present; got {:?}", gas);
assert!(gas.contains(&"com.lib:auth".to_string()),
"parent's auth must be merged into child deps; got {:?}", gas);
assert!(gas.contains(&"com.lib:sdk-core".to_string()),
"parent's sdk-core must be merged into child deps; got {:?}", gas);
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn test_resolve_transitive_cached_child_overrides_parent_same_ga() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_p021_override_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let child_pom = r#"<?xml version="1.0"?>
<project>
<parent>
<groupId>com.example</groupId>
<artifactId>p</artifactId>
<version>1.0</version>
</parent>
<artifactId>c</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>3.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "c", "1.0", child_pom);
let parent_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>p</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>auth</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "p", "1.0", parent_pom);
let coord = MavenCoord {
group_id: "com.example".into(), artifact_id: "c".into(),
version: "1.0".into(), classifier: None,
exclusions: Vec::new(), scope: None,
};
let client = reqwest::blocking::Client::new();
let deps = resolve_transitive_cached(&client, &coord, &cache_dir, &[], None).unwrap();
let auth = deps.iter().find(|d| d.artifact_id == "auth")
.expect("auth must appear");
assert_eq!(auth.version, "3.0",
"child's auth (3.0) must win over parent's auth (1.0); deduplicated on G:A");
assert_eq!(deps.iter().filter(|d| d.artifact_id == "auth").count(), 1,
"auth must appear exactly once after dedup");
let _ = std::fs::remove_dir_all(&cache_dir);
}
#[test]
fn test_collect_parent_dependencies_no_parent_returns_empty() {
let pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>root</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>x</artifactId>
<version>1.0</version>
</dependency>
</dependencies>
</project>"#;
let props = HashMap::new();
let client = reqwest::blocking::Client::new();
let mut visited = HashSet::new();
let deps = collect_parent_dependencies(
&client, pom, Path::new("/tmp"), &[], &props, 0, &mut visited,
).unwrap();
assert!(deps.is_empty(), "no parent → no inherited deps");
}
#[test]
fn test_verify_pom_body_rejects_empty() {
let err = verify_pom_body(b"").unwrap_err();
assert!(err.contains("empty"), "got: {}", err);
}
#[test]
fn test_verify_pom_body_rejects_non_xml() {
assert!(verify_pom_body(b"not xml at all").is_err());
}
#[test]
fn test_verify_pom_body_rejects_wrong_root_element() {
let body = br#"<?xml version="1.0"?><html><body>oops</body></html>"#;
let err = verify_pom_body(body).unwrap_err();
assert!(err.contains("unexpected root"), "got: {}", err);
}
#[test]
fn test_verify_pom_body_accepts_valid_project_root() {
let body = br#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>lib</artifactId>
<version>1.0</version>
</project>"#;
verify_pom_body(body).expect("valid POM body must pass");
}
#[test]
fn test_resolve_inner_explicit_dep_unchanged_by_constraint() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_adr022_explicit_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let explicit_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>explicit-dep</artifactId>
<version>1.0</version>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "explicit-dep", "1.0", explicit_pom);
let mut deps = BTreeMap::new();
deps.insert("com.example:explicit-dep".to_string(), "1.0".to_string());
let mut constraints = BTreeMap::new();
constraints.insert("com.example:explicit-dep".to_string(), "5.0".to_string());
let mut lock = Lockfile::default();
let result = resolve_inner(
&deps,
&cache_dir,
&mut lock,
&[],
&[],
&BTreeMap::new(),
&constraints,
&HashMap::new(),
false,
);
let _ = std::fs::remove_dir_all(&cache_dir);
result.expect("resolve_inner should succeed");
let keys: Vec<String> = lock.dependencies.keys().cloned().collect();
assert!(
keys.contains(&"com.example:explicit-dep:1.0".to_string()),
"explicit pin must keep version 1.0; got {:?}", keys
);
assert!(
!keys.contains(&"com.example:explicit-dep:5.0".to_string()),
"BOM constraint 5.0 must NOT override explicit 1.0; got {:?}", keys
);
}
#[test]
fn test_resolve_inner_transitive_dep_upgraded_by_constraint() {
let cache_dir = std::env::temp_dir()
.join(format!("ym_adr022_trans_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&cache_dir);
std::fs::create_dir_all(&cache_dir).unwrap();
let host_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.example</groupId>
<artifactId>host</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>com.lib</groupId>
<artifactId>nested</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>"#;
write_pom_to_cache(&cache_dir, "com.example", "host", "1.0", host_pom);
let nested_5_pom = r#"<?xml version="1.0"?>
<project>
<groupId>com.lib</groupId>
<artifactId>nested</artifactId>
<version>5.0</version>
</project>"#;
write_pom_to_cache(&cache_dir, "com.lib", "nested", "5.0", nested_5_pom);
let mut deps = BTreeMap::new();
deps.insert("com.example:host".to_string(), "1.0".to_string());
let mut constraints = BTreeMap::new();
constraints.insert("com.lib:nested".to_string(), "5.0".to_string());
let mut lock = Lockfile::default();
let result = resolve_inner(
&deps,
&cache_dir,
&mut lock,
&[],
&[],
&BTreeMap::new(),
&constraints,
&HashMap::new(),
false,
);
let _ = std::fs::remove_dir_all(&cache_dir);
result.expect("resolve_inner should succeed");
let keys: Vec<String> = lock.dependencies.keys().cloned().collect();
assert!(
keys.contains(&"com.lib:nested:5.0".to_string()),
"BOM constraint must upgrade non-explicit transitive; got {:?}", keys
);
assert!(
!keys.contains(&"com.lib:nested:2.0".to_string()),
"transitive version 2.0 must not win after BOM upgrade; got {:?}", keys
);
}
}