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 serde::{Deserialize, Serialize};
12use winnow::{
13    ModalResult,
14    Parser,
15    combinator::{cut_err, eof, opt, preceded, terminated},
16    error::{StrContext, StrContextValue},
17    token::{take_till, take_until},
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.try_into()?)),
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    /// Recognizes a [`FullVersion`] in a string slice.
140    ///
141    /// Consumes all of its input.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if `input` is not a valid [alpm-package-version] (_full_ or _full with
146    /// epoch_).
147    ///
148    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
149    pub fn parser(input: &mut &str) -> ModalResult<Self> {
150        // Advance the parser until after a ':' if there is one, e.g.:
151        // "1:1.0.0-1" -> "1.0.0-1"
152        let epoch = opt(terminated(take_till(1.., ':'), ':').and_then(
153            // cut_err now that we've found a pattern with ':'
154            cut_err(Epoch::parser),
155        ))
156        .context(StrContext::Expected(StrContextValue::Description(
157            "followed by a ':'",
158        )))
159        .parse_next(input)?;
160
161        // Advance the parser until the next '-', e.g.:
162        // "1.0.0-1" -> "-1"
163        let pkgver: PackageVersion = cut_err(take_until(0.., "-"))
164            .context(StrContext::Expected(StrContextValue::Description(
165                "alpm-pkgver string, followed by a '-' and an alpm-pkgrel string",
166            )))
167            .take()
168            .and_then(cut_err(PackageVersion::parser))
169            .parse_next(input)?;
170
171        // Consume the delimiter '-'
172        // "-1" -> "1"
173        // and parse everything until eof as a PackageRelease, e.g.:
174        // "1" -> ""
175        let pkgrel: PackageRelease = preceded("-", cut_err(PackageRelease::parser))
176            .context(StrContext::Expected(StrContextValue::Description(
177                "alpm-pkgrel string",
178            )))
179            .parse_next(input)?;
180
181        // Ensure that there are no trailing chars left.
182        eof.context(StrContext::Expected(StrContextValue::Description(
183            "end of full alpm-package-version string",
184        )))
185        .parse_next(input)?;
186
187        Ok(Self {
188            epoch,
189            pkgver,
190            pkgrel,
191        })
192    }
193}
194
195impl Display for FullVersion {
196    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
197        if let Some(epoch) = self.epoch {
198            write!(fmt, "{epoch}:")?;
199        }
200        write!(fmt, "{}-{}", self.pkgver, self.pkgrel)?;
201
202        Ok(())
203    }
204}
205
206impl FromStr for FullVersion {
207    type Err = Error;
208    /// Creates a new [`FullVersion`] from a string slice.
209    ///
210    /// Delegates to [`FullVersion::parser`].
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if [`Version::parser`] fails.
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        Ok(Self::parser.parse(s)?)
217    }
218}
219
220impl Ord for FullVersion {
221    /// Compares `self` to another [`FullVersion`].
222    ///
223    /// The comparison rules and algorithms are explained in more detail in [alpm-package-version]
224    /// and [alpm-pkgver].
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use std::{cmp::Ordering, str::FromStr};
230    ///
231    /// use alpm_types::FullVersion;
232    ///
233    /// # fn main() -> testresult::TestResult {
234    /// // Examples for "full"
235    /// let version_a = FullVersion::from_str("1.0.0-1")?;
236    /// let version_b = FullVersion::from_str("1.0.0-2")?;
237    /// assert_eq!(version_a.cmp(&version_b), Ordering::Less);
238    /// assert_eq!(version_b.cmp(&version_a), Ordering::Greater);
239    ///
240    /// let version_a = FullVersion::from_str("1.0.0-1")?;
241    /// let version_b = FullVersion::from_str("1.0.0-1")?;
242    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
243    ///
244    /// // Examples for "full with epoch"
245    /// let version_a = FullVersion::from_str("1:1.0.0-1")?;
246    /// let version_b = FullVersion::from_str("1.0.0-2")?;
247    /// assert_eq!(version_a.cmp(&version_b), Ordering::Greater);
248    /// assert_eq!(version_b.cmp(&version_a), Ordering::Less);
249    ///
250    /// let version_a = FullVersion::from_str("1:1.0.0-1")?;
251    /// let version_b = FullVersion::from_str("1:1.0.0-1")?;
252    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
253    /// # Ok(())
254    /// # }
255    /// ```
256    ///
257    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
258    /// [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
259    fn cmp(&self, other: &Self) -> Ordering {
260        match (self.epoch, other.epoch) {
261            (Some(self_epoch), Some(other_epoch)) if self_epoch.cmp(&other_epoch).is_ne() => {
262                return self_epoch.cmp(&other_epoch);
263            }
264            (Some(_), None) => return Ordering::Greater,
265            (None, Some(_)) => return Ordering::Less,
266            (_, _) => {}
267        }
268
269        let pkgver_cmp = self.pkgver.cmp(&other.pkgver);
270        if pkgver_cmp.is_ne() {
271            return pkgver_cmp;
272        }
273
274        self.pkgrel.cmp(&other.pkgrel)
275    }
276}
277
278impl PartialOrd for FullVersion {
279    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
280        Some(self.cmp(other))
281    }
282}
283
284impl TryFrom<Version> for FullVersion {
285    type Error = crate::Error;
286
287    /// Creates a [`FullVersion`] from a [`Version`].
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if `value.pkgrel` is [`None`].
292    fn try_from(value: Version) -> Result<Self, Self::Error> {
293        Ok(Self {
294            pkgver: value.pkgver,
295            pkgrel: value.pkgrel.ok_or(Error::MissingComponent {
296                component: "pkgrel",
297            })?,
298            epoch: value.epoch,
299        })
300    }
301}
302
303impl TryFrom<&Version> for FullVersion {
304    type Error = crate::Error;
305
306    /// Creates a [`FullVersion`] from a [`Version`] reference.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if `value.pkgrel` is [`None`].
311    fn try_from(value: &Version) -> Result<Self, Self::Error> {
312        Self::try_from(value.clone())
313    }
314}
315
316impl From<FullVersion> for Version {
317    /// Creates a [`Version`] from a [`FullVersion`].
318    fn from(value: FullVersion) -> Self {
319        Self {
320            pkgver: value.pkgver,
321            pkgrel: Some(value.pkgrel),
322            epoch: value.epoch,
323        }
324    }
325}
326
327impl From<&FullVersion> for Version {
328    /// Creates a [`Version`] from a [`FullVersion`] reference.
329    fn from(value: &FullVersion) -> Self {
330        Self::from(value.clone())
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use log::{LevelFilter, debug};
337    use rstest::rstest;
338    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
339    use testresult::TestResult;
340
341    use super::*;
342
343    /// Initialize a logger that shows trace messages on stderr.
344    fn init_logger() {
345        if TermLogger::init(
346            LevelFilter::Trace,
347            Config::default(),
348            TerminalMode::Stderr,
349            ColorChoice::Auto,
350        )
351        .is_err()
352        {
353            debug!("Not initializing another logger, as one is initialized already.");
354        }
355    }
356
357    /// Ensures that valid [`FullVersion`] strings are parsed successfully as expected.
358    #[rstest]
359    #[case::full_with_epoch(
360        "1:foo-1",
361        FullVersion {
362            pkgver: PackageVersion::from_str("foo")?,
363            epoch: Some(Epoch::from_str("1")?),
364            pkgrel: PackageRelease::from_str("1")?,
365        },
366    )]
367    #[case::full(
368        "foo-1",
369        FullVersion {
370            pkgver: PackageVersion::from_str("foo")?,
371            epoch: None,
372            pkgrel: PackageRelease::from_str("1")?
373        }
374    )]
375    fn valid_full_version_from_string(
376        #[case] version: &str,
377        #[case] expected: FullVersion,
378    ) -> TestResult {
379        init_logger();
380
381        assert_eq!(
382            FullVersion::from_str(version),
383            Ok(expected),
384            "Expected valid parsing for FullVersion {version}"
385        );
386
387        Ok(())
388    }
389
390    /// Ensures that invalid [`FullVersion`] strings lead to parse errors.
391    #[rstest]
392    #[case::two_pkgrel("1:foo-1-1", "expected end of package release value")]
393    #[case::two_epoch("1:1:foo-1", "invalid pkgver character")]
394    #[case::empty_string(
395        "",
396        "expected alpm-pkgver string, followed by a '-' and an alpm-pkgrel string"
397    )]
398    #[case::colon(
399        ":",
400        "expected alpm-pkgver string, followed by a '-' and an alpm-pkgrel string"
401    )]
402    #[case::dot(
403        ".",
404        "expected alpm-pkgver string, followed by a '-' and an alpm-pkgrel string"
405    )]
406    #[case::no_pkgrel_with_epoch(
407        "1:1.0.0",
408        "expected alpm-pkgver string, followed by a '-' and an alpm-pkgrel string"
409    )]
410    #[case::no_pkgrel(
411        "1.0.0",
412        "expected alpm-pkgver string, followed by a '-' and an alpm-pkgrel string"
413    )]
414    #[case::no_pkgrel_dash_end(
415        "1.0.0-",
416        "invalid package release\nexpected positive decimal integer, alpm-pkgrel string"
417    )]
418    #[case::starts_with_dash(
419        "-1foo:1",
420        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
421    )]
422    #[case::ends_with_colon(
423        "1-foo:",
424        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
425    )]
426    #[case::ends_with_colon_number(
427        "1-foo:1",
428        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
429    )]
430    fn parse_error_in_full_version_from_string(#[case] version: &str, #[case] err_snippet: &str) {
431        init_logger();
432
433        let Err(Error::ParseError(err_msg)) = FullVersion::from_str(version) else {
434            panic!("parsing '{version}' as FullVersion did not fail as expected")
435        };
436        assert!(
437            err_msg.contains(err_snippet),
438            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
439        );
440    }
441
442    /// Ensures that [`FullVersion`] can be created from valid/compatible [`Version`] (and
443    /// [`Version`] reference) and fails otherwise.
444    #[rstest]
445    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, Ok(FullVersion::from_str("1:1.0.0-1")?))]
446    #[case::full(Version::from_str("1.0.0-1")?, Ok(FullVersion::from_str("1.0.0-1")?))]
447    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Err(Error::MissingComponent{component: "pkgrel"}))]
448    #[case::minimal(Version::from_str("1.0.0")?, Err(Error::MissingComponent{component: "pkgrel"}))]
449    fn full_version_try_from_version(
450        #[case] version: Version,
451        #[case] expected: Result<FullVersion, Error>,
452    ) -> TestResult {
453        assert_eq!(FullVersion::try_from(&version), expected);
454        assert_eq!(FullVersion::try_from(version), expected);
455        Ok(())
456    }
457
458    /// Ensures that [`Version`] can be created from [`FullVersion`] (and [`FullVersion`]
459    /// reference).
460    #[rstest]
461    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, FullVersion::from_str("1:1.0.0-1")?)]
462    #[case::full(Version::from_str("1.0.0-1")?, FullVersion::from_str("1.0.0-1")?)]
463    fn version_from_full_version(
464        #[case] version: Version,
465        #[case] full_version: FullVersion,
466    ) -> TestResult {
467        assert_eq!(Version::from(&full_version), version);
468        Ok(())
469    }
470
471    /// Ensures that [`FullVersion`] is properly serialized back to its string representation.
472    #[rstest]
473    #[case::with_epoch("1:1-1")]
474    #[case::plain("1-1")]
475    fn full_version_to_string(#[case] input: &str) -> TestResult {
476        assert_eq!(format!("{}", FullVersion::from_str(input)?), input);
477        Ok(())
478    }
479
480    /// Ensures that [`FullVersion`]s can be compared.
481    ///
482    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
483    /// [`PackageRelease`].
484    #[rstest]
485    #[case::full_equal("1.0.0-1", "1.0.0-1", Ordering::Equal)]
486    #[case::full_less("1.0.0-1", "1.0.0-2", Ordering::Less)]
487    #[case::full_greater("1.0.0-2", "1.0.0-1", Ordering::Greater)]
488    #[case::full_with_epoch_equal("1:1.0.0-1", "1:1.0.0-1", Ordering::Equal)]
489    #[case::full_with_epoch_less("1.0.0-1", "1:1.0.0-1", Ordering::Less)]
490    #[case::full_with_epoch_less("1:1.0.0-1", "2:1.0.0-1", Ordering::Less)]
491    #[case::full_with_epoch_greater("1:1.0.0-1", "1.0.0-1", Ordering::Greater)]
492    #[case::full_with_epoch_greater("2:1.0.0-1", "1:1.0.0-1", Ordering::Greater)]
493    fn full_version_comparison(
494        #[case] version_a: &str,
495        #[case] version_b: &str,
496        #[case] expected: Ordering,
497    ) -> TestResult {
498        let version_a = FullVersion::from_str(version_a)?;
499        let version_b = FullVersion::from_str(version_b)?;
500
501        // Derive the expected vercmp binary exitcode from the expected Ordering.
502        let vercmp_result = match &expected {
503            Ordering::Equal => 0,
504            Ordering::Greater => 1,
505            Ordering::Less => -1,
506        };
507
508        let ordering = version_a.cmp(&version_b);
509        assert_eq!(
510            ordering, expected,
511            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
512        );
513
514        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
515
516        // If we find the `vercmp` binary, also run the test against the actual binary.
517        #[cfg(feature = "compatibility_tests")]
518        {
519            let output = std::process::Command::new("vercmp")
520                .arg(version_a.to_string())
521                .arg(version_b.to_string())
522                .output()?;
523            let result = String::from_utf8_lossy(&output.stdout);
524            assert_eq!(result.trim(), vercmp_result.to_string());
525        }
526
527        // Now check that the opposite holds true as well.
528        let reverse_vercmp_result = match &expected {
529            Ordering::Equal => 0,
530            Ordering::Greater => -1,
531            Ordering::Less => 1,
532        };
533        let reverse_expected = match &expected {
534            Ordering::Equal => Ordering::Equal,
535            Ordering::Greater => Ordering::Less,
536            Ordering::Less => Ordering::Greater,
537        };
538
539        let reverse_ordering = version_b.cmp(&version_a);
540        assert_eq!(
541            reverse_ordering, reverse_expected,
542            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
543        );
544
545        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
546
547        Ok(())
548    }
549}