use std::cmp::Ordering;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrereleaseIdent {
Numeric(u64),
Alphanumeric(String),
}
impl PrereleaseIdent {
fn precedence(&self, other: &Self) -> Ordering {
match (self, other) {
(Self::Numeric(left), Self::Numeric(right)) => left.cmp(right),
(Self::Numeric(_), Self::Alphanumeric(_)) => Ordering::Less,
(Self::Alphanumeric(_), Self::Numeric(_)) => Ordering::Greater,
(Self::Alphanumeric(left), Self::Alphanumeric(right)) => left.cmp(right),
}
}
}
impl fmt::Display for PrereleaseIdent {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Numeric(value) => write!(formatter, "{value}"),
Self::Alphanumeric(text) => formatter.write_str(text),
}
}
}
#[derive(Debug, Clone)]
pub struct SemVer {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub prerelease: Vec<PrereleaseIdent>,
pub build: Option<String>,
}
impl SemVer {
pub fn parse(text: &str) -> Result<Self, SemVerError> {
let (rest, build) = match text.split_once('+') {
Some((rest, build)) => (rest, Some(build)),
None => (text, None),
};
let (core, prerelease) = match rest.split_once('-') {
Some((core, prerelease)) => (core, Some(prerelease)),
None => (rest, None),
};
let mut parts = core.split('.');
let major = core_part(parts.next(), "major")?;
let minor = core_part(parts.next(), "minor")?;
let patch = core_part(parts.next(), "patch")?;
if parts.next().is_some() {
return Err(SemVerError::ExtraCorePart);
}
let prerelease = match prerelease {
Some(idents) => parse_prerelease(idents)?,
None => Vec::new(),
};
let build = match build {
Some(metadata) => Some(parse_build(metadata)?),
None => None,
};
Ok(Self {
major,
minor,
patch,
prerelease,
build,
})
}
}
impl fmt::Display for SemVer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)?;
for (position, ident) in self.prerelease.iter().enumerate() {
let separator = if position == 0 { '-' } else { '.' };
write!(formatter, "{separator}{ident}")?;
}
if let Some(build) = &self.build {
write!(formatter, "+{build}")?;
}
Ok(())
}
}
impl PartialEq for SemVer {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for SemVer {}
impl PartialOrd for SemVer {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SemVer {
fn cmp(&self, other: &Self) -> Ordering {
self.major
.cmp(&other.major)
.then_with(|| self.minor.cmp(&other.minor))
.then_with(|| self.patch.cmp(&other.patch))
.then_with(|| prerelease_precedence(&self.prerelease, &other.prerelease))
}
}
fn prerelease_precedence(left: &[PrereleaseIdent], right: &[PrereleaseIdent]) -> Ordering {
match (left.is_empty(), right.is_empty()) {
(true, true) => Ordering::Equal,
(true, false) => Ordering::Greater,
(false, true) => Ordering::Less,
(false, false) => {
for (this, that) in left.iter().zip(right.iter()) {
let ordering = this.precedence(that);
if ordering != Ordering::Equal {
return ordering;
}
}
left.len().cmp(&right.len())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SemVerError {
#[error("the version is missing its {part} part")]
MissingCorePart {
part: &'static str,
},
#[error("the version has more than three dotted version-core parts")]
ExtraCorePart,
#[error("the {part} part is not an accepted numeric identifier: {reason}")]
MalformedCorePart {
part: &'static str,
reason: &'static str,
},
#[error("the prerelease is malformed: {reason}")]
MalformedPrerelease {
reason: &'static str,
},
#[error("the build metadata is malformed: {reason}")]
MalformedBuild {
reason: &'static str,
},
}
fn core_part(part: Option<&str>, name: &'static str) -> Result<u64, SemVerError> {
let Some(part) = part else {
return Err(SemVerError::MissingCorePart { part: name });
};
numeric_identifier(part).map_err(|reason| SemVerError::MalformedCorePart { part: name, reason })
}
fn numeric_identifier(text: &str) -> Result<u64, &'static str> {
if text.is_empty() {
return Err("it is empty");
}
if !text.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("it contains a non-digit");
}
if text.len() > 1 && text.starts_with('0') {
return Err("it has a leading zero");
}
text.parse::<u64>()
.map_err(|_| "it overflows the deliberate 64-bit bound")
}
fn parse_prerelease(idents: &str) -> Result<Vec<PrereleaseIdent>, SemVerError> {
idents.split('.').map(prerelease_identifier).collect()
}
fn prerelease_identifier(ident: &str) -> Result<PrereleaseIdent, SemVerError> {
if ident.is_empty() {
return Err(SemVerError::MalformedPrerelease {
reason: "an identifier is empty",
});
}
if !ident
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(SemVerError::MalformedPrerelease {
reason: "an identifier contains a character outside [0-9A-Za-z-]",
});
}
if ident.bytes().all(|byte| byte.is_ascii_digit()) {
return numeric_identifier(ident)
.map(PrereleaseIdent::Numeric)
.map_err(|reason| SemVerError::MalformedPrerelease { reason });
}
Ok(PrereleaseIdent::Alphanumeric(ident.to_owned()))
}
fn parse_build(metadata: &str) -> Result<String, SemVerError> {
for ident in metadata.split('.') {
if ident.is_empty() {
return Err(SemVerError::MalformedBuild {
reason: "an identifier is empty",
});
}
if !ident
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return Err(SemVerError::MalformedBuild {
reason: "an identifier contains a character outside [0-9A-Za-z-]",
});
}
}
Ok(metadata.to_owned())
}
#[cfg(test)]
mod tests {
use super::{PrereleaseIdent, SemVer, SemVerError};
type TestResult = Result<(), Box<dyn std::error::Error>>;
type ExpectedRefusal = fn(&SemVerError) -> bool;
#[test]
fn the_spec_precedence_chain_ranks_strictly_ascending() -> TestResult {
let chain = [
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-alpha.beta",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-beta.11",
"1.0.0-rc.1",
"1.0.0",
"2.0.0",
"2.1.0",
"2.1.1",
];
for pair in chain.windows(2) {
let lower = SemVer::parse(pair[0])?;
let higher = SemVer::parse(pair[1])?;
assert!(
lower < higher,
"`{}` must rank strictly below `{}`",
pair[0],
pair[1]
);
}
Ok(())
}
#[test]
fn build_metadata_is_ignored_for_precedence_and_kept_for_display() -> TestResult {
let bare = SemVer::parse("1.0.0")?;
let stamped = SemVer::parse("1.0.0+20130313144700")?;
assert_eq!(bare, stamped);
assert_eq!(stamped.to_string(), "1.0.0+20130313144700");
Ok(())
}
#[test]
fn a_full_form_round_trips_through_display() -> TestResult {
let text = "1.2.3-rc.1.x-y+exp.sha.5114f85";
assert_eq!(SemVer::parse(text)?.to_string(), text);
Ok(())
}
#[test]
fn numeric_prerelease_identifiers_compare_numerically_not_lexically() -> TestResult {
assert!(SemVer::parse("1.0.0-beta.2")? < SemVer::parse("1.0.0-beta.11")?);
Ok(())
}
#[test]
fn classification_is_part_of_the_parse() -> TestResult {
let parsed = SemVer::parse("1.0.0-alpha.7.0a")?;
assert_eq!(
parsed.prerelease,
vec![
PrereleaseIdent::Alphanumeric("alpha".to_owned()),
PrereleaseIdent::Numeric(7),
PrereleaseIdent::Alphanumeric("0a".to_owned()),
]
);
Ok(())
}
#[test]
fn malformed_versions_refuse_by_name() -> TestResult {
let refusals: [(&str, ExpectedRefusal); 10] = [
("1.0", |error| {
matches!(error, SemVerError::MissingCorePart { part: "patch" })
}),
("1.2.3.4", |error| {
matches!(error, SemVerError::ExtraCorePart)
}),
("01.2.3", |error| {
matches!(error, SemVerError::MalformedCorePart { part: "major", .. })
}),
("1.2.x", |error| {
matches!(error, SemVerError::MalformedCorePart { part: "patch", .. })
}),
("v1.2.3", |error| {
matches!(error, SemVerError::MalformedCorePart { part: "major", .. })
}),
("18446744073709551616.0.0", |error| {
matches!(
error,
SemVerError::MalformedCorePart {
part: "major",
reason: "it overflows the deliberate 64-bit bound",
}
)
}),
("1.2.3-", |error| {
matches!(error, SemVerError::MalformedPrerelease { .. })
}),
("1.2.3-rc..1", |error| {
matches!(error, SemVerError::MalformedPrerelease { .. })
}),
("1.2.3-01", |error| {
matches!(error, SemVerError::MalformedPrerelease { .. })
}),
("1.2.3+a_b", |error| {
matches!(error, SemVerError::MalformedBuild { .. })
}),
];
for (text, expected) in refusals {
match SemVer::parse(text) {
Ok(parsed) => {
return Err(format!("`{text}` must refuse, parsed as `{parsed}`").into());
}
Err(error) => {
assert!(
expected(&error),
"`{text}` refused with the wrong arm: {error}"
);
}
}
}
Ok(())
}
#[test]
fn padding_is_not_forgiven() {
assert!(SemVer::parse(" 1.2.3").is_err());
assert!(SemVer::parse("1.2.3 ").is_err());
assert!(SemVer::parse("").is_err());
}
#[test]
fn refusals_never_echo_the_refused_text() -> TestResult {
let hostile_digits = "9".repeat(4096);
let hostile_ident = "a".repeat(4096);
for text in [
format!("{hostile_digits}.0.0"),
format!("1.0.0-{hostile_ident}!"),
format!("1.0.0+{hostile_ident}_"),
format!("x{hostile_ident}.2.3"),
] {
let Err(error) = SemVer::parse(&text) else {
return Err(format!("a hostile {}-byte version must refuse", text.len()).into());
};
let rendered = error.to_string();
assert!(
rendered.len() < 256,
"a refusal must stay bounded; got {} bytes",
rendered.len()
);
assert!(
!rendered.contains(&hostile_digits) && !rendered.contains(&hostile_ident),
"a refusal must not echo the refused text"
);
}
Ok(())
}
}