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 serde::{Deserialize, Serialize};
12use winnow::{
13    ModalResult,
14    Parser,
15    combinator::{cut_err, eof, opt, terminated},
16    error::{StrContext, StrContextValue},
17    token::take_till,
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, Deserialize, Eq, PartialEq, Serialize)]
53pub struct MinimalVersion {
54    /// The version of the package
55    pub pkgver: PackageVersion,
56    /// The epoch of the package
57    pub epoch: Option<Epoch>,
58}
59
60impl MinimalVersion {
61    /// Creates a new [`MinimalVersion`].
62    ///
63    /// # Examples
64    ///
65    /// ```
66    /// use alpm_types::{Epoch, MinimalVersion, PackageVersion};
67    ///
68    /// # fn main() -> testresult::TestResult {
69    /// // A minimal version.
70    /// let version = MinimalVersion::new(PackageVersion::new("1.0.0".to_string())?, None);
71    ///
72    /// // A minimal version with epoch.
73    /// let version = MinimalVersion::new(
74    ///     PackageVersion::new("1.0.0".to_string())?,
75    ///     Some(Epoch::new(1.try_into()?)),
76    /// );
77    /// # Ok(())
78    /// # }
79    /// ```
80    pub fn new(pkgver: PackageVersion, epoch: Option<Epoch>) -> Self {
81        Self { pkgver, epoch }
82    }
83
84    /// Compares `self` to another [`MinimalVersion`] and returns a number.
85    ///
86    /// - `1` if `self` is newer than `other`
87    /// - `0` if `self` and `other` are equal
88    /// - `-1` if `self` is older than `other`
89    ///
90    /// This output behavior is based on the behavior of the [vercmp] tool.
91    ///
92    /// Delegates to [`MinimalVersion::cmp`] for comparison.
93    /// The rules and algorithms used for comparison are explained in more detail in
94    /// [alpm-package-version] and [alpm-pkgver].
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use std::str::FromStr;
100    ///
101    /// use alpm_types::MinimalVersion;
102    ///
103    /// # fn main() -> Result<(), alpm_types::Error> {
104    /// assert_eq!(
105    ///     MinimalVersion::from_str("1.0.0")?.vercmp(&MinimalVersion::from_str("0.1.0")?),
106    ///     1
107    /// );
108    /// assert_eq!(
109    ///     MinimalVersion::from_str("1.0.0")?.vercmp(&MinimalVersion::from_str("1.0.0")?),
110    ///     0
111    /// );
112    /// assert_eq!(
113    ///     MinimalVersion::from_str("0.1.0")?.vercmp(&MinimalVersion::from_str("1.0.0")?),
114    ///     -1
115    /// );
116    /// # Ok(())
117    /// # }
118    /// ```
119    ///
120    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
121    /// [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
122    /// [vercmp]: https://man.archlinux.org/man/vercmp.8
123    pub fn vercmp(&self, other: &MinimalVersion) -> i8 {
124        match self.cmp(other) {
125            Ordering::Less => -1,
126            Ordering::Equal => 0,
127            Ordering::Greater => 1,
128        }
129    }
130
131    /// Recognizes a [`MinimalVersion`] in a string slice.
132    ///
133    /// Consumes all of its input.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if `input` is not a valid [alpm-package-version] (_full_ or _full with
138    /// epoch_).
139    ///
140    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
141    pub fn parser(input: &mut &str) -> ModalResult<Self> {
142        // Advance the parser until after a ':' if there is one, e.g.:
143        // "1:1.0.0-1" -> "1.0.0-1"
144        let epoch = opt(terminated(take_till(1.., ':'), ':').and_then(
145            // cut_err now that we've found a pattern with ':'
146            cut_err(Epoch::parser),
147        ))
148        .context(StrContext::Expected(StrContextValue::Description(
149            "followed by a ':'",
150        )))
151        .parse_next(input)?;
152
153        // Advance the parser until the next '-', e.g.:
154        // "1.0.0-1" -> "-1"
155        let pkgver: PackageVersion = cut_err(PackageVersion::parser)
156            .context(StrContext::Expected(StrContextValue::Description(
157                "alpm-pkgver string",
158            )))
159            .parse_next(input)?;
160
161        // Ensure that there are no trailing chars left.
162        eof.context(StrContext::Expected(StrContextValue::Description(
163            "end of full alpm-package-version string",
164        )))
165        .parse_next(input)?;
166
167        Ok(Self { epoch, pkgver })
168    }
169}
170
171impl Display for MinimalVersion {
172    fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
173        if let Some(epoch) = self.epoch {
174            write!(fmt, "{epoch}:")?;
175        }
176        write!(fmt, "{}", self.pkgver)?;
177
178        Ok(())
179    }
180}
181
182impl FromStr for MinimalVersion {
183    type Err = Error;
184    /// Creates a new [`MinimalVersion`] from a string slice.
185    ///
186    /// Delegates to [`MinimalVersion::parser`].
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if [`Version::parser`] fails.
191    fn from_str(s: &str) -> Result<Self, Self::Err> {
192        Ok(Self::parser.parse(s)?)
193    }
194}
195
196impl Ord for MinimalVersion {
197    /// Compares `self` to another [`MinimalVersion`].
198    ///
199    /// The comparison rules and algorithms are explained in more detail in [alpm-package-version]
200    /// and [alpm-pkgver].
201    ///
202    /// # Examples
203    ///
204    /// ```
205    /// use std::{cmp::Ordering, str::FromStr};
206    ///
207    /// use alpm_types::MinimalVersion;
208    ///
209    /// # fn main() -> testresult::TestResult {
210    /// // Examples for "minimal"
211    /// let version_a = MinimalVersion::from_str("0.1.0")?;
212    /// let version_b = MinimalVersion::from_str("1.0.0")?;
213    /// assert_eq!(version_a.cmp(&version_b), Ordering::Less);
214    /// assert_eq!(version_b.cmp(&version_a), Ordering::Greater);
215    ///
216    /// let version_a = MinimalVersion::from_str("1.0.0")?;
217    /// let version_b = MinimalVersion::from_str("1.0.0")?;
218    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
219    ///
220    /// // Examples for "minimal with epoch"
221    /// let version_a = MinimalVersion::from_str("1:1.0.0")?;
222    /// let version_b = MinimalVersion::from_str("1.0.0")?;
223    /// assert_eq!(version_a.cmp(&version_b), Ordering::Greater);
224    /// assert_eq!(version_b.cmp(&version_a), Ordering::Less);
225    ///
226    /// let version_a = MinimalVersion::from_str("1:1.0.0")?;
227    /// let version_b = MinimalVersion::from_str("1:1.0.0")?;
228    /// assert_eq!(version_a.cmp(&version_b), Ordering::Equal);
229    /// # Ok(())
230    /// # }
231    /// ```
232    ///
233    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
234    /// [alpm-pkgver]: https://alpm.archlinux.page/specifications/alpm-pkgver.7.html
235    fn cmp(&self, other: &Self) -> Ordering {
236        match (self.epoch, other.epoch) {
237            (Some(self_epoch), Some(other_epoch)) if self_epoch.cmp(&other_epoch).is_ne() => {
238                return self_epoch.cmp(&other_epoch);
239            }
240            (Some(_), None) => return Ordering::Greater,
241            (None, Some(_)) => return Ordering::Less,
242            (_, _) => {}
243        }
244
245        self.pkgver.cmp(&other.pkgver)
246    }
247}
248
249impl PartialOrd for MinimalVersion {
250    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
251        Some(self.cmp(other))
252    }
253}
254
255impl TryFrom<Version> for MinimalVersion {
256    type Error = crate::Error;
257
258    /// Creates a [`MinimalVersion`] from a [`Version`].
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if `value.pkgrel` is [`None`].
263    fn try_from(value: Version) -> Result<Self, Self::Error> {
264        if value.pkgrel.is_some() {
265            Err(Error::InvalidComponent {
266                component: "pkgrel",
267                context: "converting a full alpm-package-version to a minimal alpm-package-version",
268            })
269        } else {
270            Ok(Self {
271                pkgver: value.pkgver,
272                epoch: value.epoch,
273            })
274        }
275    }
276}
277
278impl TryFrom<&Version> for MinimalVersion {
279    type Error = crate::Error;
280
281    /// Creates a [`MinimalVersion`] from a [`Version`] reference.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if `value.pkgrel` is [`None`].
286    fn try_from(value: &Version) -> Result<Self, Self::Error> {
287        Self::try_from(value.clone())
288    }
289}
290
291impl From<MinimalVersion> for Version {
292    /// Creates a [`Version`] from a [`MinimalVersion`].
293    fn from(value: MinimalVersion) -> Self {
294        Self {
295            pkgver: value.pkgver,
296            pkgrel: None,
297            epoch: value.epoch,
298        }
299    }
300}
301
302impl From<&MinimalVersion> for Version {
303    /// Creates a [`Version`] from a [`MinimalVersion`] reference.
304    fn from(value: &MinimalVersion) -> Self {
305        Self::from(value.clone())
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use log::{LevelFilter, debug};
312    use rstest::rstest;
313    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
314    use testresult::TestResult;
315
316    use super::*;
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    fn minimal_version_from_str_succeeds(
348        #[case] version: &str,
349        #[case] expected: MinimalVersion,
350    ) -> TestResult {
351        init_logger();
352
353        assert_eq!(
354            MinimalVersion::from_str(version),
355            Ok(expected),
356            "Expected valid parsing for MinimalVersion {version}"
357        );
358
359        Ok(())
360    }
361
362    /// Ensures that invalid [`MinimalVersion`] strings lead to parse errors.
363    #[rstest]
364    #[case::two_pkgrel(
365        "1:foo-1-1",
366        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
367    )]
368    #[case::two_epoch(
369        "1:1:foo-1",
370        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
371    )]
372    #[case::empty_string(
373        "",
374        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
375    )]
376    #[case::colon(
377        ":",
378        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
379    )]
380    #[case::dot(
381        ".",
382        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
383    )]
384    #[case::full_with_epoch(
385        "1:1.0.0-1",
386        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
387    )]
388    #[case::full(
389        "1.0.0-1",
390        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
391    )]
392    #[case::no_pkgrel_dash_end(
393        "1.0.0-",
394        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
395    )]
396    #[case::starts_with_dash(
397        "-1foo:1",
398        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
399    )]
400    #[case::ends_with_colon(
401        "1-foo:",
402        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
403    )]
404    #[case::ends_with_colon_number(
405        "1-foo:1",
406        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
407    )]
408    fn minimal_version_from_str_parse_error(#[case] version: &str, #[case] err_snippet: &str) {
409        init_logger();
410
411        let Err(Error::ParseError(err_msg)) = MinimalVersion::from_str(version) else {
412            panic!("parsing '{version}' as MinimalVersion did not fail as expected")
413        };
414        assert!(
415            err_msg.contains(err_snippet),
416            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
417        );
418    }
419
420    /// Ensures that [`MinimalVersion`] can be created from valid/compatible [`Version`] (and
421    /// [`Version`] reference) and fails otherwise.
422    #[rstest]
423    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Ok(MinimalVersion::from_str("1:1.0.0")?))]
424    #[case::minimal(Version::from_str("1.0.0")?, Ok(MinimalVersion::from_str("1.0.0")?))]
425    #[case::full_with_epoch(Version::from_str("1:1.0.0-1")?, Err(Error::InvalidComponent{component: "pkgrel", context: "converting a full alpm-package-version to a minimal alpm-package-version"}))]
426    #[case::full(Version::from_str("1.0.0-1")?, Err(Error::InvalidComponent{component: "pkgrel", context: "converting a full alpm-package-version to a minimal alpm-package-version"}))]
427    fn minimal_version_try_from_version(
428        #[case] version: Version,
429        #[case] expected: Result<MinimalVersion, Error>,
430    ) -> TestResult {
431        assert_eq!(MinimalVersion::try_from(&version), expected);
432        Ok(())
433    }
434
435    /// Ensures that [`Version`] can be created from [`MinimalVersion`] (and [`MinimalVersion`]
436    /// reference).
437    #[rstest]
438    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, MinimalVersion::from_str("1:1.0.0")?)]
439    #[case::minimal(Version::from_str("1.0.0")?, MinimalVersion::from_str("1.0.0")?)]
440    fn version_from_minimal_version(
441        #[case] version: Version,
442        #[case] full_version: MinimalVersion,
443    ) -> TestResult {
444        assert_eq!(Version::from(&full_version), version);
445        Ok(())
446    }
447
448    /// Ensures that [`MinimalVersion`] is properly serialized back to its string representation.
449    #[rstest]
450    #[case::with_epoch("1:1.0.0")]
451    #[case::plain("1.0.0")]
452    fn minimal_version_to_string(#[case] input: &str) -> TestResult {
453        assert_eq!(format!("{}", MinimalVersion::from_str(input)?), input);
454        Ok(())
455    }
456
457    /// Ensures that [`MinimalVersion`]s can be compared.
458    ///
459    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
460    /// [`PackageRelease`].
461    #[rstest]
462    #[case::minimal_equal("1.0.0", "1.0.0", Ordering::Equal)]
463    #[case::minimal_less("1.0.0", "2.0.0", Ordering::Less)]
464    #[case::minimal_greater("2.0.0", "1.0.0", Ordering::Greater)]
465    #[case::minimal_with_epoch_equal("1:1.0.0", "1:1.0.0", Ordering::Equal)]
466    #[case::minimal_with_epoch_less("1.0.0", "1:1.0.0", Ordering::Less)]
467    #[case::minimal_with_epoch_less("1:1.0.0", "2:1.0.0", Ordering::Less)]
468    #[case::minimal_with_epoch_greater("1:1.0.0", "1.0.0", Ordering::Greater)]
469    #[case::minimal_with_epoch_greater("2:1.0.0", "1:1.0.0", Ordering::Greater)]
470    fn minimal_version_comparison(
471        #[case] version_a: &str,
472        #[case] version_b: &str,
473        #[case] expected: Ordering,
474    ) -> TestResult {
475        let version_a = MinimalVersion::from_str(version_a)?;
476        let version_b = MinimalVersion::from_str(version_b)?;
477
478        // Derive the expected vercmp binary exitcode from the expected Ordering.
479        let vercmp_result = match &expected {
480            Ordering::Equal => 0,
481            Ordering::Greater => 1,
482            Ordering::Less => -1,
483        };
484
485        let ordering = version_a.cmp(&version_b);
486        assert_eq!(
487            ordering, expected,
488            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
489        );
490
491        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
492
493        // If we find the `vercmp` binary, also run the test against the actual binary.
494        #[cfg(feature = "compatibility_tests")]
495        {
496            let output = std::process::Command::new("vercmp")
497                .arg(version_a.to_string())
498                .arg(version_b.to_string())
499                .output()?;
500            let result = String::from_utf8_lossy(&output.stdout);
501            assert_eq!(result.trim(), vercmp_result.to_string());
502        }
503
504        // Now check that the opposite holds true as well.
505        let reverse_vercmp_result = match &expected {
506            Ordering::Equal => 0,
507            Ordering::Greater => -1,
508            Ordering::Less => 1,
509        };
510        let reverse_expected = match &expected {
511            Ordering::Equal => Ordering::Equal,
512            Ordering::Greater => Ordering::Less,
513            Ordering::Less => Ordering::Greater,
514        };
515
516        let reverse_ordering = version_b.cmp(&version_a);
517        assert_eq!(
518            reverse_ordering, reverse_expected,
519            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
520        );
521
522        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
523
524        Ok(())
525    }
526}