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