Skip to main content

alpm_types/version/
pkg_generic.rs

1//! A flexible and generic package version.
2
3use std::{
4    cmp::Ordering,
5    fmt::{Display, Formatter},
6    str::FromStr,
7};
8
9use alpm_parsers::traits::{AlpmParser, ParserUntil, ParserUntilInclusive};
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use winnow::{
13    ModalResult,
14    Parser,
15    combinator::opt,
16    error::{ContextError, ErrMode, StrContext, StrContextValue},
17};
18
19use crate::{Epoch, Error, PackageRelease, PackageVersion};
20#[cfg(doc)]
21use crate::{FullVersion, MinimalVersion};
22
23/// A version of a package
24///
25/// A [`Version`] generically tracks an optional [`Epoch`], a [`PackageVersion`] and an optional
26/// [`PackageRelease`].
27/// See [alpm-package-version] for details on the format.
28///
29/// # Notes
30///
31/// - If [`PackageRelease`] should be mandatory for your use-case, use [`FullVersion`] instead.
32/// - If [`PackageRelease`] should not be used in your use-case, use [`MinimalVersion`] instead.
33///
34/// ## Examples
35/// ```
36/// use std::str::FromStr;
37///
38/// use alpm_types::{Epoch, PackageRelease, PackageVersion, Version};
39///
40/// # fn main() -> Result<(), alpm_types::Error> {
41///
42/// let version = Version::from_str("1:2-3")?;
43/// assert_eq!(version.epoch, Some(Epoch::from_str("1")?));
44/// assert_eq!(version.pkgver, PackageVersion::new("2".to_string())?);
45/// assert_eq!(version.pkgrel, Some(PackageRelease::new(3, None)));
46/// # Ok(())
47/// # }
48/// ```
49///
50/// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
51#[derive(Clone, Debug, Eq, PartialEq)]
52#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
53pub struct Version {
54    /// The version of the package
55    pub pkgver: PackageVersion,
56    /// The epoch of the package
57    pub epoch: Option<Epoch>,
58    /// The release of the package
59    pub pkgrel: Option<PackageRelease>,
60}
61
62impl Version {
63    /// Create a new Version
64    pub fn new(
65        pkgver: PackageVersion,
66        epoch: Option<Epoch>,
67        pkgrel: Option<PackageRelease>,
68    ) -> Self {
69        Version {
70            pkgver,
71            epoch,
72            pkgrel,
73        }
74    }
75
76    /// Compare two Versions and return a number
77    ///
78    /// The comparison algorithm is based on libalpm/ pacman's vercmp behavior.
79    ///
80    /// * `1` if `a` is newer than `b`
81    /// * `0` if `a` and `b` are considered to be the same version
82    /// * `-1` if `a` is older than `b`
83    ///
84    /// ## Examples
85    /// ```
86    /// use std::str::FromStr;
87    ///
88    /// use alpm_types::Version;
89    ///
90    /// # fn main() -> Result<(), alpm_types::Error> {
91    ///
92    /// assert_eq!(
93    ///     Version::vercmp(&Version::from_str("1.0.0")?, &Version::from_str("0.1.0")?),
94    ///     1
95    /// );
96    /// assert_eq!(
97    ///     Version::vercmp(&Version::from_str("1.0.0")?, &Version::from_str("1.0.0")?),
98    ///     0
99    /// );
100    /// assert_eq!(
101    ///     Version::vercmp(&Version::from_str("0.1.0")?, &Version::from_str("1.0.0")?),
102    ///     -1
103    /// );
104    /// # Ok(())
105    /// # }
106    /// ```
107    pub fn vercmp(a: &Version, b: &Version) -> i8 {
108        match a.cmp(b) {
109            Ordering::Less => -1,
110            Ordering::Equal => 0,
111            Ordering::Greater => 1,
112        }
113    }
114}
115
116impl AlpmParser for Version {
117    /// Recognizes a [`Version`] in a string slice.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if `input` does not begin with a valid [alpm-package-version].
122    ///
123    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
124    fn parser(input: &mut &str) -> ModalResult<Self> {
125        // Parse an optional epoch, which advances the cursor until after a ':', e.g.:
126        // "1:1.0.0-1" -> "1.0.0-1"
127        //
128        // If no epoch exists, the cursor does not move.
129        let epoch = opt(Epoch::parser_until_inclusive(":")).parse_next(input)?;
130
131        // Advance the parser until the next '-', e.g.:
132        // "1.0.0-1" -> "-1"
133        let pkgver = PackageVersion::parser.parse_next(input)?;
134
135        // Parse an optional PackageRelease, e.g.:
136        // "-1" -> ""
137        //
138        // If an `-` is found, the PackageRelease is expected and must exist
139        let delimiter = opt('-').parse_next(input)?;
140        let pkgrel = if delimiter.is_some() {
141            Some(PackageRelease::parser.parse_next(input)?)
142        } else {
143            None
144        };
145
146        Ok(Self {
147            epoch,
148            pkgver,
149            pkgrel,
150        })
151    }
152
153    fn delimiter_error_context<'a, O, P>(
154        parser: P,
155    ) -> impl Parser<&'a str, O, ErrMode<ContextError>>
156    where
157        P: Parser<&'a str, O, ErrMode<ContextError>>,
158    {
159        parser
160            .context(StrContext::Label("alpm-package-version"))
161            .context(StrContext::Expected(StrContextValue::Description(
162                "end of the version string",
163            )))
164    }
165}
166
167impl FromStr for Version {
168    type Err = Error;
169    /// Creates a new [`Version`] from a string slice.
170    ///
171    /// Delegates to [`Version::parser`].
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if [`Version::parser`] fails.
176    fn from_str(s: &str) -> Result<Version, Self::Err> {
177        Ok(Self::parser_until_eof.parse(s)?)
178    }
179}
180
181impl Display for Version {
182    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
183        if let Some(epoch) = self.epoch {
184            write!(fmt, "{epoch}:")?;
185        }
186
187        write!(fmt, "{}", self.pkgver)?;
188
189        if let Some(pkgrel) = &self.pkgrel {
190            write!(fmt, "-{pkgrel}")?;
191        }
192
193        Ok(())
194    }
195}
196
197impl Ord for Version {
198    fn cmp(&self, other: &Self) -> Ordering {
199        match (self.epoch, other.epoch) {
200            (Some(self_epoch), Some(other_epoch)) if self_epoch.cmp(&other_epoch).is_ne() => {
201                return self_epoch.cmp(&other_epoch);
202            }
203            (Some(_), None) => return Ordering::Greater,
204            (None, Some(_)) => return Ordering::Less,
205            (_, _) => {}
206        }
207
208        let pkgver_cmp = self.pkgver.cmp(&other.pkgver);
209        if pkgver_cmp.is_ne() {
210            return pkgver_cmp;
211        }
212
213        self.pkgrel.cmp(&other.pkgrel)
214    }
215}
216
217impl PartialOrd for Version {
218    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
219        Some(self.cmp(other))
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use insta::assert_snapshot;
226    use rstest::rstest;
227
228    use super::*;
229    use crate::configure_insta;
230
231    /// Ensure that valid version strings are parsed as expected.
232    #[rstest]
233    #[case(
234        "foo",
235        Version {
236            epoch: None,
237            pkgver: PackageVersion::new("foo".to_string()).unwrap(),
238            pkgrel: None
239        },
240    )]
241    #[case(
242        "1:foo-1",
243        Version {
244            pkgver: PackageVersion::new("foo".to_string()).unwrap(),
245            epoch: Some(Epoch::new(1)),
246            pkgrel: Some(PackageRelease::new(1, None))
247        },
248    )]
249    #[case(
250        "1:foo",
251        Version {
252            pkgver: PackageVersion::new("foo".to_string()).unwrap(),
253            epoch: Some(Epoch::new(1)),
254            pkgrel: None,
255        },
256    )]
257    #[case(
258        "foo-1",
259        Version {
260            pkgver: PackageVersion::new("foo".to_string()).unwrap(),
261            epoch: None,
262            pkgrel: Some(PackageRelease::new(1, None))
263        }
264    )]
265    // yes, this is valid
266    #[case(
267        ".-1",
268        Version {
269            pkgver: PackageVersion::new(".".to_string()).unwrap(),
270            epoch: None,
271            pkgrel: Some(PackageRelease::new(1, None))
272            }
273    )]
274    fn valid_version_from_string(#[case] version: &str, #[case] expected: Version) {
275        assert_eq!(
276            Version::from_str(version),
277            Ok(expected),
278            "Expected valid parsing for version {version}"
279        )
280    }
281
282    /// Ensure that invalid version strings produce the respective errors.
283    #[rstest]
284    #[case::two_pkgrel("1:foo-1-1")]
285    #[case::two_epoch("1:1:foo-1")]
286    #[case::no_version("")]
287    #[case::no_version(":")]
288    #[case::invalid_integer("-1foo:1")]
289    #[case::invalid_integer("1-foo:1")]
290    fn parse_error_in_version_from_string(#[case] version: &str) {
291        let Err(Error::ParseError(err_msg)) = Version::from_str(version) else {
292            panic!("parsing '{version}' did not fail as expected")
293        };
294
295        let (test_name, _guard) = configure_insta();
296        assert_snapshot!(test_name, err_msg.to_string());
297    }
298
299    /// Ensure that versions are properly serialized back to their string representation.
300    #[rstest]
301    #[case(Version::from_str("1:1-1").unwrap(), "1:1-1")]
302    #[case(Version::from_str("1-1").unwrap(), "1-1")]
303    #[case(Version::from_str("1").unwrap(), "1")]
304    #[case(Version::from_str("1:1").unwrap(), "1:1")]
305    fn version_to_string(#[case] version: Version, #[case] to_str: &str) {
306        assert_eq!(format!("{version}"), to_str);
307    }
308
309    #[rstest]
310    // Major version comparisons
311    #[case(Version::from_str("1"), Version::from_str("1"), Ordering::Equal)]
312    #[case(Version::from_str("1"), Version::from_str("2"), Ordering::Less)]
313    #[case(
314        Version::from_str("20220102"),
315        Version::from_str("20220202"),
316        Ordering::Less
317    )]
318    // Major vs Major.Minor
319    #[case(Version::from_str("1"), Version::from_str("1.1"), Ordering::Less)]
320    #[case(Version::from_str("01"), Version::from_str("1"), Ordering::Equal)]
321    #[case(Version::from_str("001a"), Version::from_str("1a"), Ordering::Equal)]
322    #[case(Version::from_str("a1a"), Version::from_str("a1b"), Ordering::Less)]
323    #[case(Version::from_str("foo"), Version::from_str("1.1"), Ordering::Less)]
324    // Major.Minor version comparisons
325    #[case(Version::from_str("1.0"), Version::from_str("1..0"), Ordering::Less)]
326    #[case(Version::from_str("1.1"), Version::from_str("1.1"), Ordering::Equal)]
327    #[case(Version::from_str("1.1"), Version::from_str("1.2"), Ordering::Less)]
328    #[case(Version::from_str("1..0"), Version::from_str("1..0"), Ordering::Equal)]
329    #[case(Version::from_str("1..0"), Version::from_str("1..1"), Ordering::Less)]
330    #[case(Version::from_str("1+0"), Version::from_str("1.0"), Ordering::Equal)]
331    #[case(Version::from_str("1+1"), Version::from_str("1+2"), Ordering::Less)]
332    // Major.Minor version comparisons with alphanumerics
333    #[case(Version::from_str("1.1"), Version::from_str("1.1.a"), Ordering::Less)]
334    #[case(Version::from_str("1.1"), Version::from_str("1.11a"), Ordering::Less)]
335    #[case(Version::from_str("1.1"), Version::from_str("1.1_a"), Ordering::Less)]
336    #[case(Version::from_str("1.1a"), Version::from_str("1.1"), Ordering::Less)]
337    #[case(Version::from_str("1.1a1"), Version::from_str("1.1"), Ordering::Less)]
338    #[case(Version::from_str("1.a"), Version::from_str("1.1"), Ordering::Less)]
339    #[case(Version::from_str("1.a"), Version::from_str("1.alpha"), Ordering::Less)]
340    #[case(Version::from_str("1.a1"), Version::from_str("1.1"), Ordering::Less)]
341    #[case(Version::from_str("1.a11"), Version::from_str("1.1"), Ordering::Less)]
342    #[case(Version::from_str("1.a1a"), Version::from_str("1.a1"), Ordering::Less)]
343    #[case(Version::from_str("1.alpha"), Version::from_str("1.b"), Ordering::Less)]
344    #[case(Version::from_str("a.1"), Version::from_str("1.1"), Ordering::Less)]
345    #[case(
346        Version::from_str("1.alpha0.0"),
347        Version::from_str("1.alpha.0"),
348        Ordering::Less
349    )]
350    // Major.Minor vs Major.Minor.Patch
351    #[case(Version::from_str("1.0"), Version::from_str("1.0."), Ordering::Less)]
352    // Major.Minor.Patch
353    #[case(Version::from_str("1.0."), Version::from_str("1.0.0"), Ordering::Less)]
354    #[case(Version::from_str("1.0.."), Version::from_str("1.0."), Ordering::Equal)]
355    #[case(
356        Version::from_str("1.0.alpha.0"),
357        Version::from_str("1.0."),
358        Ordering::Less
359    )]
360    #[case(
361        Version::from_str("1.a001a.1"),
362        Version::from_str("1.a1a.1"),
363        Ordering::Equal
364    )]
365    fn version_cmp(
366        #[case] version_a: Result<Version, Error>,
367        #[case] version_b: Result<Version, Error>,
368        #[case] expected: Ordering,
369    ) {
370        // Simply unwrap the Version as we expect all test strings to be valid.
371        let version_a = version_a.unwrap();
372        let version_b = version_b.unwrap();
373
374        // Derive the expected vercmp binary exitcode from the expected Ordering.
375        let vercmp_result = match &expected {
376            Ordering::Equal => 0,
377            Ordering::Greater => 1,
378            Ordering::Less => -1,
379        };
380
381        let ordering = version_a.cmp(&version_b);
382        assert_eq!(
383            ordering, expected,
384            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
385        );
386
387        assert_eq!(Version::vercmp(&version_a, &version_b), vercmp_result);
388
389        // If we find the `vercmp` binary, also run the test against the actual binary.
390        #[cfg(feature = "_compatibility_tests")]
391        {
392            let output = std::process::Command::new("vercmp")
393                .arg(version_a.to_string())
394                .arg(version_b.to_string())
395                .output()
396                .unwrap();
397            let result = String::from_utf8_lossy(&output.stdout);
398            assert_eq!(result.trim(), vercmp_result.to_string());
399        }
400
401        // Now check that the opposite holds true as well.
402        let reverse_vercmp_result = match &expected {
403            Ordering::Equal => 0,
404            Ordering::Greater => -1,
405            Ordering::Less => 1,
406        };
407        let reverse_expected = match &expected {
408            Ordering::Equal => Ordering::Equal,
409            Ordering::Greater => Ordering::Less,
410            Ordering::Less => Ordering::Greater,
411        };
412
413        let reverse_ordering = version_b.cmp(&version_a);
414        assert_eq!(
415            reverse_ordering, reverse_expected,
416            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
417        );
418
419        assert_eq!(
420            Version::vercmp(&version_b, &version_a),
421            reverse_vercmp_result
422        );
423    }
424}