// 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: // // // 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 { 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 = 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 { 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::().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, right: VecDeque, } impl Iterator for VersionComparisonIterator { type Item = (Option, Option); fn next(&mut self) -> Option { 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 { 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 ); } } }