#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionCheck {
Match,
PatchDrift,
MinorDrift,
MajorDrift,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VersionPin {
major: u64,
minor: Option<u64>,
patch: Option<u64>,
}
impl VersionPin {
pub fn parse(pin: &str) -> Result<Self, VersionPinParseError> {
let trimmed = pin.trim();
if trimmed.is_empty() {
return Err(VersionPinParseError::EmptyPin);
}
let make_invalid_component = |component: &str| VersionPinParseError::InvalidPinComponent {
pin: trimmed.to_owned(),
component: component.to_owned(),
};
let mut parts = trimmed.split('.');
let Some(major_str) = parts.next() else {
return Err(VersionPinParseError::EmptyPin);
};
let major = major_str.parse::<u64>().map_err(|_| make_invalid_component(major_str))?;
let minor = match parts.next() {
Some(minor_str) => Some(minor_str.parse::<u64>().map_err(|_| make_invalid_component(minor_str))?),
None => None,
};
let patch = match parts.next() {
Some(patch_str) => Some(patch_str.parse::<u64>().map_err(|_| make_invalid_component(patch_str))?),
None => None,
};
if parts.next().is_some() {
return Err(VersionPinParseError::TooManyPinComponents(trimmed.to_owned()));
}
if patch.is_some() && minor.is_none() {
return Err(make_invalid_component(trimmed));
}
Ok(VersionPin { major, minor, patch })
}
pub fn check(&self, installed: &str) -> Result<VersionCheck, VersionPinParseError> {
let (installed_major, installed_minor, installed_patch) = parse_installed_version(installed)?;
if self.major != installed_major {
return Ok(VersionCheck::MajorDrift);
}
let Some(pin_minor) = self.minor else {
return Ok(VersionCheck::Match);
};
if pin_minor != installed_minor {
return Ok(VersionCheck::MinorDrift);
}
let Some(pin_patch) = self.patch else {
return Ok(VersionCheck::Match);
};
if pin_patch != installed_patch { Ok(VersionCheck::PatchDrift) } else { Ok(VersionCheck::Match) }
}
#[must_use]
pub fn as_string(&self) -> String {
match (self.minor, self.patch) {
(Some(minor), Some(patch)) => format!("{}.{}.{}", self.major, minor, patch),
(Some(minor), None) => format!("{}.{}", self.major, minor),
_ => format!("{}", self.major),
}
}
#[must_use]
pub const fn is_exact(&self) -> bool {
self.minor.is_some() && self.patch.is_some()
}
}
impl std::fmt::Display for VersionPin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.as_string())
}
}
fn parse_installed_version(version: &str) -> Result<(u64, u64, u64), VersionPinParseError> {
let core = version.split(['-', '+']).next().unwrap_or(version).trim();
let mut parts = core.split('.');
let invalid = || VersionPinParseError::InvalidInstalledVersion(version.to_owned());
let major = parts.next().ok_or_else(invalid)?.parse::<u64>().map_err(|_| invalid())?;
let minor = parts.next().ok_or_else(invalid)?.parse::<u64>().map_err(|_| invalid())?;
let patch = parts.next().ok_or_else(invalid)?.parse::<u64>().map_err(|_| invalid())?;
Ok((major, minor, patch))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionPinParseError {
EmptyPin,
InvalidPinComponent { pin: String, component: String },
TooManyPinComponents(String),
InvalidInstalledVersion(String),
}
impl std::fmt::Display for VersionPinParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyPin => f.write_str("`version` pin in mago.toml must not be empty"),
Self::InvalidPinComponent { pin, component } => {
write!(
f,
"Invalid `version` pin `{pin}` in mago.toml: `{component}` is not a valid version component (expected a non-negative integer)"
)
}
Self::TooManyPinComponents(pin) => {
write!(
f,
"Invalid `version` pin `{pin}` in mago.toml: must be `<major>`, `<major>.<minor>`, or `<major>.<minor>.<patch>` (at most three components)"
)
}
Self::InvalidInstalledVersion(version) => {
write!(f, "installed mago version `{version}` is not in `<major>.<minor>.<patch>` form")
}
}
}
}
impl std::error::Error for VersionPinParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_major_only_pin() {
let pin = VersionPin::parse("1").unwrap();
assert_eq!(pin.as_string(), "1");
assert!(!pin.is_exact());
}
#[test]
fn parses_major_minor_pin() {
let pin = VersionPin::parse("1.19").unwrap();
assert_eq!(pin.as_string(), "1.19");
assert!(!pin.is_exact());
}
#[test]
fn parses_exact_pin() {
let pin = VersionPin::parse("1.19.3").unwrap();
assert_eq!(pin.as_string(), "1.19.3");
assert!(pin.is_exact());
}
#[test]
fn rejects_empty_pin() {
assert_eq!(VersionPin::parse(""), Err(VersionPinParseError::EmptyPin));
assert_eq!(VersionPin::parse(" "), Err(VersionPinParseError::EmptyPin));
}
#[test]
fn rejects_range_operators() {
assert!(matches!(VersionPin::parse("^1"), Err(VersionPinParseError::InvalidPinComponent { .. })));
assert!(matches!(VersionPin::parse(">=1"), Err(VersionPinParseError::InvalidPinComponent { .. })));
assert!(matches!(VersionPin::parse("~1.2"), Err(VersionPinParseError::InvalidPinComponent { .. })));
}
#[test]
fn rejects_four_components() {
assert_eq!(VersionPin::parse("1.2.3.4"), Err(VersionPinParseError::TooManyPinComponents("1.2.3.4".to_owned())));
}
#[test]
fn rejects_non_numeric_components() {
assert!(matches!(VersionPin::parse("1.x.3"), Err(VersionPinParseError::InvalidPinComponent { .. })));
}
#[test]
fn major_pin_matches_any_minor_or_patch() {
let pin = VersionPin::parse("1").unwrap();
assert_eq!(pin.check("1.19.3").unwrap(), VersionCheck::Match);
assert_eq!(pin.check("1.0.0").unwrap(), VersionCheck::Match);
assert_eq!(pin.check("1.99.99").unwrap(), VersionCheck::Match);
}
#[test]
fn major_pin_fails_on_major_drift() {
let pin = VersionPin::parse("1").unwrap();
assert_eq!(pin.check("2.0.0").unwrap(), VersionCheck::MajorDrift);
assert_eq!(pin.check("0.9.0").unwrap(), VersionCheck::MajorDrift);
}
#[test]
fn minor_pin_matches_any_patch() {
let pin = VersionPin::parse("1.19").unwrap();
assert_eq!(pin.check("1.19.0").unwrap(), VersionCheck::Match);
assert_eq!(pin.check("1.19.9").unwrap(), VersionCheck::Match);
}
#[test]
fn minor_pin_drifts_on_different_minor() {
let pin = VersionPin::parse("1.19").unwrap();
assert_eq!(pin.check("1.20.0").unwrap(), VersionCheck::MinorDrift);
assert_eq!(pin.check("1.18.0").unwrap(), VersionCheck::MinorDrift);
}
#[test]
fn minor_pin_fails_on_major_drift() {
let pin = VersionPin::parse("1.19").unwrap();
assert_eq!(pin.check("2.19.0").unwrap(), VersionCheck::MajorDrift);
}
#[test]
fn exact_pin_matches_exactly() {
let pin = VersionPin::parse("1.19.3").unwrap();
assert_eq!(pin.check("1.19.3").unwrap(), VersionCheck::Match);
}
#[test]
fn exact_pin_drifts_on_patch() {
let pin = VersionPin::parse("1.19.3").unwrap();
assert_eq!(pin.check("1.19.4").unwrap(), VersionCheck::PatchDrift);
assert_eq!(pin.check("1.19.2").unwrap(), VersionCheck::PatchDrift);
}
#[test]
fn exact_pin_minor_drift_beats_patch_drift() {
let pin = VersionPin::parse("1.19.3").unwrap();
assert_eq!(pin.check("1.20.3").unwrap(), VersionCheck::MinorDrift);
}
#[test]
fn major_drift_always_wins() {
let pin = VersionPin::parse("1.19.3").unwrap();
assert_eq!(pin.check("2.0.0").unwrap(), VersionCheck::MajorDrift);
}
#[test]
fn installed_version_prerelease_is_ignored() {
let pin = VersionPin::parse("1.19").unwrap();
assert_eq!(pin.check("1.19.0-rc1").unwrap(), VersionCheck::Match);
assert_eq!(pin.check("1.19.0+build.42").unwrap(), VersionCheck::Match);
}
}