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