utils/flexver: add flexver comparator

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I79b8d3745a8754619f810de1bac8b66f6a6a6964
This commit is contained in:
raf 2026-02-28 23:41:17 +03:00
commit 1c08e00ccf
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
2 changed files with 328 additions and 0 deletions

326
src/utils/flexver.rs Normal file
View file

@ -0,0 +1,326 @@
// 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(&current, 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(&current, 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
);
}
}
}

View file

@ -1,5 +1,7 @@
pub mod flexver;
pub mod hash;
pub mod id;
pub use flexver::FlexVer;
pub use hash::verify_hash;
pub use id::generate_pakku_id;