use std::cmp;
use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct GemVersion {
version: String,
segments: Vec<VersionSegment>,
}
impl fmt::Display for GemVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.version)
}
}
fn validation_regex() -> &'static regex::Regex {
static VALIDATION_REGEX: OnceLock<regex::Regex> = OnceLock::new();
VALIDATION_REGEX.get_or_init(|| {
regex::Regex::new(
"^\\s*([0-9]+(?:\\.[0-9a-zA-Z]+)*(-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?)?\\s*$",
)
.expect("Internal error: Bad Regex")
})
}
fn segment_regex() -> &'static regex::Regex {
static SEGMENT_REGEX: OnceLock<regex::Regex> = OnceLock::new();
SEGMENT_REGEX.get_or_init(|| {
regex::Regex::new("[0-9]+|[a-zA-Z]+").expect("Internal Error: Invalid Regular Expression!")
})
}
impl TryFrom<String> for GemVersion {
type Error = VersionError;
fn try_from(version_string: String) -> Result<Self, Self::Error> {
Self::from_str(&version_string)
}
}
impl From<GemVersion> for String {
fn from(version: GemVersion) -> String {
version.to_string()
}
}
impl FromStr for GemVersion {
type Err = VersionError;
fn from_str(version_string: &str) -> Result<Self, Self::Err> {
if version_string.trim().is_empty() {
Ok(GemVersion {
version: String::from("0"),
segments: vec![VersionSegment::U32(0)],
})
} else if validation_regex().is_match(version_string) {
let version = version_string.trim().to_string();
let for_segments = version.replace('-', ".pre.");
let (segments_l, segments_r) = segment_regex()
.find_iter(&for_segments)
.map(|regex_match| {
regex_match.as_str().parse::<u32>().ok().map_or_else(
|| VersionSegment::String(regex_match.as_str().to_string()),
VersionSegment::U32,
)
})
.fold(
(vec![], vec![]),
|(mut acc_segments_l, mut acc_segments_r), item| {
match item {
item @ VersionSegment::U32(_) if acc_segments_r.is_empty() => {
acc_segments_l.push(item);
}
_ => acc_segments_r.push(item),
}
(acc_segments_l, acc_segments_r)
},
);
let is_zero_segment = |v: &VersionSegment| *v == VersionSegment::U32(0);
let segments_l = drop_right_while(segments_l, is_zero_segment);
let segments_r = drop_right_while(segments_r, is_zero_segment);
let mut segments = segments_l;
segments.extend(segments_r);
Ok(GemVersion { version, segments })
} else {
Err(VersionError::InvalidVersion(String::from(version_string)))
}
}
}
impl PartialEq<GemVersion> for GemVersion {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for GemVersion {}
impl Hash for GemVersion {
fn hash<H: Hasher>(&self, state: &mut H) {
self.segments.hash(state);
}
}
impl Ord for GemVersion {
fn cmp(&self, other: &Self) -> Ordering {
let max = cmp::max(self.segments.len(), other.segments.len());
let default = VersionSegment::U32(0);
for index in 0..max {
let segment_l = self.segments.get(index).unwrap_or(&default);
let segment_r = other.segments.get(index).unwrap_or(&default);
match segment_l.cmp(segment_r) {
Ordering::Equal => {}
ord => return ord,
}
}
Ordering::Equal
}
}
impl PartialOrd<GemVersion> for GemVersion {
fn partial_cmp(&self, other: &GemVersion) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum VersionError {
InvalidVersion(String),
}
impl std::error::Error for VersionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
impl fmt::Display for VersionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VersionError::InvalidVersion(version) => {
write!(f, "Invalid version string: {version}")
}
}
}
}
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
enum VersionSegment {
String(String),
U32(u32),
}
impl PartialOrd for VersionSegment {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for VersionSegment {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(VersionSegment::U32(a), VersionSegment::U32(b)) => a.cmp(b),
(VersionSegment::U32(_), VersionSegment::String(_)) => Ordering::Greater,
(VersionSegment::String(_), VersionSegment::U32(_)) => Ordering::Less,
(VersionSegment::String(a), VersionSegment::String(b)) => a.cmp(b),
}
}
}
fn drop_right_while<A>(mut v: Vec<A>, pred: impl Fn(&A) -> bool) -> Vec<A> {
while v.last().is_some_and(&pred) {
v.pop();
}
v
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_initialize() {
for version in &["1.0", "1.0 ", " 1.0 ", "1.0\n", "\n1.0\n", "1.0"] {
assert_eq!(v(version), v("1.0"));
}
}
#[test]
fn empty_version() {
assert_eq!(v(""), v("0"));
assert_eq!(v(" "), v("0"));
assert_eq!(v(" "), v("0"));
}
#[test]
fn spaceship() {
assert_eq!(v("1.0"), v("1.0.0"));
assert_eq!(v("1"), v("1.0.0"));
assert!(v("1.0") > v("1.0.a"));
assert!(v("1.8.2") > v("0.0.0"));
assert!(v("1.8.2") > v("1.8.2.a"));
assert!(v("1.8.2.b") > v("1.8.2.a"));
assert!(v("1.8.2.a") < v("1.8.2"));
assert!(v("1.8.2.a10") > v("1.8.2.a9"));
assert_eq!(v(""), v("0"));
assert_eq!(v("0.beta.1"), v("0.0.beta.1"));
assert!(v("0.0.beta") < v("0.0.beta.1"));
assert!(v("0.0.beta") < v("0.beta.1"));
assert!(v("5.a") < v("5.0.0.rc2"));
assert!(v("5.x") > v("5.0.0.rc2"));
assert_eq!(v("1.9.3"), v("1.9.3"));
assert!(v("1.9.3") > v("1.9.2.99"));
assert!(v("1.9.3") < v("1.9.3.1"));
}
#[test]
fn invalid_versions() {
assert_eq!(
"junk".parse::<GemVersion>(),
Err(VersionError::InvalidVersion(String::from("junk")))
);
assert_eq!(
"1.0\n2.0".parse::<GemVersion>(),
Err(VersionError::InvalidVersion(String::from("1.0\n2.0")))
);
assert_eq!(
"1..2".parse::<GemVersion>(),
Err(VersionError::InvalidVersion(String::from("1..2")))
);
assert_eq!(
"1.2\\ 3.4".parse::<GemVersion>(),
Err(VersionError::InvalidVersion(String::from("1.2\\ 3.4")))
);
assert_eq!(
"2.3422222.222.222222222.22222.ads0as.dasd0.ddd2222.2.qd3e.".parse::<GemVersion>(),
Err(VersionError::InvalidVersion(String::from(
"2.3422222.222.222222222.22222.ads0as.dasd0.ddd2222.2.qd3e."
)))
);
}
#[test]
fn display_preserves_original_string() {
assert_eq!("4.0.0.preview2", v("4.0.0.preview2").to_string());
assert_eq!("4.0.0.preview.2", v("4.0.0.preview.2").to_string());
assert_eq!("3.1.2", v("3.1.2").to_string());
assert_eq!("1.0", v("1.0").to_string());
assert_eq!("1.0.0", v("1.0.0").to_string());
assert_eq!("0.0.beta.1", v("0.0.beta.1").to_string());
}
#[test]
fn equality_with_split_prerelease_segment() {
assert_eq!(v("1.2"), v("1.2"));
assert_ne!(v("1.2"), v("1.3"));
assert_eq!(v("1.2.b1"), v("1.2.b.1"));
}
#[test]
fn empty_version_display() {
assert_eq!("0", v("").to_string());
assert_eq!("0", v(" ").to_string());
assert_eq!("0", v(" ").to_string());
}
#[test]
fn uppercase_letters_not_silently_dropped() {
assert_ne!(v("1.0.0.Preview2"), v("1.0.0.review2"));
assert_ne!(v("1.0.0.preview2"), v("1.0.0.Preview2"));
assert_ne!(v("1.0P"), v("1.0p"));
assert!(v("1.0.0.Preview2") < v("1.0.0.preview2"));
}
#[test]
fn serde_roundtrip() {
let original = v("3.1.2");
let serialized = serde_json::to_string(&original).unwrap();
assert_eq!(serialized, "\"3.1.2\"");
let deserialized: GemVersion = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn ord_enables_sorting() {
let mut versions = vec![v("3.0"), v("1.0"), v("2.0")];
versions.sort();
assert_eq!(versions, vec![v("1.0"), v("2.0"), v("3.0")]);
}
#[test]
fn semver_comparison() {
assert!(v("1.0.0-alpha") < v("1.0.0-alpha.1"));
assert!(v("1.0.0-alpha.1") < v("1.0.0-beta.2"));
assert!(v("1.0.0-beta.2") < v("1.0.0-beta.11"));
assert!(v("1.0.0-beta.11") < v("1.0.0-rc.1"));
assert!(v("1.0.0-rc1") < v("1.0.0"));
assert!(v("1.0.0-1") < v("1"));
}
#[test]
fn test_version_display() {
for version in &["1.0.0-alpha", "1.0.0-beta.2", "1.0.0-1"] {
let gem = v(version);
assert_eq!(version, &&gem.to_string());
assert_eq!(v(&version.replace('-', ".pre.")), gem);
}
}
#[test]
fn hash_consistent_with_eq() {
use std::collections::hash_map::DefaultHasher;
use std::collections::HashSet;
use std::hash::{Hash, Hasher};
fn hash_version(v: &GemVersion) -> u64 {
let mut hasher = DefaultHasher::new();
v.hash(&mut hasher);
hasher.finish()
}
assert_eq!(hash_version(&v("1.0")), hash_version(&v("1.0.0")));
assert_ne!(hash_version(&v("1.0")), hash_version(&v("2.0")));
let mut set = HashSet::new();
set.insert(v("1.0"));
set.insert(v("1.0.0"));
assert_eq!(set.len(), 1);
}
fn v(s: &str) -> GemVersion {
s.parse().unwrap()
}
}