Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I79b8d3745a8754619f810de1bac8b66f6a6a6964
326 lines
8.3 KiB
Rust
326 lines
8.3 KiB
Rust
// FlexVer - Flexible Version Comparison
|
|
//
|
|
// This implementation is based on the original implementation of the
|
|
// `flexver-rs` crate, which no longer appears to be maintained.
|
|
//
|
|
// See:
|
|
// <https://git.sleeping.town/exa/FlexVer/src/branch/trunk/rust/src/lib.rs>
|
|
//
|
|
// This implementation provides semver-like version comparison with support for:
|
|
//
|
|
// - Flexible version string parsing (not strict semver)
|
|
// - Pre-release handling (parts starting with `-`)
|
|
// - Build metadata stripping (parts after `+`)
|
|
// - Numerical vs lexical comparison
|
|
|
|
use std::{
|
|
cmp::Ordering::{self, Equal, Greater, Less},
|
|
collections::VecDeque,
|
|
};
|
|
|
|
/// Type of version component for sorting purposes
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum SortingType {
|
|
/// A numeric component with both i64 value and string representation
|
|
Numerical(i64, String),
|
|
|
|
/// A lexical string component
|
|
Lexical(String),
|
|
|
|
/// A semver pre-release component (starting with `-`)
|
|
SemverPrerelease(String),
|
|
}
|
|
|
|
impl SortingType {
|
|
fn into_string(self) -> String {
|
|
match self {
|
|
Self::Numerical(_, s) | Self::Lexical(s) | Self::SemverPrerelease(s) => s,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_semver_prerelease(s: &str) -> bool {
|
|
s.len() > 1 && s.starts_with('-')
|
|
}
|
|
|
|
/// Decompose a version string into its component parts
|
|
fn decompose(str_in: &str) -> VecDeque<SortingType> {
|
|
if str_in.is_empty() {
|
|
return VecDeque::new();
|
|
}
|
|
|
|
// Strip build metadata (after `+`)
|
|
let s = if let Some((left, _)) = str_in.split_once('+') {
|
|
left
|
|
} else {
|
|
str_in
|
|
};
|
|
|
|
let mut out: VecDeque<SortingType> = VecDeque::new();
|
|
let mut current = String::new();
|
|
|
|
let mut currently_numeric = s.starts_with(|c: char| c.is_ascii_digit());
|
|
let mut skip = s.starts_with('-');
|
|
|
|
fn handle_split(
|
|
current: &str,
|
|
c: Option<&char>,
|
|
currently_numeric: bool,
|
|
) -> Option<SortingType> {
|
|
let numeric = if let Some(c) = c {
|
|
c.is_ascii_digit()
|
|
} else {
|
|
false
|
|
};
|
|
|
|
use SortingType::*;
|
|
|
|
if currently_numeric {
|
|
if numeric {
|
|
return None;
|
|
} else {
|
|
return Some(Numerical(
|
|
current.parse::<i64>().unwrap(),
|
|
current.to_owned(),
|
|
));
|
|
}
|
|
}
|
|
|
|
if !(numeric || c == Some(&'-') || c.is_none()) {
|
|
return None;
|
|
}
|
|
|
|
if is_semver_prerelease(current) {
|
|
if c == Some(&'-') {
|
|
// Pre-releases can have multiple dashes
|
|
None
|
|
} else {
|
|
Some(SemverPrerelease(current.to_owned()))
|
|
}
|
|
} else {
|
|
Some(Lexical(current.to_owned()))
|
|
}
|
|
}
|
|
|
|
for c in s.chars() {
|
|
if let Some(part) = handle_split(¤t, Some(&c), currently_numeric) {
|
|
if skip {
|
|
skip = false;
|
|
} else {
|
|
out.push_back(part);
|
|
current.clear();
|
|
currently_numeric = c.is_ascii_digit();
|
|
}
|
|
}
|
|
current.push(c);
|
|
}
|
|
|
|
if let Some(part) = handle_split(¤t, None, currently_numeric) {
|
|
out.push_back(part);
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
/// Compare two version strings using FlexVer rules.
|
|
///
|
|
/// Returns:
|
|
/// - `Ordering::Less` if `a` < `b`
|
|
/// - `Ordering::Equal` if `a` == `b`
|
|
/// - `Ordering::Greater` if `a` > `b`
|
|
///
|
|
/// This matches the behavior of flexver-java:
|
|
/// - "1.0.0" > "1.0.0-beta" (release > pre-release)
|
|
/// - "1.0.0-beta" < "1.0.0+build123" (pre-release < build metadata)
|
|
pub fn compare(left: &str, right: &str) -> Ordering {
|
|
let iter = VersionComparisonIterator {
|
|
left: decompose(left),
|
|
right: decompose(right),
|
|
};
|
|
|
|
for next in iter {
|
|
use SortingType::*;
|
|
|
|
let current = match next {
|
|
// Left ran out first
|
|
(Some(l), None) => {
|
|
if let SemverPrerelease(_) = l {
|
|
Less
|
|
} else {
|
|
Greater
|
|
}
|
|
},
|
|
// Right ran out first
|
|
(None, Some(r)) => {
|
|
if let SemverPrerelease(_) = r {
|
|
Greater
|
|
} else {
|
|
Less
|
|
}
|
|
},
|
|
// Both have components
|
|
(Some(l), Some(r)) => {
|
|
match (l, r) {
|
|
(Numerical(l, _), Numerical(r, _)) => l.cmp(&r),
|
|
(l, r) => l.into_string().cmp(&r.into_string()),
|
|
}
|
|
},
|
|
(None, None) => unreachable!(),
|
|
};
|
|
|
|
if current != Equal {
|
|
return current;
|
|
}
|
|
}
|
|
|
|
Equal
|
|
}
|
|
|
|
/// Version comparison iterator that yields pairs of components
|
|
#[derive(Debug)]
|
|
struct VersionComparisonIterator {
|
|
left: VecDeque<SortingType>,
|
|
right: VecDeque<SortingType>,
|
|
}
|
|
|
|
impl Iterator for VersionComparisonIterator {
|
|
type Item = (Option<SortingType>, Option<SortingType>);
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let item = (self.left.pop_front(), self.right.pop_front());
|
|
if let (None, None) = item {
|
|
None
|
|
} else {
|
|
Some(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// FlexVer type for use with standard library traits
|
|
#[derive(Debug, Copy, Clone)]
|
|
pub struct FlexVer<'a>(pub &'a str);
|
|
|
|
impl PartialEq for FlexVer<'_> {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
compare(self.0, other.0) == Equal
|
|
}
|
|
}
|
|
|
|
impl Eq for FlexVer<'_> {}
|
|
|
|
impl PartialOrd for FlexVer<'_> {
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
impl Ord for FlexVer<'_> {
|
|
fn cmp(&self, other: &Self) -> Ordering {
|
|
compare(self.0, other.0)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::cmp::Ordering;
|
|
|
|
use super::*;
|
|
|
|
fn cmp(a: &str, b: &str) -> Ordering {
|
|
compare(a, b)
|
|
}
|
|
|
|
#[test]
|
|
fn test_basic_release_comparison() {
|
|
assert_eq!(cmp("1.0.0", "1.0.0"), Ordering::Equal);
|
|
assert_eq!(cmp("1.0.0", "1.0.1"), Ordering::Less);
|
|
assert_eq!(cmp("1.0.1", "1.0.0"), Ordering::Greater);
|
|
assert_eq!(cmp("1.0.0", "1.1.0"), Ordering::Less);
|
|
assert_eq!(cmp("1.1.0", "1.0.0"), Ordering::Greater);
|
|
assert_eq!(cmp("2.0.0", "1.9.9"), Ordering::Greater);
|
|
}
|
|
|
|
#[test]
|
|
fn test_prerelease_comparison() {
|
|
// Release > pre-release
|
|
assert_eq!(cmp("1.0.0", "1.0.0-beta"), Ordering::Greater);
|
|
assert_eq!(cmp("1.0.0-beta", "1.0.0"), Ordering::Less);
|
|
|
|
// Pre-release with tilde
|
|
assert_eq!(cmp("1.0.0~1", "1.0.0~2"), Ordering::Less);
|
|
assert_eq!(cmp("1.0.0~2", "1.0.0~1"), Ordering::Greater);
|
|
assert_eq!(cmp("1.0.0~1", "1.0.0~1"), Ordering::Equal);
|
|
}
|
|
|
|
#[test]
|
|
fn test_prerelease_with_tilde_vs_alpha() {
|
|
// In FlexVer, "~" is not treated as a pre-release marker
|
|
// Only "-" followed by text marks a pre-release
|
|
// So "1.0.0~1" is compared lexicographically vs "1.0.0-beta"
|
|
// Since '~' (ASCII 126) > '-' (ASCII 45), "~1" > "-beta"
|
|
assert_eq!(cmp("1.0.0~1", "1.0.0-beta"), Ordering::Greater);
|
|
assert_eq!(cmp("1.0.0-beta", "1.0.0~1"), Ordering::Less);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_metadata() {
|
|
// Build metadata with + is stripped for comparison
|
|
assert_eq!(cmp("1.0.0+build", "1.0.0"), Ordering::Equal);
|
|
assert_eq!(cmp("1.0.0+build", "1.0.0-alpha"), Ordering::Greater);
|
|
}
|
|
|
|
#[test]
|
|
fn test_with_file_extensions() {
|
|
// File extensions should be handled by string comparison
|
|
assert_eq!(cmp("mod-1.0.0.jar", "mod-1.0.0.jar"), Ordering::Equal);
|
|
assert!(cmp("mod-1.0.0.jar", "mod-1.0.1.jar").is_lt());
|
|
assert!(cmp("mod-1.0.1.jar", "mod-1.0.0.jar").is_gt());
|
|
}
|
|
|
|
#[test]
|
|
fn test_complex_versions() {
|
|
// Simple version comparison
|
|
assert!(cmp("sodium-1.0.0", "sodium-1.0.1").is_lt());
|
|
assert!(cmp("sodium-1.0.1", "sodium-1.0.0").is_gt());
|
|
|
|
// File extensions are NOT stripped - they're part of the version string
|
|
// "sodium-1.0.0.jar" < "sodium-1.0.0~1.jar" because '.' (48) < '~' (126)
|
|
assert!(cmp("sodium-1.0.0.jar", "sodium-1.0.0~1.jar").is_lt());
|
|
assert!(cmp("sodium-1.0.0~1.jar", "sodium-1.0.0.jar").is_gt());
|
|
|
|
assert!(cmp("fabric-0.15.0.1", "fabric-0.15.0.2").is_lt());
|
|
}
|
|
|
|
#[test]
|
|
fn test_min_max() {
|
|
assert_eq!(FlexVer("1.0.0").min(FlexVer("1.0.0")), FlexVer("1.0.0"));
|
|
assert_eq!(FlexVer("a1.2.6").min(FlexVer("b1.7.3")), FlexVer("a1.2.6"));
|
|
assert_eq!(FlexVer("b1.7.3").max(FlexVer("a1.2.6")), FlexVer("b1.7.3"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_commutative() {
|
|
// If a > b, then b < a
|
|
let pairs = vec![
|
|
("1.0.0", "1.0.1"),
|
|
("1.0.0-beta", "1.0.0"),
|
|
("1.0.0~1", "1.0.0~2"),
|
|
];
|
|
|
|
for (a, b) in pairs {
|
|
let ordering = compare(a, b);
|
|
let inverse = match ordering {
|
|
Ordering::Less => Ordering::Greater,
|
|
Ordering::Greater => Ordering::Less,
|
|
Ordering::Equal => Ordering::Equal,
|
|
};
|
|
assert_eq!(
|
|
compare(b, a),
|
|
inverse,
|
|
"Commutativity violation: {} vs {}",
|
|
a,
|
|
b
|
|
);
|
|
}
|
|
}
|
|
}
|