diff --git a/src/utils/flexver.rs b/src/utils/flexver.rs new file mode 100644 index 0000000..7290776 --- /dev/null +++ b/src/utils/flexver.rs @@ -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: +// +// +// 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 + ); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 86947cb..5d52a4a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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;