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