use crate::build::{central_repos, extra_repos};
use crate::{descriptor, workspace};
use anyhow::{bail, Context, Result};
use curie_deps::repo::Repository;
use curie_deps::version::{intersect_highest_satisfying, VersionRange};
use curie_deps::{
fetch_available_versions, resolve_with_pins, DepEntry, Gav, ResolveOptions, VersionRangeError,
};
use std::collections::BTreeMap;
use std::path::Path;
pub fn run_fetch_workspace_member(
workspace_root: &Path,
member_index: usize,
coords: &[String],
no_transitive: bool,
offline: bool,
) -> Result<()> {
let ws = workspace::load(workspace_root)?;
let member = &ws.members[member_index];
run_fetch_with_desc(&member.descriptor, coords, no_transitive, offline)
}
pub fn run_fetch(project_root: &Path, coords: &[String], no_transitive: bool, offline: bool) -> Result<()> {
let desc = descriptor::load(project_root)?;
if desc.is_workspace() {
bail!("`curie fetch` cannot run on a workspace root; target a member with --project");
}
run_fetch_with_desc(&desc, coords, no_transitive, offline)
}
fn run_fetch_with_desc(
desc: &descriptor::Descriptor,
coords: &[String],
no_transitive: bool,
offline: bool,
) -> Result<()> {
if coords.is_empty() {
if no_transitive {
bail!("--no-transitive requires at least one coordinate argument");
}
return fetch_project_dependencies(desc, offline);
}
fetch_coordinates(desc, coords, no_transitive, offline)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ArtifactType {
Jar,
Pom,
}
fn parse_artifact_coord(arg: &str) -> Result<(Gav, ArtifactType)> {
let parts: Vec<&str> = arg.split(':').collect();
match parts.len() {
3 => {
let gav = Gav::from_key_version(&format!("{}:{}", parts[0], parts[1]), parts[2])?;
Ok((gav, ArtifactType::Jar))
}
4 => {
let artifact_type = match parts[3] {
"jar" => ArtifactType::Jar,
"pom" => ArtifactType::Pom,
other => bail!(
"unsupported artifact type {:?} in {:?}; expected \"jar\" or \"pom\"",
other,
arg
),
};
let gav = Gav::from_key_version(&format!("{}:{}", parts[0], parts[1]), parts[2])?;
Ok((gav, artifact_type))
}
5 => {
let artifact_type = match parts[3] {
"jar" => ArtifactType::Jar,
other => bail!(
"unsupported artifact type {:?} in {:?}; classifiers are only supported for \"jar\"",
other,
arg
),
};
let mut gav = Gav::from_key_version(&format!("{}:{}", parts[0], parts[1]), parts[2])?;
gav.classifier = Some(parts[4].to_string());
Ok((gav, artifact_type))
}
_ => bail!(
"invalid coordinate {:?}: expected \"group:artifact:version\", \
\"group:artifact:version:type\", or \"group:artifact:version:type:classifier\"",
arg
),
}
}
#[cfg(test)]
fn parse_gav_arg(arg: &str) -> Result<Gav> {
let (gav, _) = parse_artifact_coord(arg)?;
Ok(gav)
}
fn fetch_coordinates(
desc: &descriptor::Descriptor,
coords: &[String],
no_transitive: bool,
offline: bool,
) -> Result<()> {
let parsed: Vec<(Gav, ArtifactType)> = coords
.iter()
.map(|c| parse_artifact_coord(c))
.collect::<Result<_>>()?;
let mut repos = central_repos();
repos.extend(extra_repos(desc));
let pom_count = fetch_pom_coords(
parsed.iter().filter(|(_, t)| *t == ArtifactType::Pom).map(|(g, _)| g),
&repos,
offline,
)?;
let jar_gavs: Vec<&Gav> =
parsed.iter().filter(|(_, t)| *t == ArtifactType::Jar).map(|(g, _)| g).collect();
let hint = RangeHintMode::Command { original: coords };
let jar_count = if jar_gavs.is_empty() {
0
} else if no_transitive {
fetch_jars_flat(&jar_gavs, &repos, offline, &hint)?
} else {
fetch_jars_transitive(&jar_gavs, &repos, offline, &hint)?
};
let total = pom_count + jar_count;
crate::parallel::emit(&crate::style::done(&format!("{} artifact(s) cached", total)));
Ok(())
}
enum RangeHintMode<'a> {
Command { original: &'a [String] },
File { path: &'a Path },
}
fn fetch_jars_transitive(
gavs: &[&Gav],
repos: &[Repository],
offline: bool,
hint: &RangeHintMode,
) -> Result<usize> {
let pins: Vec<String> = gavs.iter().map(|g| format!("{}:{}", g.group, g.artifact)).collect();
let mut total = 0;
let mut range_pairs: Vec<(String, String)> = Vec::new();
for gav in gavs {
let key = format!("{}:{}", gav.group, gav.artifact);
let classifier = gav.classifier.as_deref();
let entry = DepEntry { key: &key, version: &gav.version, repo_id: None, exclusions: vec![], classifier, allow_version_conflict: false };
let opts = ResolveOptions {
default_repos: repos.to_vec(),
named_repos: vec![],
progress: true,
bom_imports: vec![],
offline,
skip_version_ranges: false, error_on_version_conflict: false,
};
match resolve_with_pins(&[entry], &opts, &pins) {
Ok(jars) => total += jars.len(),
Err(e) => match e.downcast::<VersionRangeError>() {
Ok(range_err) => range_pairs
.extend(range_err.violations.into_iter().map(|v| (v.dep_key, v.range))),
Err(other) => return Err(other),
},
}
}
if !range_pairs.is_empty() {
bail!("{}", build_range_message(&range_pairs, repos, offline, hint));
}
Ok(total)
}
fn fetch_jars_flat(
gavs: &[&Gav],
repos: &[Repository],
offline: bool,
_hint: &RangeHintMode,
) -> Result<usize> {
let mut count = 0;
for gav in gavs {
curie_deps::fetch_artifact(gav, repos, offline)?;
count += 1;
}
Ok(count)
}
struct RangeSuggestion {
dep_key: String,
ranges: Vec<String>,
exact: Option<String>,
}
fn suggested_coord(s: &RangeSuggestion) -> String {
match &s.exact {
Some(v) => format!("{}:{}", s.dep_key, v),
None => format!("{}:<version>", s.dep_key),
}
}
fn range_suggestions(
pairs: &[(String, String)],
repos: &[Repository],
offline: bool,
) -> Vec<RangeSuggestion> {
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (dep_key, range) in pairs {
let ranges = grouped.entry(dep_key.clone()).or_default();
if !ranges.contains(range) {
ranges.push(range.clone());
}
}
grouped
.into_iter()
.map(|(dep_key, ranges)| {
let exact = compute_exact_version(&dep_key, &ranges, repos, offline);
RangeSuggestion { dep_key, ranges, exact }
})
.collect()
}
fn compute_exact_version(
dep_key: &str,
ranges: &[String],
repos: &[Repository],
offline: bool,
) -> Option<String> {
let parsed: Vec<VersionRange> = ranges.iter().filter_map(|r| VersionRange::parse(r).ok()).collect();
if parsed.len() != ranges.len() {
return None;
}
let available = fetch_available_versions(dep_key, repos, offline).ok()?;
intersect_highest_satisfying(&parsed, &available)
}
fn build_range_message(
pairs: &[(String, String)],
repos: &[Repository],
offline: bool,
hint: &RangeHintMode,
) -> String {
let suggestions = range_suggestions(pairs, repos, offline);
match hint {
RangeHintMode::Command { original } => format_command_hint(&suggestions, original),
RangeHintMode::File { path } => format_file_hint(&suggestions, path),
}
}
fn range_diagnosis(suggestions: &[RangeSuggestion]) -> String {
let mut msg = String::from("non-deterministic version ranges in dependency graph");
for s in suggestions {
let target = match &s.exact {
Some(v) => v.clone(),
None => "(no published version satisfies the range — pick one manually)".to_string(),
};
msg.push_str(&format!("\n\n {}\n {} \u{2192} {}", s.dep_key, s.ranges.join(", "), target));
}
msg
}
fn format_command_hint(suggestions: &[RangeSuggestion], original: &[String]) -> String {
let mut coords: Vec<String> = original.to_vec();
for s in suggestions {
coords.push(suggested_coord(s));
}
let mut msg = range_diagnosis(suggestions);
msg.push_str("\n\nRe-run with an explicit version for each ranged artifact:\n curie fetch ");
msg.push_str(&coords.join(" "));
msg
}
fn format_file_hint(suggestions: &[RangeSuggestion], path: &Path) -> String {
let mut msg = range_diagnosis(suggestions);
msg.push_str(&format!(
"\n\nAdd an explicit version for each ranged artifact to {}:",
path.display()
));
for s in suggestions {
msg.push_str(&format!("\n {}", suggested_coord(s)));
}
msg
}
fn fetch_project_dependencies(desc: &descriptor::Descriptor, offline: bool) -> Result<()> {
let prod = fetch_dep_section(desc, false, offline)?;
let test = fetch_dep_section(desc, true, offline)?;
let total = prod + test;
if total == 0 {
crate::parallel::emit(&crate::style::neutral("Fetch", "no dependencies declared"));
} else {
crate::parallel::emit(&crate::style::done(&format!("{total} JAR(s) cached")));
}
Ok(())
}
fn fetch_dep_section(desc: &descriptor::Descriptor, tests: bool, offline: bool) -> Result<usize> {
let dep_map = if tests { &desc.test_dependencies } else { &desc.dependencies };
let mut entries: Vec<DepEntry> = dep_map
.iter()
.map(|(k, v)| DepEntry { key: k, version: v.version(), repo_id: v.repository(), exclusions: v.exclusions(), classifier: None, allow_version_conflict: v.allow_version_conflict() })
.collect();
let mut ap_pairs = desc.ap_pairs();
if tests {
ap_pairs.extend(desc.test_ap_pairs());
}
entries.extend(
ap_pairs
.into_iter()
.map(|(k, v)| DepEntry { key: k, version: v, repo_id: None, exclusions: vec![], classifier: None, allow_version_conflict: false }),
);
if entries.is_empty() {
return Ok(0);
}
let bom_gavs = if tests { desc.test_bom_gavs()? } else { desc.prod_bom_gavs()? };
let opts = ResolveOptions {
default_repos: central_repos(),
named_repos: extra_repos(desc),
progress: true,
bom_imports: bom_gavs,
offline,
skip_version_ranges: false, error_on_version_conflict: false,
};
let jars = curie_deps::resolve(&entries, &opts)?;
let label = if tests { "Test deps" } else { "Dependencies" };
crate::parallel::emit(&crate::style::resolve(label, &format!("{} JAR(s)", jars.len())));
Ok(jars.len())
}
fn read_coordinate_file(path: &Path) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("cannot read coordinate file {}", path.display()))?;
let lines = content
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_owned)
.collect();
Ok(lines)
}
fn parse_coordinate_lines(lines: &[String]) -> Result<Vec<(Gav, ArtifactType)>> {
lines
.iter()
.map(|line| {
parse_artifact_coord(line).with_context(|| format!("invalid coordinate {:?}", line))
})
.collect()
}
fn repos_for_file_mode(project_root: &Path) -> Vec<Repository> {
let mut repos = central_repos();
if let Ok(desc) = descriptor::load(project_root) {
if !desc.is_workspace() {
repos.extend(extra_repos(&desc));
}
}
repos
}
pub fn run_fetch_file(project_root: &Path, path: &Path, no_transitive: bool, offline: bool) -> Result<()> {
let lines = read_coordinate_file(path)?;
let coords = parse_coordinate_lines(&lines)?;
if coords.is_empty() {
crate::parallel::emit(&crate::style::neutral("Fetch", "no coordinates in file"));
return Ok(());
}
let repos = repos_for_file_mode(project_root);
let pom_count = fetch_pom_coords(coords.iter().filter(|(_, t)| *t == ArtifactType::Pom).map(|(g, _)| g), &repos, offline)?;
let jar_gavs: Vec<&Gav> = coords.iter().filter(|(_, t)| *t == ArtifactType::Jar).map(|(g, _)| g).collect();
let hint = RangeHintMode::File { path };
let jar_count = if jar_gavs.is_empty() {
0
} else if no_transitive {
fetch_jars_flat(&jar_gavs, &repos, offline, &hint)?
} else {
fetch_jars_transitive(&jar_gavs, &repos, offline, &hint)?
};
let total = pom_count + jar_count;
crate::parallel::emit(&crate::style::done(&format!("{} artifact(s) cached", total)));
Ok(())
}
fn fetch_pom_coords<'a>(
gavs: impl Iterator<Item = &'a Gav>,
repos: &[curie_deps::repo::Repository],
offline: bool,
) -> Result<usize> {
let mut count = 0;
for gav in gavs {
curie_deps::fetch_pom_only(gav, repos, offline)?;
count += 1;
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn empty_app_descriptor() -> descriptor::Descriptor {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"demo\"\nversion = \"0.1.0\"\n",
)
.unwrap();
descriptor::load(dir.path()).unwrap()
}
#[test]
fn parse_gav_arg_accepts_three_parts() {
let gav = parse_gav_arg("com.google.guava:guava:33.2.0-jre").unwrap();
assert_eq!(gav.group, "com.google.guava");
assert_eq!(gav.artifact, "guava");
assert_eq!(gav.version, "33.2.0-jre");
}
#[test]
fn parse_gav_arg_rejects_two_parts() {
assert!(parse_gav_arg("com.google.guava:guava").is_err());
}
#[test]
fn parse_gav_arg_rejects_four_parts() {
assert!(parse_gav_arg("a:b:c:d").is_err());
}
#[test]
fn parse_gav_arg_rejects_empty_group() {
assert!(parse_gav_arg(":artifact:1.0").is_err());
}
#[test]
fn no_transitive_without_coord_is_an_error() {
let desc = empty_app_descriptor();
let err = run_fetch_with_desc(&desc, &[], true, true).unwrap_err();
assert!(err.to_string().contains("--no-transitive"));
}
#[test]
fn empty_project_fetch_succeeds_without_network() {
let desc = empty_app_descriptor();
run_fetch_with_desc(&desc, &[], false, true).unwrap();
}
fn write_coord_file(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("deps.txt");
fs::write(&path, contents).unwrap();
(dir, path)
}
#[test]
fn read_coordinate_file_skips_blank_lines_and_comments() {
let (_dir, path) = write_coord_file(
"# comment\n\ncom.example:foo:1.0\n \n# another\ncom.example:bar:2.0\n",
);
let lines = read_coordinate_file(&path).unwrap();
assert_eq!(lines, vec!["com.example:foo:1.0", "com.example:bar:2.0"]);
}
#[test]
fn read_coordinate_file_skips_indented_comment() {
let (_dir, path) = write_coord_file(" # indented comment\ncom.example:foo:1.0\n");
let lines = read_coordinate_file(&path).unwrap();
assert_eq!(lines, vec!["com.example:foo:1.0"]);
}
#[test]
fn read_coordinate_file_missing_path_errors_with_path() {
let err = read_coordinate_file(std::path::Path::new("/no/such/file.txt")).unwrap_err();
assert!(err.to_string().contains("/no/such/file.txt"));
}
#[test]
fn parse_coordinate_lines_accepts_jar_coord() {
let lines = vec!["com.google.guava:guava:33.2.0-jre".to_owned()];
let coords = parse_coordinate_lines(&lines).unwrap();
let (gav, kind) = &coords[0];
assert_eq!(gav.group, "com.google.guava");
assert_eq!(gav.artifact, "guava");
assert_eq!(gav.version, "33.2.0-jre");
assert_eq!(*kind, ArtifactType::Jar);
}
#[test]
fn parse_coordinate_lines_accepts_pom_type() {
let lines = vec!["com.google.guava:guava-bom:33.4.8-jre:pom".to_owned()];
let coords = parse_coordinate_lines(&lines).unwrap();
let (gav, kind) = &coords[0];
assert_eq!(gav.artifact, "guava-bom");
assert_eq!(*kind, ArtifactType::Pom);
}
#[test]
fn parse_coordinate_lines_accepts_explicit_jar_type() {
let lines = vec!["com.example:foo:1.0:jar".to_owned()];
let coords = parse_coordinate_lines(&lines).unwrap();
assert_eq!(coords[0].1, ArtifactType::Jar);
}
#[test]
fn parse_coordinate_lines_rejects_unknown_type() {
let lines = vec!["com.example:foo:1.0:war".to_owned()];
assert!(parse_coordinate_lines(&lines).is_err());
}
#[test]
fn parse_coordinate_lines_rejects_malformed_and_names_it() {
let lines = vec!["not-a-coordinate".to_owned()];
let err = parse_coordinate_lines(&lines).unwrap_err();
assert!(err.to_string().contains("not-a-coordinate"));
}
#[test]
fn run_fetch_file_with_only_comments_succeeds() {
let (_dir, path) = write_coord_file("# nothing here\n\n# also nothing\n");
let project = tempfile::tempdir().unwrap();
run_fetch_file(project.path(), &path, false, true).unwrap();
}
#[test]
fn run_fetch_file_offline_cache_miss_errors() {
let (_dir, path) = write_coord_file("com.example:definitely-not-cached:9.9.9\n");
let project = tempfile::tempdir().unwrap();
let err = run_fetch_file(project.path(), &path, false, true).unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn run_fetch_file_pom_offline_cache_miss_errors() {
let (_dir, path) = write_coord_file("com.example:definitely-not-cached:9.9.9:pom\n");
let project = tempfile::tempdir().unwrap();
let err = run_fetch_file(project.path(), &path, false, true).unwrap_err();
assert!(!err.to_string().is_empty());
}
fn suggestion(dep_key: &str, ranges: &[&str], exact: Option<&str>) -> RangeSuggestion {
RangeSuggestion {
dep_key: dep_key.to_string(),
ranges: ranges.iter().map(|s| s.to_string()).collect(),
exact: exact.map(str::to_string),
}
}
#[test]
fn command_hint_appends_transitive_pin_with_exact_version() {
let suggestions = vec![suggestion(
"com.google.code.gson:gson",
&["[2.9.1,2.11)"],
Some("2.10.1"),
)];
let original = vec!["ch.epfl.scala:bsp4j:2.1.1".to_string()];
let msg = format_command_hint(&suggestions, &original);
assert!(msg.contains("non-deterministic version ranges"), "{msg}");
assert!(
msg.contains(
"curie fetch ch.epfl.scala:bsp4j:2.1.1 com.google.code.gson:gson:2.10.1"
),
"{msg}"
);
}
#[test]
fn command_hint_keeps_all_original_coords_and_appends_pins() {
let suggestions = vec![suggestion("foo:bar", &["[1.0,2.0)"], Some("1.5"))];
let original = vec!["a:b:1.0".to_string(), "c:d:2.0".to_string()];
let msg = format_command_hint(&suggestions, &original);
assert!(
msg.contains("curie fetch a:b:1.0 c:d:2.0 foo:bar:1.5"),
"{msg}"
);
}
#[test]
fn command_hint_falls_back_to_placeholder_when_unresolved() {
let suggestions = vec![suggestion("foo:bar", &["[9.0,)"], None)];
let original = vec!["grp:app:1.0".to_string()];
let msg = format_command_hint(&suggestions, &original);
assert!(msg.contains("foo:bar:<version>"), "{msg}");
}
#[test]
fn file_hint_names_the_file_and_lists_lines_to_add() {
let suggestions = vec![suggestion(
"com.google.code.gson:gson",
&["[2.9.1,2.11)"],
Some("2.10.1"),
)];
let msg = format_file_hint(&suggestions, Path::new("deps.txt"));
assert!(msg.contains("Add an explicit version"), "{msg}");
assert!(msg.contains("deps.txt"), "{msg}");
assert!(msg.contains("com.google.code.gson:gson:2.10.1"), "{msg}");
}
}