use std::collections::HashMap;
use std::fmt;
use std::str::FromStr;
use semver::Version;
use thiserror::Error;
use url::Url;
use crate::lockfile::GitCommit;
use crate::resolver::git::CredentialMode;
use crate::resolver::git::GitError;
use crate::resolver::git::list_advertised_refs;
use crate::version_requirement::VersionRequirement;
#[derive(Debug, Error)]
pub enum VersionError {
#[error(
"no version satisfies requirement `{requirement}` (considered: {})",
format_versions(.considered)
)]
NoSatisfyingVersion {
requirement: VersionRequirement,
considered: Vec<Version>,
},
}
fn format_versions(versions: &[Version]) -> String {
if versions.is_empty() {
return "<none>".to_string();
}
versions
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VersionTag {
prefix: Option<String>,
version: Version,
}
impl VersionTag {
pub fn new(prefix: Option<String>, version: Version) -> Self {
Self { prefix, version }
}
pub fn prefix(&self) -> Option<&str> {
self.prefix.as_deref()
}
pub fn into_version(self) -> Version {
self.version
}
}
impl fmt::Display for VersionTag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.prefix {
Some(p) => write!(f, "{p}/v{}", self.version),
None => write!(f, "v{}", self.version),
}
}
}
impl FromStr for VersionTag {
type Err = VersionTagError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((prefix, rest)) = s.rsplit_once("/v")
&& let Ok(version) = Version::parse(rest)
{
return Ok(Self {
prefix: Some(prefix.to_string()),
version,
});
}
let stripped = s
.strip_prefix('v')
.ok_or_else(|| VersionTagError(s.to_string()))?;
let version = Version::parse(stripped).map_err(|_| VersionTagError(s.to_string()))?;
Ok(Self {
prefix: None,
version,
})
}
}
#[derive(Debug, Error)]
#[error("`{0}` is not a valid version tag (expected `v<semver>` or `<prefix>/v<semver>`)")]
pub struct VersionTagError(String);
const REF_TAG_PREFIX: &str = "refs/tags/";
const REF_HEAD_PREFIX: &str = "refs/heads/";
const PEELED_TAG_SUFFIX: &str = "^{}";
pub type RemoteRefs = HashMap<String, GitCommit>;
pub fn discover_remote_tags(
url: &Url,
max_refs: usize,
mode: CredentialMode,
) -> Result<RemoteRefs, GitError> {
let advertised = list_advertised_refs(url, max_refs, mode)?;
let mut refs = HashMap::new();
for (name, oid) in advertised {
let Some(stripped) = name.strip_prefix(REF_TAG_PREFIX) else {
continue;
};
let (base, peeled) = match stripped.strip_suffix(PEELED_TAG_SUFFIX) {
Some(b) => (b, true),
None => (stripped, false),
};
let Ok(commit) = GitCommit::try_from(oid) else {
continue;
};
if peeled || !refs.contains_key(base) {
refs.insert(base.to_string(), commit);
}
}
Ok(refs)
}
pub fn discover_remote_branches(
url: &Url,
max_refs: usize,
mode: CredentialMode,
) -> Result<RemoteRefs, GitError> {
let advertised = list_advertised_refs(url, max_refs, mode)?;
let mut refs = HashMap::new();
for (name, oid) in advertised {
let Some(stripped) = name.strip_prefix(REF_HEAD_PREFIX) else {
continue;
};
let Ok(commit) = GitCommit::try_from(oid) else {
continue;
};
refs.insert(stripped.to_string(), commit);
}
Ok(refs)
}
fn parsed_versions<'a>(
refs: &'a RemoteRefs,
path_prefix: Option<&'a str>,
) -> impl Iterator<Item = Version> + 'a {
refs.keys()
.filter_map(|tag| tag.parse::<VersionTag>().ok())
.filter(move |t| t.prefix() == path_prefix)
.map(VersionTag::into_version)
}
pub fn parse_versions(refs: &RemoteRefs, path_prefix: Option<&str>) -> Vec<Version> {
let mut versions: Vec<Version> = parsed_versions(refs, path_prefix).collect();
versions.sort_by(|a, b| b.cmp(a));
versions
}
pub fn filter_matching(
refs: &RemoteRefs,
path_prefix: Option<&str>,
requirement: &VersionRequirement,
) -> Vec<Version> {
let mut versions: Vec<Version> = parsed_versions(refs, path_prefix)
.filter(|v| requirement.matches(v))
.collect();
versions.sort_by(|a, b| b.cmp(a));
versions
}
pub fn select_version(
refs: &RemoteRefs,
path_prefix: Option<&str>,
requirement: &VersionRequirement,
) -> Result<Version, VersionError> {
let parsed = parse_versions(refs, path_prefix);
parsed
.iter()
.find(|v| requirement.matches(v))
.cloned()
.ok_or(VersionError::NoSatisfyingVersion {
requirement: requirement.clone(),
considered: parsed,
})
}
pub fn resolve_version_to_commit(
refs: &RemoteRefs,
path_prefix: Option<&str>,
requirement: &VersionRequirement,
) -> Result<(Version, GitCommit), VersionError> {
let version = select_version(refs, path_prefix, requirement)?;
let tag = VersionTag::new(path_prefix.map(String::from), version.clone()).to_string();
let commit = refs.get(&tag).cloned().unwrap();
Ok((version, commit))
}
#[cfg(test)]
mod tests {
use super::*;
fn req(s: &str) -> VersionRequirement {
s.parse().unwrap()
}
fn refs(items: &[&str]) -> RemoteRefs {
let sentinel = GitCommit::try_from(
"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
.to_string()
.to_string(),
)
.unwrap();
items
.iter()
.map(|s| (s.to_string(), sentinel.clone()))
.collect()
}
#[test]
fn version_tag_round_trips() {
let parsed: VersionTag = "csvkit/v1.2.3".parse().unwrap();
assert_eq!(parsed.prefix(), Some("csvkit"));
assert_eq!(parsed.to_string(), "csvkit/v1.2.3");
assert_eq!(parsed.into_version(), Version::parse("1.2.3").unwrap());
let root: VersionTag = "v0.5.0".parse().unwrap();
assert_eq!(root.prefix(), None);
assert_eq!(root.to_string(), "v0.5.0");
assert!("release-2026".parse::<VersionTag>().is_err());
assert!("vXYZ".parse::<VersionTag>().is_err());
}
#[test]
fn version_tag_new_displays_correctly() {
let v = Version::parse("1.2.3").unwrap();
assert_eq!(VersionTag::new(None, v.clone()).to_string(), "v1.2.3");
assert_eq!(
VersionTag::new(Some("csvkit".to_string()), v).to_string(),
"csvkit/v1.2.3"
);
}
#[test]
fn selects_highest_root_version() {
let v = select_version(
&refs(&["v1.0.0", "v1.2.0", "v2.0.0", "csvkit/v0.5.0"]),
None,
&req("^1"),
)
.unwrap();
assert_eq!(v, Version::parse("1.2.0").unwrap());
}
#[test]
fn selects_path_prefixed_version() {
let v = select_version(
&refs(&["csvkit/v0.5.0", "csvkit/v0.6.0", "spellbook/v1.0.0"]),
Some("csvkit"),
&req(">=0.5"),
)
.unwrap();
assert_eq!(v, Version::parse("0.6.0").unwrap());
}
#[test]
fn ignores_non_semver_tags() {
let v =
select_version(&refs(&["v1.0.0", "release-2026", "vXYZ"]), None, &req("^1")).unwrap();
assert_eq!(v, Version::parse("1.0.0").unwrap());
}
#[test]
fn errors_when_no_satisfying_version() {
let err = select_version(&refs(&["v1.0.0"]), None, &req("^2")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no version satisfies"), "got: {msg}");
assert!(
msg.contains("1.0.0"),
"msg should list considered versions: {msg}"
);
}
#[test]
fn root_selector_ignores_path_prefixed_tags() {
let err = select_version(
&refs(&["csvkit/v1.0.0", "spellbook/v1.0.0"]),
None,
&req("^1"),
)
.unwrap_err();
assert!(matches!(err, VersionError::NoSatisfyingVersion { .. }));
}
#[test]
fn path_selector_ignores_root_tags() {
let err =
select_version(&refs(&["v1.0.0", "v2.0.0"]), Some("csvkit"), &req("^1")).unwrap_err();
assert!(matches!(err, VersionError::NoSatisfyingVersion { .. }));
}
#[test]
fn resolve_version_to_commit_round_trips_the_tag() {
let mut refs = refs(&["v1.0.0", "v1.2.0", "v2.0.0"]);
let target = GitCommit::try_from(
"b1c2d3e4f5a6b1c2d3e4f5a6b1c2d3e4f5a6b1c2"
.to_string()
.to_string(),
)
.unwrap();
refs.insert("v1.2.0".to_string(), target.clone());
let (version, commit) = resolve_version_to_commit(&refs, None, &req("^1")).unwrap();
assert_eq!(version, Version::parse("1.2.0").unwrap());
assert_eq!(commit, target);
}
}