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 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 insta::assert_snapshot;
337    use log::{LevelFilter, debug};
338    use rstest::rstest;
339    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
340    use testresult::TestResult;
341
342    use super::*;
343    use crate::configure_insta;
344
345    /// Initialize a logger that shows trace messages on stderr.
346    fn init_logger() {
347        if TermLogger::init(
348            LevelFilter::Trace,
349            Config::default(),
350            TerminalMode::Stderr,
351            ColorChoice::Auto,
352        )
353        .is_err()
354        {
355            debug!("Not initializing another logger, as one is initialized already.");
356        }
357    }
358
359    /// Ensures that valid [`FullVersion`] strings are parsed successfully as expected.
360    #[rstest]
361    #[case::full_with_epoch(
362        "1:foo-1",
363        FullVersion {
364            pkgver: PackageVersion::from_str("foo")?,
365            epoch: Some(Epoch::from_str("1")?),
366            pkgrel: PackageRelease::from_str("1")?,
367        },
368    )]
369    #[case::full(
370        "foo-1",
371        FullVersion {
372            pkgver: PackageVersion::from_str("foo")?,
373            epoch: None,
374            pkgrel: PackageRelease::from_str("1")?
375        }
376    )]
377    fn valid_full_version_from_string(
378        #[case] version: &str,
379        #[case] expected: FullVersion,
380    ) -> TestResult {
381        init_logger();
382
383        assert_eq!(
384            FullVersion::from_str(version),
385            Ok(expected),
386            "Expected valid parsing for FullVersion {version}"
387        );
388
389        Ok(())
390    }
391
392    /// Ensures that invalid [`FullVersion`] strings lead to parse errors.
393    #[rstest]
394    #[case::two_pkgrel("1:foo-1-1")]
395    #[case::two_epoch("1:1:foo-1")]
396    #[case::empty_string("")]
397    #[case::colon(":")]
398    #[case::dot(".")]
399    #[case::no_pkgrel_with_epoch("1:1.0.0")]
400    #[case::no_pkgrel("1.0.0")]
401    #[case::no_pkgrel_dash_end("1.0.0-")]
402    #[case::starts_with_dash("-1foo:1")]
403    #[case::ends_with_colon("1-foo:")]
404    #[case::ends_with_colon_number("1-foo:1")]
405    fn parse_error_in_full_version_from_string(#[case] input: &str) {
406        init_logger();
407
408        let Err(Error::ParseError(err_msg)) = FullVersion::from_str(input) else {
409            panic!("'{input}' erroneously parsed as a FullVersion")
410        };
411
412        let (test_name, _guard) = configure_insta();
413        assert_snapshot!(test_name, err_msg.to_string());
414    }
415
416    /// Ensures that [`FullVersion`] can be created from valid/compatible [`Version`] (and
417    /// [`Version`] reference) and fails otherwise.
418    #[rstest]
419    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, Ok(FullVersion::from_str("1:1.0.0-1")?))]
420    #[case::full(Version::from_str("1.0.0-1")?, Ok(FullVersion::from_str("1.0.0-1")?))]
421    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Err(Error::MissingComponent{component: "pkgrel"}))]
422    #[case::minimal(Version::from_str("1.0.0")?, Err(Error::MissingComponent{component: "pkgrel"}))]
423    fn full_version_try_from_version(
424        #[case] version: Version,
425        #[case] expected: Result<FullVersion, Error>,
426    ) -> TestResult {
427        assert_eq!(FullVersion::try_from(&version), expected);
428        assert_eq!(FullVersion::try_from(version), expected);
429        Ok(())
430    }
431
432    /// Ensures that [`Version`] can be created from [`FullVersion`] (and [`FullVersion`]
433    /// reference).
434    #[rstest]
435    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, FullVersion::from_str("1:1.0.0-1")?)]
436    #[case::full(Version::from_str("1.0.0-1")?, FullVersion::from_str("1.0.0-1")?)]
437    fn version_from_full_version(
438        #[case] version: Version,
439        #[case] full_version: FullVersion,
440    ) -> TestResult {
441        assert_eq!(Version::from(&full_version), version);
442        Ok(())
443    }
444
445    /// Ensures that [`FullVersion`] is properly serialized back to its string representation.
446    #[rstest]
447    #[case::with_epoch("1:1-1")]
448    #[case::plain("1-1")]
449    fn full_version_to_string(#[case] input: &str) -> TestResult {
450        assert_eq!(format!("{}", FullVersion::from_str(input)?), input);
451        Ok(())
452    }
453
454    /// Ensures that [`FullVersion`]s can be compared.
455    ///
456    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
457    /// [`PackageRelease`].
458    #[rstest]
459    #[case::full_equal("1.0.0-1", "1.0.0-1", Ordering::Equal)]
460    #[case::full_less("1.0.0-1", "1.0.0-2", Ordering::Less)]
461    #[case::full_greater("1.0.0-2", "1.0.0-1", Ordering::Greater)]
462    #[case::full_with_epoch_equal("1:1.0.0-1", "1:1.0.0-1", Ordering::Equal)]
463    #[case::full_with_epoch_less("1.0.0-1", "1:1.0.0-1", Ordering::Less)]
464    #[case::full_with_epoch_less("1:1.0.0-1", "2:1.0.0-1", Ordering::Less)]
465    #[case::full_with_epoch_greater("1:1.0.0-1", "1.0.0-1", Ordering::Greater)]
466    #[case::full_with_epoch_greater("2:1.0.0-1", "1:1.0.0-1", Ordering::Greater)]
467    fn full_version_comparison(
468        #[case] version_a: &str,
469        #[case] version_b: &str,
470        #[case] expected: Ordering,
471    ) -> TestResult {
472        let version_a = FullVersion::from_str(version_a)?;
473        let version_b = FullVersion::from_str(version_b)?;
474
475        // Derive the expected vercmp binary exitcode from the expected Ordering.
476        let vercmp_result = match &expected {
477            Ordering::Equal => 0,
478            Ordering::Greater => 1,
479            Ordering::Less => -1,
480        };
481
482        let ordering = version_a.cmp(&version_b);
483        assert_eq!(
484            ordering, expected,
485            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
486        );
487
488        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
489
490        // If we find the `vercmp` binary, also run the test against the actual binary.
491        #[cfg(feature = "compatibility_tests")]
492        {
493            let output = std::process::Command::new("vercmp")
494                .arg(version_a.to_string())
495                .arg(version_b.to_string())
496                .output()?;
497            let result = String::from_utf8_lossy(&output.stdout);
498            assert_eq!(result.trim(), vercmp_result.to_string());
499        }
500
501        // Now check that the opposite holds true as well.
502        let reverse_vercmp_result = match &expected {
503            Ordering::Equal => 0,
504            Ordering::Greater => -1,
505            Ordering::Less => 1,
506        };
507        let reverse_expected = match &expected {
508            Ordering::Equal => Ordering::Equal,
509            Ordering::Greater => Ordering::Less,
510            Ordering::Less => Ordering::Greater,
511        };
512
513        let reverse_ordering = version_b.cmp(&version_a);
514        assert_eq!(
515            reverse_ordering, reverse_expected,
516            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
517        );
518
519        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
520
521        Ok(())
522    }
523}