Skip to main content

arch_toolkit/deps/
version.rs

1//! Version comparison utilities for dependency resolution.
2//!
3//! This module provides epoch/pkgver/pkgrel comparison for dependency
4//! requirement checking and version analysis. It matches libalpm's conditional
5//! pkgrel behavior while preserving the resolver's existing pkgver segmentation.
6
7use std::cmp::Ordering;
8
9/// What: Split an Arch package version into epoch and remaining version text.
10///
11/// Inputs:
12/// - `version`: A version that may begin with a numeric `epoch:` prefix.
13///
14/// Output:
15/// - Returns the parsed epoch and the remaining `pkgver-pkgrel` text.
16///
17/// Details:
18/// - Missing or malformed epochs are treated as epoch zero without discarding version text.
19fn split_epoch(version: &str) -> (u64, &str) {
20    version
21        .split_once(':')
22        .and_then(|(epoch, rest)| {
23            (!rest.is_empty())
24                .then(|| epoch.parse::<u64>().ok().map(|epoch| (epoch, rest)))
25                .flatten()
26        })
27        .unwrap_or((0, version))
28}
29
30/// What: Split an Arch package version into pkgver and numeric pkgrel.
31///
32/// Inputs:
33/// - `version`: Version text without an epoch prefix.
34///
35/// Output:
36/// - Returns pkgver and an optional numeric pkgrel.
37///
38/// Details:
39/// - Only a final numeric `-pkgrel` suffix is split; textual prerelease suffixes remain pkgver.
40/// - A missing pkgrel remains `None` because libalpm compares pkgrel only when both versions
41///   declare one.
42fn split_pkgrel(version: &str) -> (&str, Option<&str>) {
43    version
44        .rsplit_once('-')
45        .and_then(|(pkgver, pkgrel)| {
46            (!pkgver.is_empty()
47                && !pkgrel.is_empty()
48                && pkgrel.chars().all(|character| character.is_ascii_digit()))
49            .then_some((pkgver, Some(pkgrel)))
50        })
51        .unwrap_or((version, None))
52}
53
54/// What: Normalize a version string to its pkgver component.
55///
56/// Inputs:
57/// - `version`: A version that may contain epoch and pkgrel components.
58///
59/// Output:
60/// - Returns pkgver without epoch or numeric pkgrel.
61///
62/// Details:
63/// - This helper is only used for major-version presentation logic; full comparison retains epoch
64///   and pkgrel through `compare_versions`.
65fn normalize_version(version: &str) -> String {
66    let (_, without_epoch) = split_epoch(version);
67    split_pkgrel(without_epoch).0.to_string()
68}
69
70/// What: Compare two pkgver-like strings by Arch-compatible numeric and text segments.
71///
72/// Inputs:
73/// - `left`: Left pkgver or pkgrel string.
74/// - `right`: Right pkgver or pkgrel string.
75///
76/// Output:
77/// - Returns lexical/numeric ordering for the first different segment.
78///
79/// Details:
80/// - Missing segments are zero and an empty text suffix sorts after a non-empty suffix, matching
81///   the existing resolver's prerelease behavior.
82fn compare_version_components(left: &str, right: &str) -> Ordering {
83    let left_parts = left.split(['.', '-']).collect::<Vec<_>>();
84    let right_parts = right.split(['.', '-']).collect::<Vec<_>>();
85    for index in 0..left_parts.len().max(right_parts.len()) {
86        let left_segment = left_parts.get(index).copied().unwrap_or("0");
87        let right_segment = right_parts.get(index).copied().unwrap_or("0");
88        let left_end = left_segment
89            .char_indices()
90            .find(|(_, character)| !character.is_ascii_digit())
91            .map_or(left_segment.len(), |(index, _)| index);
92        let right_end = right_segment
93            .char_indices()
94            .find(|(_, character)| !character.is_ascii_digit())
95            .map_or(right_segment.len(), |(index, _)| index);
96        let (left_number, left_suffix) = (&left_segment[..left_end], &left_segment[left_end..]);
97        let (right_number, right_suffix) =
98            (&right_segment[..right_end], &right_segment[right_end..]);
99        let ordering = match (left_number.parse::<u64>(), right_number.parse::<u64>()) {
100            (Ok(left_number), Ok(right_number)) => left_number.cmp(&right_number),
101            (Ok(_), Err(_)) => Ordering::Less,
102            (Err(_), Ok(_)) => Ordering::Greater,
103            (Err(_), Err(_)) => left_segment.cmp(right_segment),
104        };
105        if ordering != Ordering::Equal {
106            return ordering;
107        }
108        match (left_suffix.is_empty(), right_suffix.is_empty()) {
109            (true, false) => return Ordering::Greater,
110            (false, true) => return Ordering::Less,
111            (false, false) if left_suffix != right_suffix => return left_suffix.cmp(right_suffix),
112            _ => {}
113        }
114    }
115    Ordering::Equal
116}
117
118/// What: Compare two Arch package versions including epoch, pkgver, and pkgrel.
119///
120/// Inputs:
121/// - `a`: Left-hand package version.
122/// - `b`: Right-hand package version.
123///
124/// Output:
125/// - Returns `Ordering::Less`, `Ordering::Equal`, or `Ordering::Greater`.
126///
127/// Details:
128/// - Numeric epoch takes precedence, followed by pkgver. Numeric pkgrel is compared only when
129///   both operands declare one, matching libalpm/pacman dependency semantics.
130///
131/// # Example
132///
133/// ```
134/// use arch_toolkit::deps::compare_versions;
135/// use std::cmp::Ordering;
136///
137/// assert_eq!(compare_versions("1:1.2.3-2", "1:1.2.3-1"), Ordering::Greater);
138/// assert_eq!(compare_versions("2:1.0-1", "1:99.0-9"), Ordering::Greater);
139/// ```
140#[must_use]
141pub fn compare_versions(a: &str, b: &str) -> Ordering {
142    let (a_epoch, a_without_epoch) = split_epoch(a);
143    let (b_epoch, b_without_epoch) = split_epoch(b);
144    let epoch_ordering = a_epoch.cmp(&b_epoch);
145    if epoch_ordering != Ordering::Equal {
146        return epoch_ordering;
147    }
148    let (a_pkgver, a_pkgrel) = split_pkgrel(a_without_epoch);
149    let (b_pkgver, b_pkgrel) = split_pkgrel(b_without_epoch);
150    let pkgver_ordering = compare_version_components(a_pkgver, b_pkgver);
151    if pkgver_ordering != Ordering::Equal {
152        return pkgver_ordering;
153    }
154    match (a_pkgrel, b_pkgrel) {
155        (Some(a_pkgrel), Some(b_pkgrel)) => compare_version_components(a_pkgrel, b_pkgrel),
156        _ => Ordering::Equal,
157    }
158}
159
160/// What: Check if a version satisfies a version requirement.
161///
162/// Inputs:
163/// - `version`: Version string to check (e.g., "1.2.3").
164/// - `requirement`: Version requirement with operator (e.g., ">=1.2.0", "=2.0", "<3.0").
165///
166/// Output:
167/// - Returns `true` if the version satisfies the requirement.
168/// - Returns `false` if the version does not satisfy the requirement.
169/// - Returns `true` if requirement is empty or has no operator (no constraint).
170///
171/// Details:
172/// - Supports operators: `>=`, `<=`, `=`, `>`, `<`.
173/// - Uses `compare_versions()` for proper version comparison (not string comparison).
174/// - Matches libalpm by comparing pkgrel only when both operands declare one.
175/// - Empty or invalid requirement strings default to `true` (no constraint).
176///
177/// # Example
178///
179/// ```
180/// use arch_toolkit::deps::version_satisfies;
181///
182/// assert!(version_satisfies("2.0", ">=1.5"));
183/// assert!(!version_satisfies("1.0", ">=1.5"));
184/// assert!(version_satisfies("1.5", "<=1.5"));
185/// assert!(version_satisfies("1.6", ">1.5"));
186/// assert!(!version_satisfies("1.4", ">1.5"));
187/// assert!(version_satisfies("1.5", "=1.5"));
188/// assert!(!version_satisfies("1.6", "<1.5"));
189/// assert!(version_satisfies("2.0", "")); // Empty requirement = satisfied
190/// ```
191#[must_use]
192pub fn version_satisfies(version: &str, requirement: &str) -> bool {
193    // Empty requirement means no constraint
194    if requirement.is_empty() {
195        return true;
196    }
197
198    // Try to extract operator and version
199    let (op, req_version) = if let Some(rest) = requirement.strip_prefix(">=") {
200        (">=", rest)
201    } else if let Some(rest) = requirement.strip_prefix("<=") {
202        ("<=", rest)
203    } else if let Some(rest) = requirement.strip_prefix("=") {
204        ("=", rest)
205    } else if let Some(rest) = requirement.strip_prefix(">") {
206        (">", rest)
207    } else if let Some(rest) = requirement.strip_prefix("<") {
208        ("<", rest)
209    } else {
210        // No operator found, assume satisfied (no constraint)
211        return true;
212    };
213
214    // Use proper version comparison
215    let comparison = compare_versions(version, req_version);
216
217    match op {
218        ">=" => matches!(comparison, Ordering::Equal | Ordering::Greater),
219        "<=" => matches!(comparison, Ordering::Equal | Ordering::Less),
220        "=" => comparison == Ordering::Equal,
221        ">" => comparison == Ordering::Greater,
222        "<" => comparison == Ordering::Less,
223        _ => true, // Unknown operator, assume satisfied
224    }
225}
226
227/// What: Extract the leading numeric component from a version string.
228///
229/// Inputs:
230/// - `version`: Version string to parse (e.g., "1.2.3", "2.0.0-alpha").
231///
232/// Output:
233/// - Returns `Some(u64)` for the first numeric segment.
234/// - Returns `None` when the first segment cannot be parsed as a number.
235///
236/// Details:
237/// - Splits version on `.` and `-`, treating the first token as the major component.
238/// - Used by `is_major_version_bump()` to extract major version numbers.
239///
240/// # Example
241///
242/// ```
243/// use arch_toolkit::deps::extract_major_component;
244///
245/// assert_eq!(extract_major_component("1.2.3"), Some(1));
246/// assert_eq!(extract_major_component("2.0.0-alpha"), Some(2));
247/// assert_eq!(extract_major_component("10.5.2"), Some(10));
248/// assert_eq!(extract_major_component("alpha"), None);
249/// ```
250#[must_use]
251pub fn extract_major_component(version: &str) -> Option<u64> {
252    let normalized = normalize_version(version);
253    let token = normalized.split(['.', '-']).next()?;
254    token.parse::<u64>().ok()
255}
256
257/// What: Determine whether a new version constitutes a major version bump.
258///
259/// Inputs:
260/// - `old`: Currently installed version (e.g., "1.2.3").
261/// - `new`: Target version to check (e.g., "2.0.0").
262///
263/// Output:
264/// - Returns `true` when the major component increased.
265/// - Returns `false` otherwise (same major, minor/patch bump, or parsing failure).
266///
267/// Details:
268/// - Extracts the first numeric segment from both versions.
269/// - Compares major version numbers only.
270/// - Returns `false` if either version cannot be parsed.
271///
272/// # Example
273///
274/// ```
275/// use arch_toolkit::deps::is_major_version_bump;
276///
277/// assert!(is_major_version_bump("1.2.3", "2.0.0"));
278/// assert!(!is_major_version_bump("1.2.3", "1.3.0"));
279/// assert!(!is_major_version_bump("1.2.3", "1.2.4"));
280/// assert!(!is_major_version_bump("2.0.0", "1.9.9"));
281/// ```
282#[must_use]
283pub fn is_major_version_bump(old: &str, new: &str) -> bool {
284    match (extract_major_component(old), extract_major_component(new)) {
285        (Some(old_major), Some(new_major)) => new_major > old_major,
286        _ => false,
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_normalize_version() {
296        // Pkgrel stripping
297        assert_eq!(normalize_version("1.2.3-1"), "1.2.3");
298        assert_eq!(normalize_version("1.2.3-42"), "1.2.3");
299        assert_eq!(normalize_version("2.0.0-1"), "2.0.0");
300
301        // Text suffixes preserved
302        assert_eq!(normalize_version("1.2.3-alpha"), "1.2.3-alpha");
303        assert_eq!(normalize_version("1.2.3-beta1"), "1.2.3-beta1");
304
305        // No dash, unchanged
306        assert_eq!(normalize_version("1.2.3"), "1.2.3");
307        assert_eq!(normalize_version("2.0"), "2.0");
308    }
309
310    #[test]
311    fn test_compare_versions_basic() {
312        // Basic numeric comparisons
313        assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
314        assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
315        assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
316        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
317        assert_eq!(compare_versions("1.9.9", "2.0.0"), Ordering::Less);
318    }
319
320    #[test]
321    fn test_compare_versions_missing_segments() {
322        // Missing segments treated as "0"
323        assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Equal);
324        assert_eq!(compare_versions("1.2", "1.2.0"), Ordering::Equal);
325        assert_eq!(compare_versions("1", "1.0.0"), Ordering::Equal);
326        assert_eq!(compare_versions("1.2", "1.2.1"), Ordering::Less);
327    }
328
329    /// What: Verify pkgrel participates in full Arch package version ordering.
330    ///
331    /// Inputs:
332    /// - Fixed versions with equal pkgver and different numeric pkgrel values.
333    ///
334    /// Output:
335    /// - Confirms pkgrel breaks pkgver ties without overriding pkgver ordering.
336    ///
337    /// Details:
338    /// - Libalpm compares pkgrel only when both operands declare one.
339    #[test]
340    fn test_compare_versions_pkgrel() {
341        assert_eq!(compare_versions("1.2.3-1", "1.2.3-2"), Ordering::Less);
342        assert_eq!(compare_versions("1.2.3-1", "1.2.3"), Ordering::Equal);
343        assert_eq!(compare_versions("1.2.3", "1.2.3-1"), Ordering::Equal);
344        assert_eq!(compare_versions("1.2.3-10", "1.2.4-1"), Ordering::Less);
345    }
346
347    /// What: Verify epoch precedes pkgver and pkgrel ordering.
348    ///
349    /// Inputs:
350    /// - Fixed versions with different epochs and release values.
351    ///
352    /// Output:
353    /// - Confirms epoch-aware comparisons and requirements are deterministic.
354    ///
355    /// Details:
356    /// - A higher epoch wins even when its pkgver is lexically lower.
357    #[test]
358    fn test_compare_versions_epoch() {
359        assert_eq!(compare_versions("2:1.0-1", "1:99.0-9"), Ordering::Greater);
360        assert_eq!(compare_versions("1:1.0-1", "1.0-99"), Ordering::Greater);
361        assert!(version_satisfies("1:2.0-3", ">=1:2.0-3"));
362        assert!(!version_satisfies("1:2.0-2", ">=1:2.0-3"));
363    }
364
365    #[test]
366    fn test_compare_versions_text_segments() {
367        // Numeric < text (pacman behavior)
368        assert_eq!(compare_versions("1.2.3", "1.2.3alpha"), Ordering::Greater);
369        assert_eq!(compare_versions("1.2.3alpha", "1.2.3"), Ordering::Less);
370        assert_eq!(compare_versions("1.2.3alpha", "1.2.3beta"), Ordering::Less);
371        assert_eq!(
372            compare_versions("1.2.3beta", "1.2.3alpha"),
373            Ordering::Greater
374        );
375    }
376
377    #[test]
378    fn test_compare_versions_mixed() {
379        // Mixed numeric and text
380        assert_eq!(compare_versions("1.2.3", "1.2.4"), Ordering::Less);
381        assert_eq!(compare_versions("1.2.3alpha", "1.2.3beta"), Ordering::Less);
382        assert_eq!(compare_versions("1.2.3", "1.2.3alpha"), Ordering::Greater);
383        assert_eq!(compare_versions("1.2.3alpha", "1.2.4"), Ordering::Less);
384    }
385
386    #[test]
387    fn test_compare_versions_edge_cases() {
388        // Edge cases
389        assert_eq!(compare_versions("", ""), Ordering::Equal);
390        assert_eq!(compare_versions("0", "0.0.0"), Ordering::Equal);
391        assert_eq!(compare_versions("10.0.0", "9.9.9"), Ordering::Greater);
392        assert_eq!(compare_versions("1.10.0", "1.9.9"), Ordering::Greater);
393    }
394
395    #[test]
396    fn test_version_satisfies_greater_equal() {
397        assert!(version_satisfies("2.0", ">=1.5"));
398        assert!(version_satisfies("1.5", ">=1.5"));
399        assert!(!version_satisfies("1.0", ">=1.5"));
400        assert!(version_satisfies("1.5.1", ">=1.5"));
401        assert!(version_satisfies("2.0.0", ">=1.5.0"));
402    }
403
404    #[test]
405    fn test_version_satisfies_less_equal() {
406        assert!(version_satisfies("1.0", "<=1.5"));
407        assert!(version_satisfies("1.5", "<=1.5"));
408        assert!(!version_satisfies("2.0", "<=1.5"));
409        assert!(version_satisfies("1.4.9", "<=1.5"));
410    }
411
412    #[test]
413    fn test_version_satisfies_equal() {
414        assert!(version_satisfies("1.5", "=1.5"));
415        assert!(!version_satisfies("1.6", "=1.5"));
416        assert!(!version_satisfies("1.4", "=1.5"));
417        assert!(version_satisfies("1.5.0", "=1.5"));
418    }
419
420    #[test]
421    fn test_version_satisfies_greater() {
422        assert!(version_satisfies("1.6", ">1.5"));
423        assert!(!version_satisfies("1.5", ">1.5"));
424        assert!(!version_satisfies("1.4", ">1.5"));
425        assert!(version_satisfies("2.0", ">1.5"));
426    }
427
428    #[test]
429    fn test_version_satisfies_less() {
430        assert!(version_satisfies("1.4", "<1.5"));
431        assert!(!version_satisfies("1.5", "<1.5"));
432        assert!(!version_satisfies("1.6", "<1.5"));
433        assert!(version_satisfies("1.0", "<1.5"));
434    }
435
436    #[test]
437    fn test_version_satisfies_empty() {
438        // Empty requirement = no constraint = satisfied
439        assert!(version_satisfies("2.0", ""));
440        assert!(version_satisfies("1.0", ""));
441        assert!(version_satisfies("any-version", ""));
442    }
443
444    #[test]
445    fn test_version_satisfies_no_operator() {
446        // No operator = no constraint = satisfied
447        assert!(version_satisfies("2.0", "n/a"));
448        assert!(version_satisfies("1.0", "some-text"));
449    }
450
451    /// What: Verify pkgrel-aware dependency requirement checks.
452    ///
453    /// Inputs:
454    /// - Fixed package versions and requirements containing numeric release suffixes.
455    ///
456    /// Output:
457    /// - Confirms requirements retain release precision.
458    ///
459    /// Details:
460    /// - An absent pkgrel compares equal to a matching pkgver, while two explicit releases retain
461    ///   their ordering.
462    #[test]
463    fn test_version_satisfies_pkgrel() {
464        assert!(version_satisfies("1.2.3-1", "=1.2.3"));
465        assert!(version_satisfies("1.2.3", "<=1.2.3-1"));
466        assert!(version_satisfies("1.2.3-10", ">=1.2.3"));
467        assert!(!version_satisfies("1.2.3-5", "=1.2.3-1"));
468    }
469
470    #[test]
471    fn test_extract_major_component() {
472        assert_eq!(extract_major_component("1.2.3"), Some(1));
473        assert_eq!(extract_major_component("2.0.0"), Some(2));
474        assert_eq!(extract_major_component("10.5.2"), Some(10));
475        assert_eq!(extract_major_component("2.0.0-alpha"), Some(2));
476        assert_eq!(extract_major_component("1.2.3-1"), Some(1));
477        assert_eq!(extract_major_component("alpha"), None);
478        assert_eq!(extract_major_component(""), None);
479    }
480
481    #[test]
482    fn test_is_major_version_bump() {
483        // Major version increases
484        assert!(is_major_version_bump("1.2.3", "2.0.0"));
485        assert!(is_major_version_bump("1.0.0", "2.0.0"));
486        assert!(is_major_version_bump("0.9.9", "1.0.0"));
487
488        // Same major version
489        assert!(!is_major_version_bump("1.2.3", "1.3.0"));
490        assert!(!is_major_version_bump("1.2.3", "1.2.4"));
491        assert!(!is_major_version_bump("1.0.0", "1.9.9"));
492
493        // Downgrade
494        assert!(!is_major_version_bump("2.0.0", "1.9.9"));
495        assert!(!is_major_version_bump("2.0.0", "1.0.0"));
496
497        // Parsing failures
498        assert!(!is_major_version_bump("alpha", "1.0.0"));
499        assert!(!is_major_version_bump("1.0.0", "beta"));
500        assert!(!is_major_version_bump("", "1.0.0"));
501    }
502
503    #[test]
504    fn test_is_major_version_bump_pkgrel() {
505        // Pkgrel should not affect major version detection
506        assert!(is_major_version_bump("1.2.3-1", "2.0.0-1"));
507        assert!(!is_major_version_bump("1.2.3-1", "1.3.0-1"));
508    }
509}