Skip to main content

alpm_types/version/
pkg_full.rs

1//! The [alpm-package-version] form _full_ and _full with epoch_.
2//!
3//! [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
4
5use std::{
6    cmp::Ordering,
7    fmt::{Display, Formatter},
8    str::FromStr,
9};
10
11use alpm_parsers::prelude::*;
12#[cfg(feature = "serde")]
13use serde::{Deserialize, Serialize};
14use winnow::{combinator::opt, error::ErrMode};
15
16use crate::{Epoch, Error, PackageRelease, PackageVersion, Version};
17
18/// A package version with mandatory [`PackageRelease`].
19///
20/// Tracks an optional [`Epoch`], a [`PackageVersion`] and a [`PackageRelease`].
21/// This reflects the _full_ and _full with epoch_ forms of [alpm-package-version].
22///
23/// # Note
24///
25/// If [`PackageRelease`] should be optional for your use-case, use [`Version`] instead.
26///
27/// # Examples
28///
29/// ```
30/// use std::str::FromStr;
31///
32/// use alpm_types::FullVersion;
33///
34/// # fn main() -> testresult::TestResult {
35/// // A full version.
36/// let version = FullVersion::from_str("1.0.0-1")?;
37///
38/// // A full version with epoch.
39/// let version = FullVersion::from_str("1:1.0.0-1")?;
40/// # Ok(())
41/// # }
42/// ```
43///
44/// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
45#[derive(Clone, Debug, Eq, PartialEq)]
46#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
47pub struct FullVersion {
48    /// The version of the package
49    pub pkgver: PackageVersion,
50    /// The release of the package
51    pub pkgrel: PackageRelease,
52    /// The epoch of the package
53    pub epoch: Option<Epoch>,
54}
55
56impl FullVersion {
57    /// Creates a new [`FullVersion`].
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use alpm_types::{Epoch, FullVersion, PackageRelease, PackageVersion};
63    ///
64    /// # fn main() -> testresult::TestResult {
65    /// // A full version.
66    /// let version = FullVersion::new(
67    ///     PackageVersion::new("1.0.0".to_string())?,
68    ///     PackageRelease::new(1, None),
69    ///     None,
70    /// );
71    ///
72    /// // A full version with epoch.
73    /// let version = FullVersion::new(
74    ///     PackageVersion::new("1.0.0".to_string())?,
75    ///     PackageRelease::new(1, None),
76    ///     Some(Epoch::new(1)),
77    /// );
78    /// # Ok(())
79    /// # }
80    /// ```
81    pub fn new(pkgver: PackageVersion, pkgrel: PackageRelease, epoch: Option<Epoch>) -> Self {
82        Self {
83            pkgver,
84            pkgrel,
85            epoch,
86        }
87    }
88
89    /// Compares `self` to another [`FullVersion`] and returns a number.
90    ///
91    /// - `1` if `self` is newer than `other`
92    /// - `0` if `self` and `other` are equal
93    /// - `-1` if `self` is older than `other`
94    ///
95    /// This output behavior is based on the behavior of the [vercmp] tool.
96    ///
97    /// Delegates to [`FullVersion::cmp`] for comparison.
98    /// The rules and algorithms used for comparison are explained in more detail in
99    /// [alpm-package-version] and [alpm-pkgver].
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use std::str::FromStr;
105    ///
106    /// use alpm_types::FullVersion;
107    ///
108    /// # fn main() -> Result<(), alpm_types::Error> {
109    /// assert_eq!(
110    ///     FullVersion::from_str("1.0.0-1")?.vercmp(&FullVersion::from_str("0.1.0-1")?),
111    ///     1
112    /// );
113    /// assert_eq!(
114    ///     FullVersion::from_str("1.0.0-1")?.vercmp(&FullVersion::from_str("1.0.0-1")?),
115    ///     0
116    /// );
117    /// assert_eq!(
118    ///     FullVersion::from_str("0.1.0-1")?.vercmp(&FullVersion::from_str("1.0.0-1")?),
119    ///     -1
120    /// );
121    /// # Ok(())
122    /// # }
123    /// ```
124    ///
125    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
126    /// [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
127    /// [vercmp]: https://man.archlinux.org/man/vercmp.8
128    pub fn vercmp(&self, other: &FullVersion) -> i8 {
129        match self.cmp(other) {
130            Ordering::Less => -1,
131            Ordering::Equal => 0,
132            Ordering::Greater => 1,
133        }
134    }
135}
136
137impl AlpmParser for FullVersion {
138    /// Recognizes a [`FullVersion`] in a string slice.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if `input` does not begin with a valid  [alpm-package-version] (_full_ or
143    /// _full with epoch_).
144    ///
145    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
146    fn parser<'a>(input: &mut Input<'a>) -> PResult<'a, Self> {
147        let parser = move |input: &mut Input<'a>| -> PResult<'a, Self> {
148            // Parse an optional epoch, which advances the cursor until after a ':', e.g.:
149            // "1:1.0.0-1" -> "1.0.0-1"
150            //
151            // If no epoch exists, the cursor does not move.
152            let epoch = opt(Epoch::parser_until_inclusive(":")).parse_next(input)?;
153
154            // Advance the parser until the next '-', e.g.:
155            // "1.0.0-1" -> "-1"
156            let pkgver: PackageVersion = PackageVersion::parser.parse_next(input)?;
157
158            "-".expected_text("the '-' delimiter that divides the alpm-pkgver and alpm-pkgrel")
159                .parse_next(input)?;
160
161            // Consume the delimiter '-'
162            // "-1" -> "1"
163            // and parse everything until eof as a PackageRelease, e.g.:
164            // "1" -> ""
165            let pkgrel: PackageRelease = PackageRelease::parser.parse_next(input)?;
166
167            Ok(Self {
168                epoch,
169                pkgver,
170                pkgrel,
171            })
172        };
173
174        parser.layer("full alpm-package-version").parse_next(input)
175    }
176
177    fn delimiter_error_context<'a, O, P>(
178        parser: P,
179    ) -> impl Parser<Input<'a>, O, ErrMode<ParseStack<'a>>>
180    where
181        P: Parser<Input<'a>, O, ErrMode<ParseStack<'a>>>,
182    {
183        parser
184            .description(concat!(
185                "The package version must end with a valid package release.\n",
186                "I.e. a positive integer followed by an optional `.` and another positive integer",
187            ))
188            .layer("full alpm-package-version")
189    }
190}
191
192impl Display for FullVersion {
193    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
194        if let Some(epoch) = self.epoch {
195            write!(fmt, "{epoch}:")?;
196        }
197        write!(fmt, "{}-{}", self.pkgver, self.pkgrel)?;
198
199        Ok(())
200    }
201}
202
203impl FromStr for FullVersion {
204    type Err = Error;
205    /// Creates a new [`FullVersion`] from a string slice.
206    ///
207    /// Delegates to [`FullVersion::parser_until_eof`](ParserUntil::parser_until_eof).
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if [`Version::parser`] fails.
212    fn from_str(s: &str) -> Result<Self, Self::Err> {
213        Ok(Self::parser_until_eof.parse(Input::new(s))?)
214    }
215}
216
217impl Ord for FullVersion {
218    /// Compares `self` to another [`FullVersion`].
219    ///
220    /// The comparison rules and algorithms are explained in more detail in [alpm-package-version]
221    /// and [alpm-pkgver].
222    ///
223    /// # Examples
224    ///
225    /// ```
226    /// use std::{cmp::Ordering, str::FromStr};
227    ///
228    /// use alpm_types::FullVersion;
229    ///
230    /// # fn main() -> testresult::TestResult {
231    /// // Examples for "full"
232    /// let version_a = FullVersion::from_str("1.0.0-1")?;
233    /// let version_b = FullVersion::from_str("1.0.0-2")?;
234    /// assert_eq!(version_a.cmp(&version_b), Ordering::Less);
235    /// assert_eq!(version_b.cmp(&version_a), Ordering::Greater);
236    ///
237    /// let version_a = FullVersion::from_str("1.0.0-1")?;
238    /// let version_b = FullVersion::from_str("1.0.0-1")?;
239    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
240    ///
241    /// // Examples for "full with epoch"
242    /// let version_a = FullVersion::from_str("1:1.0.0-1")?;
243    /// let version_b = FullVersion::from_str("1.0.0-2")?;
244    /// assert_eq!(version_a.cmp(&version_b), Ordering::Greater);
245    /// assert_eq!(version_b.cmp(&version_a), Ordering::Less);
246    ///
247    /// let version_a = FullVersion::from_str("1:1.0.0-1")?;
248    /// let version_b = FullVersion::from_str("1:1.0.0-1")?;
249    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
250    /// # Ok(())
251    /// # }
252    /// ```
253    ///
254    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
255    /// [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
256    fn cmp(&self, other: &Self) -> Ordering {
257        match (self.epoch, other.epoch) {
258            (Some(self_epoch), Some(other_epoch)) if self_epoch.cmp(&other_epoch).is_ne() => {
259                return self_epoch.cmp(&other_epoch);
260            }
261            (Some(_), None) => return Ordering::Greater,
262            (None, Some(_)) => return Ordering::Less,
263            (_, _) => {}
264        }
265
266        let pkgver_cmp = self.pkgver.cmp(&other.pkgver);
267        if pkgver_cmp.is_ne() {
268            return pkgver_cmp;
269        }
270
271        self.pkgrel.cmp(&other.pkgrel)
272    }
273}
274
275impl PartialOrd for FullVersion {
276    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
277        Some(self.cmp(other))
278    }
279}
280
281impl TryFrom<Version> for FullVersion {
282    type Error = crate::Error;
283
284    /// Creates a [`FullVersion`] from a [`Version`].
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if `value.pkgrel` is [`None`].
289    fn try_from(value: Version) -> Result<Self, Self::Error> {
290        Ok(Self {
291            pkgver: value.pkgver,
292            pkgrel: value.pkgrel.ok_or(Error::MissingComponent {
293                component: "pkgrel",
294            })?,
295            epoch: value.epoch,
296        })
297    }
298}
299
300impl TryFrom<&Version> for FullVersion {
301    type Error = crate::Error;
302
303    /// Creates a [`FullVersion`] from a [`Version`] reference.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if `value.pkgrel` is [`None`].
308    fn try_from(value: &Version) -> Result<Self, Self::Error> {
309        Self::try_from(value.clone())
310    }
311}
312
313impl From<FullVersion> for Version {
314    /// Creates a [`Version`] from a [`FullVersion`].
315    fn from(value: FullVersion) -> Self {
316        Self {
317            pkgver: value.pkgver,
318            pkgrel: Some(value.pkgrel),
319            epoch: value.epoch,
320        }
321    }
322}
323
324impl From<&FullVersion> for Version {
325    /// Creates a [`Version`] from a [`FullVersion`] reference.
326    fn from(value: &FullVersion) -> Self {
327        Self::from(value.clone())
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use insta::assert_snapshot;
334    use log::{LevelFilter, debug};
335    use rstest::rstest;
336    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
337    use testresult::TestResult;
338
339    use super::*;
340    use crate::configure_insta;
341
342    /// Initialize a logger that shows trace messages on stderr.
343    fn init_logger() {
344        if TermLogger::init(
345            LevelFilter::Trace,
346            Config::default(),
347            TerminalMode::Stderr,
348            ColorChoice::Auto,
349        )
350        .is_err()
351        {
352            debug!("Not initializing another logger, as one is initialized already.");
353        }
354    }
355
356    /// Ensures that valid [`FullVersion`] strings are parsed successfully as expected.
357    #[rstest]
358    #[case::full_with_epoch(
359        "1:foo-1",
360        FullVersion {
361            pkgver: PackageVersion::from_str("foo")?,
362            epoch: Some(Epoch::from_str("1")?),
363            pkgrel: PackageRelease::from_str("1")?,
364        },
365    )]
366    #[case::full(
367        "foo-1",
368        FullVersion {
369            pkgver: PackageVersion::from_str("foo")?,
370            epoch: None,
371            pkgrel: PackageRelease::from_str("1")?
372        }
373    )]
374    fn valid_full_version_from_string(
375        #[case] version: &str,
376        #[case] expected: FullVersion,
377    ) -> TestResult {
378        init_logger();
379
380        assert_eq!(
381            FullVersion::from_str(version)?,
382            expected,
383            "Expected valid parsing for FullVersion {version}"
384        );
385
386        Ok(())
387    }
388
389    /// Ensures that invalid [`FullVersion`] strings lead to parse errors.
390    #[rstest]
391    #[case::two_pkgrel("1:foo-1-1")]
392    #[case::two_epoch("1:1:foo-1")]
393    #[case::empty_string("")]
394    #[case::colon(":")]
395    #[case::dot(".")]
396    #[case::no_pkgrel_with_epoch("1:1.0.0")]
397    #[case::no_pkgrel("1.0.0")]
398    #[case::no_pkgrel_dash_end("1.0.0-")]
399    #[case::starts_with_dash("-1foo:1")]
400    #[case::ends_with_colon("1-foo:")]
401    #[case::ends_with_colon_number("1-foo:1")]
402    fn parse_error_in_full_version_from_string(#[case] input: &str) {
403        init_logger();
404
405        let (test_name, _guard) = configure_insta();
406
407        let Err(Error::ParseError(err_msg)) = FullVersion::from_str(input) else {
408            panic!("'{input}' erroneously parsed as a FullVersion")
409        };
410
411        assert_snapshot!(test_name, err_msg.to_string());
412    }
413
414    /// Ensures that [`FullVersion`] can be created from a compatible [`Version`].
415    #[rstest]
416    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, FullVersion::from_str("1:1.0.0-1")?)]
417    #[case::full(Version::from_str("1.0.0-1")?, FullVersion::from_str("1.0.0-1")?)]
418    fn full_version_try_from_version_succeeds(
419        #[case] version: Version,
420        #[case] expected: FullVersion,
421    ) -> TestResult {
422        assert_eq!(FullVersion::try_from(&version)?, expected);
423        Ok(())
424    }
425
426    /// Ensures that [`FullVersion`] fails to be created from an incompatible [`Version`].
427    #[rstest]
428    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?)]
429    #[case::minimal(Version::from_str("1.0.0")?)]
430    fn full_version_try_from_version_fails_on_incompatible_version(
431        #[case] version: Version,
432    ) -> TestResult {
433        let Err(Error::MissingComponent {
434            component: "pkgrel",
435        }) = FullVersion::try_from(&version)
436        else {
437            panic!("Expected to fail with Error::MissingComponent");
438        };
439
440        Ok(())
441    }
442
443    /// Ensures that [`Version`] can be created from [`FullVersion`] (and [`FullVersion`]
444    /// reference).
445    #[rstest]
446    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, FullVersion::from_str("1:1.0.0-1")?)]
447    #[case::full(Version::from_str("1.0.0-1")?, FullVersion::from_str("1.0.0-1")?)]
448    fn version_from_full_version(
449        #[case] version: Version,
450        #[case] full_version: FullVersion,
451    ) -> TestResult {
452        assert_eq!(Version::from(&full_version), version);
453        Ok(())
454    }
455
456    /// Ensures that [`FullVersion`] is properly serialized back to its string representation.
457    #[rstest]
458    #[case::with_epoch("1:1-1")]
459    #[case::plain("1-1")]
460    fn full_version_to_string(#[case] input: &str) -> TestResult {
461        assert_eq!(format!("{}", FullVersion::from_str(input)?), input);
462        Ok(())
463    }
464
465    /// Ensures that [`FullVersion`]s can be compared.
466    ///
467    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
468    /// [`PackageRelease`].
469    #[rstest]
470    #[case::full_equal("1.0.0-1", "1.0.0-1", Ordering::Equal)]
471    #[case::full_less("1.0.0-1", "1.0.0-2", Ordering::Less)]
472    #[case::full_greater("1.0.0-2", "1.0.0-1", Ordering::Greater)]
473    #[case::full_with_epoch_equal("1:1.0.0-1", "1:1.0.0-1", Ordering::Equal)]
474    #[case::full_with_epoch_less("1.0.0-1", "1:1.0.0-1", Ordering::Less)]
475    #[case::full_with_epoch_less("1:1.0.0-1", "2:1.0.0-1", Ordering::Less)]
476    #[case::full_with_epoch_greater("1:1.0.0-1", "1.0.0-1", Ordering::Greater)]
477    #[case::full_with_epoch_greater("2:1.0.0-1", "1:1.0.0-1", Ordering::Greater)]
478    fn full_version_comparison(
479        #[case] version_a: &str,
480        #[case] version_b: &str,
481        #[case] expected: Ordering,
482    ) -> TestResult {
483        let version_a = FullVersion::from_str(version_a)?;
484        let version_b = FullVersion::from_str(version_b)?;
485
486        // Derive the expected vercmp binary exitcode from the expected Ordering.
487        let vercmp_result = match &expected {
488            Ordering::Equal => 0,
489            Ordering::Greater => 1,
490            Ordering::Less => -1,
491        };
492
493        let ordering = version_a.cmp(&version_b);
494        assert_eq!(
495            ordering, expected,
496            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
497        );
498
499        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
500
501        // If we find the `vercmp` binary, also run the test against the actual binary.
502        #[cfg(feature = "_compatibility_tests")]
503        {
504            let output = std::process::Command::new("vercmp")
505                .arg(version_a.to_string())
506                .arg(version_b.to_string())
507                .output()?;
508            let result = String::from_utf8_lossy(&output.stdout);
509            assert_eq!(result.trim(), vercmp_result.to_string());
510        }
511
512        // Now check that the opposite holds true as well.
513        let reverse_vercmp_result = match &expected {
514            Ordering::Equal => 0,
515            Ordering::Greater => -1,
516            Ordering::Less => 1,
517        };
518        let reverse_expected = match &expected {
519            Ordering::Equal => Ordering::Equal,
520            Ordering::Greater => Ordering::Less,
521            Ordering::Less => Ordering::Greater,
522        };
523
524        let reverse_ordering = version_b.cmp(&version_a);
525        assert_eq!(
526            reverse_ordering, reverse_expected,
527            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
528        );
529
530        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
531
532        Ok(())
533    }
534}