Skip to main content

alpm_types/version/
pkg_minimal.rs

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