use std::cmp::Ordering;
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum Segment {
Numeric(u64),
String(String),
}
impl PartialOrd for Segment {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Segment {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(Segment::Numeric(a), Segment::Numeric(b)) => a.cmp(b),
(Segment::String(a), Segment::String(b)) => a.cmp(b),
(Segment::Numeric(_), Segment::String(_)) => Ordering::Greater,
(Segment::String(_), Segment::Numeric(_)) => Ordering::Less,
}
}
}
impl fmt::Display for Segment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Segment::Numeric(n) => write!(f, "{}", n),
Segment::String(s) => write!(f, "{}", s),
}
}
}
#[derive(Debug, Clone, Eq)]
pub struct Version {
segments: Vec<Segment>,
original: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum VersionError {
#[error("empty version string")]
Empty,
#[error("invalid character in version: '{0}'")]
InvalidCharacter(char),
#[error("invalid version format: '{0}'")]
InvalidFormat(String),
}
impl Version {
pub fn parse(input: &str) -> Result<Self, VersionError> {
let input = input.trim();
if input.is_empty() {
return Ok(Version {
segments: vec![Segment::Numeric(0)],
original: "0".to_string(),
});
}
for c in input.chars() {
if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
return Err(VersionError::InvalidCharacter(c));
}
}
let normalized = input.replace('-', ".pre.");
let segments = parse_segments(&normalized)?;
if segments.is_empty() {
return Err(VersionError::InvalidFormat(input.to_string()));
}
Ok(Version {
segments,
original: input.to_string(),
})
}
pub fn is_prerelease(&self) -> bool {
self.segments
.iter()
.any(|s| matches!(s, Segment::String(_)))
}
pub fn bump(&self) -> Version {
let mut new_segments = self.segments.clone();
while new_segments
.last()
.is_some_and(|s| matches!(s, Segment::String(_)))
{
new_segments.pop();
}
if new_segments.len() > 1 {
new_segments.pop();
}
if let Some(Segment::Numeric(n)) = new_segments.last_mut() {
*n += 1;
}
if new_segments.is_empty() {
new_segments.push(Segment::Numeric(1));
}
let original = new_segments
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(".");
Version {
segments: new_segments,
original,
}
}
pub fn increment_last(&self) -> Version {
let mut new_segments = self.segments.clone();
for seg in new_segments.iter_mut().rev() {
if let Segment::Numeric(n) = seg {
*n += 1;
break;
}
}
let original = new_segments
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(".");
Version {
segments: new_segments,
original,
}
}
pub fn append_zero(&self) -> Version {
let mut new_segments = self.segments.clone();
new_segments.push(Segment::Numeric(0));
let original = new_segments
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(".");
Version {
segments: new_segments,
original,
}
}
pub fn segments(&self) -> &[Segment] {
&self.segments
}
}
fn parse_segments(input: &str) -> Result<Vec<Segment>, VersionError> {
let mut segments = Vec::new();
for part in input.split('.') {
if part.is_empty() {
continue;
}
let mut chars = part.chars().peekable();
while chars.peek().is_some() {
let first = *chars.peek().unwrap();
if first.is_ascii_digit() {
let mut num_str = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_digit() {
num_str.push(c);
chars.next();
} else {
break;
}
}
let n: u64 = num_str.parse().map_err(|_| {
VersionError::InvalidFormat(format!("numeric overflow: {}", num_str))
})?;
segments.push(Segment::Numeric(n));
} else if first.is_ascii_alphabetic() {
let mut s = String::new();
while let Some(&c) = chars.peek() {
if c.is_ascii_alphabetic() {
s.push(c);
chars.next();
} else {
break;
}
}
segments.push(Segment::String(s));
} else {
return Err(VersionError::InvalidCharacter(first));
}
}
}
Ok(segments)
}
impl PartialEq for Version {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> Ordering {
let a = &self.segments;
let b = &other.segments;
let max_len = a.len().max(b.len());
for i in 0..max_len {
let seg_a = a.get(i);
let seg_b = b.get(i);
let ord = match (seg_a, seg_b) {
(Some(sa), Some(sb)) => sa.cmp(sb),
(Some(sa), None) => sa.cmp(&Segment::Numeric(0)),
(None, Some(sb)) => Segment::Numeric(0).cmp(sb),
(None, None) => Ordering::Equal,
};
if ord != Ordering::Equal {
return ord;
}
}
Ordering::Equal
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.original)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_version() {
let v = Version::parse("1.2.3").unwrap();
assert_eq!(
v.segments,
vec![
Segment::Numeric(1),
Segment::Numeric(2),
Segment::Numeric(3)
]
);
}
#[test]
fn parse_single_segment() {
let v = Version::parse("5").unwrap();
assert_eq!(v.segments, vec![Segment::Numeric(5)]);
}
#[test]
fn parse_with_leading_zeros() {
let v = Version::parse("01.02.03").unwrap();
assert_eq!(
v.segments,
vec![
Segment::Numeric(1),
Segment::Numeric(2),
Segment::Numeric(3)
]
);
}
#[test]
fn parse_prerelease_with_dot() {
let v = Version::parse("1.0.0.alpha").unwrap();
assert_eq!(
v.segments,
vec![
Segment::Numeric(1),
Segment::Numeric(0),
Segment::Numeric(0),
Segment::String("alpha".to_string()),
]
);
assert!(v.is_prerelease());
}
#[test]
fn parse_prerelease_inline() {
let v = Version::parse("1.0.0rc1").unwrap();
assert_eq!(
v.segments,
vec![
Segment::Numeric(1),
Segment::Numeric(0),
Segment::Numeric(0),
Segment::String("rc".to_string()),
Segment::Numeric(1),
]
);
}
#[test]
fn parse_prerelease_with_hyphen() {
let v = Version::parse("1.0.0-rc1").unwrap();
assert_eq!(
v.segments,
vec![
Segment::Numeric(1),
Segment::Numeric(0),
Segment::Numeric(0),
Segment::String("pre".to_string()),
Segment::String("rc".to_string()),
Segment::Numeric(1),
]
);
}
#[test]
fn parse_empty_string() {
let v = Version::parse("").unwrap();
assert_eq!(v.segments, vec![Segment::Numeric(0)]);
}
#[test]
fn parse_invalid_character() {
assert!(Version::parse("1.0+build").is_err());
assert!(Version::parse("1.0_pre1").is_err());
}
#[test]
fn compare_simple_versions() {
let v1 = Version::parse("1.0.0").unwrap();
let v2 = Version::parse("1.0.1").unwrap();
assert!(v1 < v2);
}
#[test]
fn compare_major_versions() {
let v1 = Version::parse("1.0.0").unwrap();
let v2 = Version::parse("2.0.0").unwrap();
assert!(v1 < v2);
}
#[test]
fn trailing_zeros_are_equal() {
let v1 = Version::parse("1.0").unwrap();
let v2 = Version::parse("1.0.0").unwrap();
let v3 = Version::parse("1.0.0.0").unwrap();
assert_eq!(v1, v2);
assert_eq!(v2, v3);
assert_eq!(v1, v3);
}
#[test]
fn single_segment_equals_with_trailing_zeros() {
let v1 = Version::parse("1").unwrap();
let v2 = Version::parse("1.0").unwrap();
assert_eq!(v1, v2);
}
#[test]
fn prerelease_less_than_release() {
let pre = Version::parse("1.0.0.alpha").unwrap();
let rel = Version::parse("1.0.0").unwrap();
assert!(pre < rel);
}
#[test]
fn prerelease_inline_less_than_release() {
let pre = Version::parse("1.0.0a").unwrap();
let rel = Version::parse("1.0.0").unwrap();
assert!(pre < rel);
}
#[test]
fn prerelease_ordering() {
let alpha = Version::parse("1.0.0.alpha").unwrap();
let beta = Version::parse("1.0.0.beta").unwrap();
let rc = Version::parse("1.0.0.rc").unwrap();
let release = Version::parse("1.0.0").unwrap();
assert!(alpha < beta);
assert!(beta < rc);
assert!(rc < release);
}
#[test]
fn prerelease_a_b_ordering() {
let a = Version::parse("1.0.0a").unwrap();
let b = Version::parse("1.0.0b").unwrap();
assert!(a < b);
}
#[test]
fn string_segment_less_than_integer() {
let with_str = Version::parse("1.0.0.alpha").unwrap();
let without = Version::parse("1.0.0").unwrap();
assert!(with_str < without);
let with_str2 = Version::parse("1.0.0a").unwrap();
assert!(with_str2 < without);
}
#[test]
fn compare_different_lengths() {
let short = Version::parse("1.0").unwrap();
let long = Version::parse("1.0.1").unwrap();
assert!(short < long);
}
#[test]
fn compare_year_based_versions() {
let v1 = Version::parse("2020.1.1").unwrap();
let v2 = Version::parse("2021.1.1").unwrap();
assert!(v1 < v2);
}
#[test]
fn not_prerelease() {
let v = Version::parse("1.2.3").unwrap();
assert!(!v.is_prerelease());
}
#[test]
fn is_prerelease_with_alpha() {
let v = Version::parse("1.0.0.alpha").unwrap();
assert!(v.is_prerelease());
}
#[test]
fn is_prerelease_inline() {
let v = Version::parse("1.0.0rc1").unwrap();
assert!(v.is_prerelease());
}
#[test]
fn four_segment_numeric_not_prerelease() {
let v = Version::parse("1.0.0.1").unwrap();
assert!(!v.is_prerelease());
}
#[test]
fn bump_three_segments() {
let v = Version::parse("1.2.3").unwrap();
let bumped = v.bump();
assert_eq!(bumped, Version::parse("1.3").unwrap());
}
#[test]
fn bump_two_segments() {
let v = Version::parse("1.0").unwrap();
let bumped = v.bump();
assert_eq!(bumped, Version::parse("2").unwrap());
}
#[test]
fn bump_single_segment() {
let v = Version::parse("1").unwrap();
let bumped = v.bump();
assert_eq!(bumped, Version::parse("2").unwrap());
}
#[test]
fn bump_four_segments() {
let v = Version::parse("1.2.3.4").unwrap();
let bumped = v.bump();
assert_eq!(bumped, Version::parse("1.2.4").unwrap());
}
#[test]
fn bump_with_trailing_zeros() {
let v = Version::parse("1.0.0").unwrap();
let bumped = v.bump();
assert_eq!(bumped, Version::parse("1.1").unwrap());
}
#[test]
fn display_preserves_original() {
let v = Version::parse("1.2.3").unwrap();
assert_eq!(v.to_string(), "1.2.3");
}
#[test]
fn display_prerelease() {
let v = Version::parse("1.0.0.alpha").unwrap();
assert_eq!(v.to_string(), "1.0.0.alpha");
}
}