use std::collections::HashSet;
use std::fmt;
use semver::{Version, VersionReq};
#[derive(Debug, Clone)]
pub enum Constraint {
Semver(VersionReq),
Latest,
RefPin(String),
}
impl fmt::Display for Constraint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Constraint::Semver(req) => write!(f, "{req}"),
Constraint::Latest => write!(f, "latest"),
Constraint::RefPin(reference) => write!(f, "ref:{reference}"),
}
}
}
pub fn parse_constraint(version: Option<&str>) -> Constraint {
let version = match version {
None => return Constraint::Latest,
Some(v) => v.trim(),
};
if version.is_empty() || version.eq_ignore_ascii_case("latest") {
return Constraint::Latest;
}
if let Some(stripped) = version.strip_prefix('v') {
if let Ok(ver) = Version::parse(stripped) {
let req = VersionReq::parse(&format!("={ver}")).expect("valid exact req");
return Constraint::Semver(req);
}
if let Ok(major) = stripped.parse::<u64>()
&& let Some(next) = major.checked_add(1)
{
let req = VersionReq::parse(&format!(">={major}.0.0, <{next}.0.0"))
.expect("valid major range req");
return Constraint::Semver(req);
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.len() == 2
&& let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>())
&& let Some(next) = minor.checked_add(1)
{
let req = VersionReq::parse(&format!(">={major}.{minor}.0, <{major}.{next}.0"))
.expect("valid minor range req");
return Constraint::Semver(req);
}
}
if let Ok(req) = VersionReq::parse(version) {
return Constraint::Semver(req);
}
Constraint::RefPin(version.to_string())
}
pub fn parse_tag(tag: &str) -> Option<Version> {
Version::parse(tag.strip_prefix('v').unwrap_or(tag)).ok()
}
pub fn satisfies(constraint: &Constraint, version: &Version) -> bool {
match constraint {
Constraint::Semver(req) => req.matches(version),
Constraint::Latest => true,
Constraint::RefPin(_) => false,
}
}
pub fn satisfies_requirement(requirement: &str, version: &Version) -> Result<bool, ParseError> {
let requirement = crate::package::parse_requirement(requirement)
.map_err(|error| ParseError::Requirement(requirement.to_owned(), error.to_string()))?;
Ok(requirement.matches(version))
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ParseError {
#[error("`{0}` is not a semver version: {1}")]
Version(String, String),
#[error("`{0}` is not a version requirement: {1}")]
Requirement(String, String),
#[error("`{0}` is not a version tag")]
Tag(String),
}
impl ParseError {
pub fn code(&self) -> &'static str {
match self {
Self::Version(..) => "version.invalid-version",
Self::Requirement(..) => "version.invalid-requirement",
Self::Tag(_) => "version.invalid-tag",
}
}
}
pub fn parse_version(text: &str) -> Result<Version, ParseError> {
Version::parse(text).map_err(|error| ParseError::Version(text.to_owned(), error.to_string()))
}
pub fn require_tag(tag: &str) -> Result<Version, ParseError> {
parse_tag(tag).ok_or_else(|| ParseError::Tag(tag.to_owned()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionPolicy {
PreferLockThenLatest,
LatestOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SelectError {
#[error("{}", no_satisfying_message(.constraints, .available))]
NoSatisfying {
constraints: Vec<(String, VersionReq)>,
available: Vec<Version>,
},
}
impl SelectError {
pub fn code(&self) -> &'static str {
match self {
Self::NoSatisfying { .. } => "version.no-satisfying",
}
}
}
fn no_satisfying_message(constraints: &[(String, VersionReq)], available: &[Version]) -> String {
let wanted: Vec<String> = constraints
.iter()
.map(|(requester, req)| format!(" `{requester}` requires {req}"))
.collect();
let have: Vec<String> = available.iter().map(Version::to_string).collect();
format!(
"no version satisfies all constraints:\n{}\navailable versions: [{}]",
wanted.join("\n"),
have.join(", ")
)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SettleError {
#[error("`{first}` requires ref:{first_ref}, but `{second}` requires ref:{second_ref}")]
PinsDiffer {
first: String,
first_ref: String,
second: String,
second_ref: String,
},
#[error("`{pinned_by}` requires ref:{reference}, but `{ranged_by}` requires {range}")]
PinBesideRange {
pinned_by: String,
reference: String,
ranged_by: String,
range: VersionReq,
},
}
impl SettleError {
pub fn code(&self) -> &'static str {
match self {
Self::PinsDiffer { .. } => "version.pins-differ",
Self::PinBesideRange { .. } => "version.pin-beside-range",
}
}
}
pub fn settle<'a>(constraints: &[(&str, &'a Constraint)]) -> Result<Option<&'a str>, SettleError> {
let mut pin: Option<(&str, &'a str)> = None;
for &(requester, constraint) in constraints {
if let Constraint::RefPin(reference) = constraint {
match pin {
Some((first, first_ref)) if first_ref != reference => {
return Err(SettleError::PinsDiffer {
first: first.to_string(),
first_ref: first_ref.to_string(),
second: requester.to_string(),
second_ref: reference.clone(),
});
}
Some(_) => {}
None => pin = Some((requester, reference)),
}
}
}
let Some((pinned_by, reference)) = pin else {
return Ok(None);
};
for &(requester, constraint) in constraints {
if let Constraint::Semver(range) = constraint {
return Err(SettleError::PinBesideRange {
pinned_by: pinned_by.to_string(),
reference: reference.to_string(),
ranged_by: requester.to_string(),
range: range.clone(),
});
}
}
Ok(Some(reference))
}
pub fn allows(
constraints: &[(&str, &Constraint)],
version: &Version,
excluded: &HashSet<Version>,
) -> bool {
!excluded.contains(version)
&& constraints.iter().all(|(_, constraint)| match constraint {
Constraint::Semver(req) => req.matches(version),
_ => true,
})
}
pub fn keeps_lock(
constraints: &[(&str, &Constraint)],
locked: &Version,
policy: SelectionPolicy,
excluded: &HashSet<Version>,
) -> bool {
policy == SelectionPolicy::PreferLockThenLatest
&& !constraints
.iter()
.any(|(_, constraint)| matches!(constraint, Constraint::Latest))
&& allows(constraints, locked, excluded)
}
pub fn select(
available: &[Version],
constraints: &[(&str, &Constraint)],
locked: Option<&Version>,
policy: SelectionPolicy,
excluded: &HashSet<Version>,
) -> Result<Option<usize>, SelectError> {
if available.is_empty() {
return Ok(None);
}
let allowed: Vec<usize> = (0..available.len())
.filter(|&i| allows(constraints, &available[i], excluded))
.collect();
let Some(newest) = allowed.iter().copied().max_by_key(|&i| (&available[i], i)) else {
let mut ascending = available.to_vec();
ascending.sort();
return Err(SelectError::NoSatisfying {
constraints: constraints
.iter()
.filter_map(|&(requester, constraint)| match constraint {
Constraint::Semver(req) => Some((requester.to_string(), req.clone())),
_ => None,
})
.collect(),
available: ascending,
});
};
let kept = locked
.filter(|locked| keeps_lock(constraints, locked, policy, excluded))
.and_then(|locked| allowed.iter().copied().find(|&i| available[i] == *locked));
Ok(Some(kept.unwrap_or(newest)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Chosen<'a> {
Pin(&'a str),
Tag(&'a str),
Locked,
Untagged,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ChooseError {
#[error(transparent)]
Settle(#[from] SettleError),
#[error(transparent)]
Select(#[from] SelectError),
}
impl ChooseError {
pub fn code(&self) -> &'static str {
match self {
Self::Settle(error) => error.code(),
Self::Select(error) => error.code(),
}
}
}
pub fn choose<'a>(
tags: &'a [String],
constraints: &[(&str, &'a Constraint)],
locked: Option<&Version>,
policy: SelectionPolicy,
excluded: &HashSet<Version>,
) -> Result<Chosen<'a>, ChooseError> {
if let Some(reference) = settle(constraints)? {
return Ok(Chosen::Pin(reference));
}
let (names, versions): (Vec<&str>, Vec<Version>) = tags
.iter()
.filter_map(|tag| Some((tag.as_str(), parse_tag(tag)?)))
.unzip();
if locked.is_some_and(|locked| keeps_lock(constraints, locked, policy, excluded)) {
return Ok(Chosen::Locked);
}
Ok(select(&versions, constraints, None, policy, excluded)?
.map_or(Chosen::Untagged, |index| Chosen::Tag(names[index])))
}