Skip to main content

alpm_types/version/
comparison.rs

1//! Comparison for [`PackageVersion`].
2//!
3//! This module implements the behavior of the [rpmvercmp algorithm in RPM version 4.8.1].
4//! The current implementation is based on the [alpm-pkgver] specification and explicitly
5//! handles undefined and undocumented behavior observed from `rpmvercmp`.
6//!
7//! The current version, including its improvements over the reference implementation are described
8//! and specified in detail in [alpm-pkgver].
9//!
10//! [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
11//! [rpmvercmp algorithm in RPM version 4.8.1]: https://github.com/rpm-software-management/rpm/blob/rpm-4.8.1-release/lib/rpmvercmp.c
12
13use std::{
14    cmp::Ordering,
15    iter::Peekable,
16    str::{CharIndices, Chars, FromStr},
17};
18
19use crate::PackageVersion;
20
21/// This enum represents a single segment in a version string.
22/// [`VersionSegment`]s are returned by the [`VersionSegments`] iterator, which is responsible for
23/// splitting a version string into its segments.
24///
25/// Version strings are split according to the following rules:
26///
27/// - Non-alphanumeric characters always count as delimiters (`.`, `-`, `$`, etc.).
28/// - There's no differentiation between delimiters represented by different characters (e.g. `'$$$'
29///   == '...' == '.$-'`).
30/// - Each segment contains the info about the amount of leading delimiters for that segment.
31///   Leading delimiters that directly follow after one another are grouped together. The length of
32///   the delimiters is important, as it plays a crucial role in the algorithm that determines which
33///   version is newer.
34///
35///   `1...a` would be represented as:
36///
37///   ```
38///   use alpm_types::VersionSegment::*;
39///   vec![
40///     Segment { text: "1", delimiter_count: 0},
41///     Segment { text: "a", delimiter_count: 3},
42///   ];
43///   ```
44/// - Alphanumeric strings are also split into individual sub-segments. This is done by walking over
45///   the string and splitting it every time a switch from alphabetic to numeric is detected or vice
46///   versa.
47///
48///   `1.1foo123.0` would be represented as:
49///
50///   ```
51///   use alpm_types::VersionSegment::*;
52///   vec![
53///     Segment { text: "1", delimiter_count: 0},
54///     Segment { text: "1", delimiter_count: 1},
55///     SubSegment { text: "foo" },
56///     SubSegment { text: "123" },
57///     Segment { text: "0", delimiter_count: 1},
58///   ];
59///   ```
60/// - Trailing delimiters are encoded as an empty string.
61///
62///   `1...` would be represented as:
63///
64///   ```
65///   use alpm_types::VersionSegment::*;
66///   vec![
67///     Segment { text: "1", delimiter_count: 0},
68///     Segment { text: "", delimiter_count: 3},
69///   ];
70///   ```
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub enum VersionSegment<'a> {
73    /// The start of a new segment.
74    /// If the current segment can be split into multiple sub-segments, this variant only contains
75    /// the **first** sub-segment.
76    ///
77    /// To figure out whether this is sub-segment, peek at the next element in the
78    /// [`VersionSegments`] iterator, whether it's a [`VersionSegment::SubSegment`].
79    Segment {
80        /// The string representation of this segment
81        text: &'a str,
82        /// The amount of leading delimiters that were found for this segment
83        delimiter_count: usize,
84    },
85    /// A sub-segment of a version string's segment.
86    ///
87    /// Note that the first sub-segment of a segment that can be split into sub-segments is
88    /// counterintuitively represented by [VersionSegment::Segment]. This implementation detail
89    /// is due to the way the comparison algorithm works, as it does not always differentiate
90    /// between segments and sub-segments.
91    SubSegment {
92        /// The string representation of this sub-segment
93        text: &'a str,
94    },
95}
96
97impl<'a> VersionSegment<'a> {
98    /// Returns the inner string slice independent of [`VersionSegment`] variant.
99    pub fn text(&self) -> &str {
100        match self {
101            VersionSegment::Segment { text, .. } | VersionSegment::SubSegment { text } => text,
102        }
103    }
104
105    /// Returns whether the inner string slice is empty, independent of [`VersionSegment`] variant
106    pub fn is_empty(&self) -> bool {
107        match self {
108            VersionSegment::Segment { text, .. } | VersionSegment::SubSegment { text } => {
109                text.is_empty()
110            }
111        }
112    }
113
114    /// Returns an iterator over the chars of the inner string slice.
115    pub fn chars(&self) -> Chars<'a> {
116        match self {
117            VersionSegment::Segment { text, .. } | VersionSegment::SubSegment { text } => {
118                text.chars()
119            }
120        }
121    }
122
123    /// Creates a type `T` from the inner string slice by relying on `T`'s [`FromStr::from_str`]
124    /// implementation.
125    pub fn parse<T: FromStr>(&self) -> Result<T, T::Err> {
126        match self {
127            VersionSegment::Segment { text, .. } | VersionSegment::SubSegment { text } => {
128                FromStr::from_str(text)
129            }
130        }
131    }
132
133    /// Compares the inner string slice with that of another [`VersionSegment`].
134    pub fn str_cmp(&self, other: &VersionSegment) -> Ordering {
135        match self {
136            VersionSegment::Segment { text, .. } | VersionSegment::SubSegment { text } => {
137                text.cmp(&other.text())
138            }
139        }
140    }
141}
142
143/// An [Iterator] over all [VersionSegment]s of an upstream version string.
144/// Check the documentation on [VersionSegment] to see how a string is split into segments.
145///
146/// Important note:
147/// Trailing delimiters will also produce a trailing [VersionSegment] with an empty string.
148///
149/// This iterator is capable of handling utf-8 strings.
150/// However, non alphanumeric chars are still interpreted as delimiters.
151#[derive(Debug)]
152pub struct VersionSegments<'a> {
153    /// The original version string. We need that reference so we can get some string
154    /// slices based on indices later on.
155    version: &'a str,
156    /// An iterator over the version's chars and their respective start byte's index.
157    version_chars: Peekable<CharIndices<'a>>,
158    /// Check if the cursor is currently in a segment.
159    /// This is necessary to detect whether the next segment should be a sub-segment or a new
160    /// segment.
161    in_segment: bool,
162}
163
164impl<'a> VersionSegments<'a> {
165    /// Create a new instance of a VersionSegments iterator.
166    pub fn new(version: &'a str) -> Self {
167        VersionSegments {
168            version,
169            version_chars: version.char_indices().peekable(),
170            in_segment: false,
171        }
172    }
173}
174
175impl<'a> Iterator for VersionSegments<'a> {
176    type Item = VersionSegment<'a>;
177
178    /// Get the next [VersionSegment] of this version string.
179    // TODO(cleanup): Investigate the arithmetic_side_effects
180    #[expect(clippy::arithmetic_side_effects)]
181    fn next(&mut self) -> Option<VersionSegment<'a>> {
182        // Used to track the number of delimiters the next segment is prefixed with.
183        let mut delimiter_count = 0;
184
185        // First up, get the delimiters out of the way.
186        // Peek at the next char, if it's a delimiter, consume it and increase the delimiter count.
187        while let Some((_, char)) = self.version_chars.peek() {
188            // An alphanumeric char indicates that we reached the next segment.
189            if char.is_alphanumeric() {
190                break;
191            }
192
193            self.version_chars.next();
194            delimiter_count += 1;
195
196            // As soon as we hit a delimiter, we know that a new segment is about to start.
197            self.in_segment = false;
198            continue;
199        }
200
201        // Get the next char. If there's no further char, we reached the end of the version string.
202        let Some((first_index, first_char)) = self.version_chars.next() else {
203            // We're at the end of the string and now have to differentiate between two cases:
204
205            // 1. There are no trailing delimiters. We can just return `None` as we truly reached
206            //    the end.
207            if delimiter_count == 0 {
208                return None;
209            }
210
211            // 2. There's no further segment, but there were some trailing delimiters. The
212            //    comparison algorithm considers this case which is why we have to somehow encode
213            //    it. We do so by returning an empty segment.
214            return Some(VersionSegment::Segment {
215                text: "",
216                delimiter_count,
217            });
218        };
219
220        // Cache the last valid char + index that was checked. We need this to
221        // calculate the offset in case the last char is a multi-byte UTF-8 char.
222        let mut last_char = first_char;
223        let mut last_char_index = first_index;
224
225        // The following section now handles the splitting of an alphanumeric string into its
226        // sub-segments. As described in the [VersionSegment] docs, the string needs to be split
227        // every time a switch from alphabetic to numeric or vice versa is detected.
228
229        let is_numeric = first_char.is_numeric();
230
231        if is_numeric {
232            // Go through chars until we hit a non-numeric char or reached the end of the string.
233            while let Some((index, next_char)) =
234                self.version_chars.next_if(|(_, peek)| peek.is_numeric())
235            {
236                last_char_index = index;
237                last_char = next_char;
238            }
239        } else {
240            // Go through chars until we hit a non-alphabetic char or reached the end of the string.
241            while let Some((index, next_char)) =
242                self.version_chars.next_if(|(_, peek)| peek.is_alphabetic())
243            {
244                last_char_index = index;
245                last_char = next_char;
246            }
247        }
248
249        // Create a subslice based on the indices of the first and last char.
250        // The last char might be multi-byte, which is why we add its length.
251        let segment_slice = &self.version[first_index..(last_char_index + last_char.len_utf8())];
252
253        if !self.in_segment {
254            // Any further segments should be sub-segments, unless we hit a delimiter in which
255            // case this variable will reset to false.
256            self.in_segment = true;
257            Some(VersionSegment::Segment {
258                text: segment_slice,
259                delimiter_count,
260            })
261        } else {
262            Some(VersionSegment::SubSegment {
263                text: segment_slice,
264            })
265        }
266    }
267}
268
269impl Ord for PackageVersion {
270    /// This block implements the logic to determine which of two package versions is newer or
271    /// whether they're considered equal.
272    ///
273    /// This logic is surprisingly complex as it mirrors the current C-alpmlib implementation's
274    /// behavior for backwards compatibility reasons.
275    /// <https://gitlab.archlinux.org/pacman/pacman/-/blob/a2d029388c7c206f5576456f91bfbea2dca98c96/lib/libalpm/version.c#L83-217>
276    #[expect(clippy::expect_used)]
277    fn cmp(&self, other: &Self) -> Ordering {
278        // Equal strings are considered equal versions.
279        if self.as_ref() == other.as_ref() {
280            return Ordering::Equal;
281        }
282
283        let mut self_segments = self.segments();
284        let mut other_segments = other.segments();
285
286        // Loop through both versions' segments and compare them.
287        loop {
288            // Try to get the next segments
289            let self_segment = self_segments.next();
290            let other_segment = other_segments.next();
291
292            // Make sure that there's a next segment for both versions.
293            let (self_segment, other_segment) = match (self_segment, other_segment) {
294                // Both segments exist, we continue after match.
295                (Some(self_seg), Some(other_seg)) => (self_seg, other_seg),
296
297                // Both versions reached their end and are thereby equal.
298                (None, None) => return Ordering::Equal,
299
300                // One version is longer than the other and both are equal until now.
301                //
302                // ## Case 1
303                //
304                // The longer version is one or more **segment**s longer.
305                // In this case, the longer version is always considered newer.
306                //   `1.0` > `1`
307                // `1.0.0` > `1.0`
308                // `1.0.a` > `1.0`
309                //     ⤷ New segment exists, thereby newer
310                //
311                // ## Case 2
312                //
313                // The current **segment** has one or more sub-segments and the next sub-segment is
314                // alphabetic.
315                // In this case, the shorter version is always newer.
316                // The reason for this is to handle pre-releases (e.g. alpha/beta).
317                // `1.0alpha` < `1.0`
318                // `1.0alpha.0` < `1.0`
319                // `1.0alpha12.0` < `1.0`
320                //     ⤷ Next sub-segment is alphabetic.
321                //
322                // ## Case 3
323                //
324                // The current **segment** has one or more sub-segments and the next sub-segment is
325                // numeric. In this case, the longer version is always newer.
326                // `1.alpha0` > `1.alpha`
327                // `1.alpha0.1` > `1.alpha`
328                //         ⤷ Next sub-segment is numeric.
329                (Some(seg), None) => {
330                    // If the current segment is the start of a segment, it's always considered
331                    // newer.
332                    let text = match seg {
333                        VersionSegment::Segment { .. } => return Ordering::Greater,
334                        VersionSegment::SubSegment { text } => text,
335                    };
336
337                    // If it's a sub-segment, we have to check for the edge-case explained above
338                    // If all chars are alphabetic, `self` is consider older.
339                    if !text.is_empty() && text.chars().all(char::is_alphabetic) {
340                        return Ordering::Less;
341                    }
342
343                    return Ordering::Greater;
344                }
345
346                // This is the same logic as above, but inverted.
347                (None, Some(seg)) => {
348                    let text = match seg {
349                        VersionSegment::Segment { .. } => return Ordering::Less,
350                        VersionSegment::SubSegment { text } => text,
351                    };
352                    if !text.is_empty() && text.chars().all(char::is_alphabetic) {
353                        return Ordering::Greater;
354                    }
355                    if !text.is_empty() && text.chars().all(char::is_alphabetic) {
356                        return Ordering::Greater;
357                    }
358
359                    return Ordering::Less;
360                }
361            };
362
363            // At this point, we have two sub-/segments.
364            //
365            // We start with the special case where one or both of the segments are empty.
366            // That means that the end of the version string has been reached, but there were one
367            // or more trailing delimiters, e.g.:
368            //
369            // `1.0.`
370            // `1.0...`
371            if other_segment.is_empty() && self_segment.is_empty() {
372                // Both reached the end of their version with a trailing delimiter.
373                // Counterintuitively, the trailing delimiter count is not considered and both
374                // versions are considered equal
375                // `1.0....` == `1.0.`
376                //       ⤷ Length of delimiters is ignored.
377                return Ordering::Equal;
378            } else if self_segment.is_empty() {
379                // Now we have to consider the special case where `other` is alphabetic.
380                // If that's the case, `self` will be considered newer, as the alphabetic string
381                // indicates a pre-release,
382                // `1.0.` > `1.0alpha0`
383                // `1.0.` > `1.0.alpha.0`
384                //                ⤷ Alphabetic sub-/segment and thereby always older.
385                //
386                // Also, we know that `other_segment` isn't empty at this point.
387                // It's noteworthy that this logic does not differentiated between segments and
388                // sub-segments.
389                if other_segment.chars().all(char::is_alphabetic) {
390                    return Ordering::Greater;
391                }
392
393                // In all other cases, `other` is newer.
394                // `1.0.` < `1.0.0`
395                // `1.0.` < `1.0.2.0`
396                return Ordering::Less;
397            } else if other_segment.is_empty() {
398                // Check docs above, as it's the same logic as above, just inverted.
399                if self_segment.chars().all(char::is_alphabetic) {
400                    return Ordering::Less;
401                }
402
403                return Ordering::Greater;
404            }
405
406            // We finally reached the end handling special cases when the version string ended.
407            // From now on, we know that we have two actual sub-/segments that might be prefixed by
408            // some delimiters.
409            //
410            // However, it is possible that one version has a segment and while the other has a
411            // sub-segment. This special case is what is handled next.
412            //
413            // We purposefully give up ownership of both segments.
414            // This is to ensure that following this match block, we finally only have to
415            // consider the actual text of the segments, as we'll know that both sub-/segments are
416            // of the same type.
417            let (self_text, other_text) = match (self_segment, other_segment) {
418                (
419                    VersionSegment::Segment {
420                        delimiter_count: self_count,
421                        text: self_text,
422                    },
423                    VersionSegment::Segment {
424                        delimiter_count: other_count,
425                        text: other_text,
426                    },
427                ) => {
428                    // Special case:
429                    // If one of the segments has more leading delimiters than the other, it is
430                    // always considered newer, no matter what follows after the delimiters.
431                    // `1..0.0` > `1.2.0`
432                    //    ⤷ Two delimiters, thereby always newer.
433                    // `1..0.0` < `1..2.0`
434                    //               ⤷ Same amount of delimiters, now `2 > 0`
435                    if self_count != other_count {
436                        return self_count.cmp(&other_count);
437                    }
438                    (self_text, other_text)
439                }
440                // If one is the start of a new segment, while the other is still a sub-segment,
441                // we can return early as a new segment always overrules a sub-segment.
442                // `1.alpha0.0` < `1.alpha.0`
443                //         ⤷ sub-segment  ⤷ segment
444                //         In the third iteration there's a sub-segment on the left side while
445                //         there's a segment on the right side.
446                (VersionSegment::Segment { .. }, VersionSegment::SubSegment { .. }) => {
447                    return Ordering::Greater;
448                }
449                (VersionSegment::SubSegment { .. }, VersionSegment::Segment { .. }) => {
450                    return Ordering::Less;
451                }
452                (
453                    VersionSegment::SubSegment { text: self_text },
454                    VersionSegment::SubSegment { text: other_text },
455                ) => (self_text, other_text),
456            };
457
458            // At this point, we know that we are dealing with two identical types of sub-/segments.
459            // Thereby, we now only have to compare the text of those sub-/segments.
460
461            // Check whether any of the texts are numeric.
462            // Numeric sub-/segments are always considered newer than non-numeric sub-/segments.
463            // E.g.: `1.0.0` > `1.foo.0`
464            //          ⤷ `0` vs `foo`.
465            //            `0` is numeric and therebynewer than a alphanumeric one.
466            let self_is_numeric = !self_text.is_empty() && self_text.chars().all(char::is_numeric);
467            let other_is_numeric =
468                !other_text.is_empty() && other_text.chars().all(char::is_numeric);
469
470            if self_is_numeric && !other_is_numeric {
471                return Ordering::Greater;
472            } else if !self_is_numeric && other_is_numeric {
473                return Ordering::Less;
474            } else if self_is_numeric && other_is_numeric {
475                // In case both are numeric, we do a number comparison.
476                //
477                // NOTE: We expect the strings to be parsed successfully, because we know that they
478                // only consist of digits.
479                //
480                // Preceding zeroes are to be ignored, which is automatically done by Rust's number
481                // parser.
482                // E.g. `1.0001.1` == `1.1.1`
483                //          ⤷ `000` is ignored in the comparison.
484                let ordering = self_text
485                    .parse::<usize>()
486                    .expect("a numeric section only contains numbers")
487                    .cmp(
488                        &other_text
489                            .parse::<usize>()
490                            .expect("a numeric section only contains numbers"),
491                    );
492
493                match ordering {
494                    Ordering::Less => return Ordering::Less,
495                    Ordering::Greater => return Ordering::Greater,
496                    // If both numbers are equal we check the next sub-/segment.
497                    Ordering::Equal => continue,
498                }
499            }
500
501            // At this point, we know that both sub-/segments are alphabetic.
502            // We do a simple string comparison to determine the newer version.
503            match self_text.cmp(other_text) {
504                Ordering::Less => return Ordering::Less,
505                Ordering::Greater => return Ordering::Greater,
506                // If the strings are equal, we check the next sub-/segment.
507                Ordering::Equal => continue,
508            }
509        }
510    }
511}
512
513impl PartialOrd for PackageVersion {
514    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
515        Some(self.cmp(other))
516    }
517}
518
519impl PartialEq for PackageVersion {
520    fn eq(&self, other: &Self) -> bool {
521        self.cmp(other).is_eq()
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use rstest::rstest;
528
529    use super::*;
530
531    #[rstest]
532    #[case("1.0.0", vec![
533        VersionSegment::Segment{ text:"1", delimiter_count: 0},
534        VersionSegment::Segment{ text:"0", delimiter_count: 1},
535        VersionSegment::Segment{ text:"0", delimiter_count: 1},
536    ])]
537    #[case("1..0", vec![
538        VersionSegment::Segment{ text:"1", delimiter_count: 0},
539        VersionSegment::Segment{ text:"0", delimiter_count: 2},
540    ])]
541    #[case("1.0.", vec![
542        VersionSegment::Segment{ text:"1", delimiter_count: 0},
543        VersionSegment::Segment{ text:"0", delimiter_count: 1},
544        VersionSegment::Segment{ text:"", delimiter_count: 1},
545    ])]
546    #[case("1..", vec![
547        VersionSegment::Segment{ text:"1", delimiter_count: 0},
548        VersionSegment::Segment{ text:"", delimiter_count: 2},
549    ])]
550    #[case("1...", vec![
551        VersionSegment::Segment{ text:"1", delimiter_count: 0},
552        VersionSegment::Segment{ text:"", delimiter_count: 3},
553    ])]
554    #[case("1.🗻lol.0", vec![
555        VersionSegment::Segment{ text:"1", delimiter_count: 0},
556        VersionSegment::Segment{ text:"lol", delimiter_count: 2},
557        VersionSegment::Segment{ text:"0", delimiter_count: 1},
558    ])]
559    #[case("1.🗻lol.", vec![
560        VersionSegment::Segment{ text:"1", delimiter_count: 0},
561        VersionSegment::Segment{ text:"lol", delimiter_count: 2},
562        VersionSegment::Segment{ text:"", delimiter_count: 1},
563    ])]
564    #[case("20220202", vec![
565        VersionSegment::Segment{ text:"20220202", delimiter_count: 0},
566    ])]
567    #[case("some_string", vec![
568        VersionSegment::Segment{ text:"some", delimiter_count: 0},
569        VersionSegment::Segment{ text:"string", delimiter_count: 1}
570    ])]
571    #[case("alpha7654numeric321", vec![
572        VersionSegment::Segment{ text:"alpha", delimiter_count: 0},
573        VersionSegment::SubSegment{ text:"7654"},
574        VersionSegment::SubSegment{ text:"numeric"},
575        VersionSegment::SubSegment{ text:"321"},
576    ])]
577    fn version_segment_iterator(
578        #[case] version: &str,
579        #[case] expected_segments: Vec<VersionSegment>,
580    ) {
581        let version = PackageVersion(version.to_string());
582        // Convert the simplified definition above into actual VersionSegment instances.
583        let mut segments_iter = version.segments();
584        let mut expected_iter = expected_segments.clone().into_iter();
585
586        // Iterate over both iterators.
587        // We do it manually to ensure that they both end at the same time.
588        loop {
589            let next_segment = segments_iter.next();
590            assert_eq!(
591                next_segment,
592                expected_iter.next(),
593                "Failed for segment {next_segment:?} in version string {version}:\nsegments: {:?}\n expected: {:?}",
594                version.segments().collect::<Vec<VersionSegment>>(),
595                expected_segments,
596            );
597            if next_segment.is_none() {
598                break;
599            }
600        }
601    }
602}