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 fluent_i18n::t;
12use serde::{Deserialize, Serialize};
13use winnow::{
14    ModalResult,
15    Parser,
16    combinator::{cut_err, eof, opt, terminated},
17    error::{StrContext, StrContextValue},
18    token::take_till,
19};
20
21use crate::{Epoch, Error, PackageVersion, Version};
22#[cfg(doc)]
23use crate::{FullVersion, PackageRelease};
24
25/// A package version without a [`PackageRelease`].
26///
27/// Tracks an optional [`Epoch`] and a [`PackageVersion`], but no [`PackageRelease`].
28/// This reflects the _minimal_ and _minimal with epoch_ forms of [alpm-package-version].
29///
30/// # Notes
31///
32/// - If [`PackageRelease`] should be optional for your use-case, use [`Version`] instead.
33/// - If [`PackageRelease`] should be mandatory for your use-case, use [`FullVersion`] instead.
34///
35/// # Examples
36///
37/// ```
38/// use std::str::FromStr;
39///
40/// use alpm_types::MinimalVersion;
41///
42/// # fn main() -> testresult::TestResult {
43/// // A minimal version.
44/// let version = MinimalVersion::from_str("1.0.0")?;
45///
46/// // A minimal version with epoch.
47/// let version = MinimalVersion::from_str("1:1.0.0")?;
48/// # Ok(())
49/// # }
50/// ```
51///
52/// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, 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.try_into()?)),
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    /// Recognizes a [`MinimalVersion`] in a string slice.
133    ///
134    /// Consumes all of its input.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if `input` is not a valid [alpm-package-version] (_full_ or _full with
139    /// epoch_).
140    ///
141    /// [alpm-package-version]: https://alpm.archlinux.page/specifications/alpm-package-version.7.html
142    pub fn parser(input: &mut &str) -> ModalResult<Self> {
143        // Advance the parser until after a ':' if there is one, e.g.:
144        // "1:1.0.0-1" -> "1.0.0-1"
145        let epoch = opt(terminated(take_till(1.., ':'), ':').and_then(
146            // cut_err now that we've found a pattern with ':'
147            cut_err(Epoch::parser),
148        ))
149        .context(StrContext::Expected(StrContextValue::Description(
150            "followed by a ':'",
151        )))
152        .parse_next(input)?;
153
154        // Advance the parser until the next '-', e.g.:
155        // "1.0.0-1" -> "-1"
156        let pkgver: PackageVersion = cut_err(PackageVersion::parser)
157            .context(StrContext::Expected(StrContextValue::Description(
158                "alpm-pkgver string",
159            )))
160            .parse_next(input)?;
161
162        // Ensure that there are no trailing chars left.
163        eof.context(StrContext::Expected(StrContextValue::Description(
164            "end of full alpm-package-version string",
165        )))
166        .parse_next(input)?;
167
168        Ok(Self { epoch, pkgver })
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.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 log::{LevelFilter, debug};
313    use rstest::rstest;
314    use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
315    use testresult::TestResult;
316
317    use super::*;
318    /// Initialize a logger that shows trace messages on stderr.
319    fn init_logger() {
320        if TermLogger::init(
321            LevelFilter::Trace,
322            Config::default(),
323            TerminalMode::Stderr,
324            ColorChoice::Auto,
325        )
326        .is_err()
327        {
328            debug!("Not initializing another logger, as one is initialized already.");
329        }
330    }
331
332    /// Ensures that valid [`MinimalVersion`] strings are parsed successfully as expected.
333    #[rstest]
334    #[case::minimal_with_epoch(
335        "1:foo",
336        MinimalVersion {
337            pkgver: PackageVersion::from_str("foo")?,
338            epoch: Some(Epoch::from_str("1")?),
339        },
340    )]
341    #[case::minimal(
342        "foo",
343        MinimalVersion {
344            pkgver: PackageVersion::from_str("foo")?,
345            epoch: None,
346        }
347    )]
348    fn minimal_version_from_str_succeeds(
349        #[case] version: &str,
350        #[case] expected: MinimalVersion,
351    ) -> TestResult {
352        init_logger();
353
354        assert_eq!(
355            MinimalVersion::from_str(version),
356            Ok(expected),
357            "Expected valid parsing for MinimalVersion {version}"
358        );
359
360        Ok(())
361    }
362
363    /// Ensures that invalid [`MinimalVersion`] strings lead to parse errors.
364    #[rstest]
365    #[case::two_pkgrel(
366        "1:foo-1-1",
367        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
368    )]
369    #[case::two_epoch(
370        "1:1:foo-1",
371        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
372    )]
373    #[case::empty_string(
374        "",
375        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
376    )]
377    #[case::colon(
378        ":",
379        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
380    )]
381    #[case::dot(
382        ".",
383        "invalid first pkgver character\nexpected ASCII alphanumeric character, alpm-pkgver string"
384    )]
385    #[case::full_with_epoch(
386        "1:1.0.0-1",
387        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
388    )]
389    #[case::full(
390        "1.0.0-1",
391        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
392    )]
393    #[case::no_pkgrel_dash_end(
394        "1.0.0-",
395        "invalid pkgver character\nexpected ASCII alphanumeric character, `_`, `+`, `.`, alpm-pkgver string"
396    )]
397    #[case::starts_with_dash(
398        "-1foo:1",
399        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
400    )]
401    #[case::ends_with_colon(
402        "1-foo:",
403        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
404    )]
405    #[case::ends_with_colon_number(
406        "1-foo:1",
407        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
408    )]
409    fn minimal_version_from_str_parse_error(#[case] version: &str, #[case] err_snippet: &str) {
410        init_logger();
411
412        let Err(Error::ParseError(err_msg)) = MinimalVersion::from_str(version) else {
413            panic!("parsing '{version}' as MinimalVersion did not fail as expected")
414        };
415        assert!(
416            err_msg.contains(err_snippet),
417            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
418        );
419    }
420
421    /// Ensures that [`MinimalVersion`] can be created from valid/compatible [`Version`] (and
422    /// [`Version`] reference) and fails otherwise.
423    #[rstest]
424    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Ok(MinimalVersion::from_str("1:1.0.0")?))]
425    #[case::minimal(Version::from_str("1.0.0")?, Ok(MinimalVersion::from_str("1.0.0")?))]
426    #[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")}))]
427    #[case::full(Version::from_str("1.0.0-1")?, Err(Error::InvalidComponent{component: "pkgrel", context: t!("error-context-convert-full-to-minimal")}))]
428    fn minimal_version_try_from_version(
429        #[case] version: Version,
430        #[case] expected: Result<MinimalVersion, Error>,
431    ) -> TestResult {
432        assert_eq!(MinimalVersion::try_from(&version), expected);
433        Ok(())
434    }
435
436    /// Ensures that [`Version`] can be created from [`MinimalVersion`] (and [`MinimalVersion`]
437    /// reference).
438    #[rstest]
439    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, MinimalVersion::from_str("1:1.0.0")?)]
440    #[case::minimal(Version::from_str("1.0.0")?, MinimalVersion::from_str("1.0.0")?)]
441    fn version_from_minimal_version(
442        #[case] version: Version,
443        #[case] full_version: MinimalVersion,
444    ) -> TestResult {
445        assert_eq!(Version::from(&full_version), version);
446        Ok(())
447    }
448
449    /// Ensures that [`MinimalVersion`] is properly serialized back to its string representation.
450    #[rstest]
451    #[case::with_epoch("1:1.0.0")]
452    #[case::plain("1.0.0")]
453    fn minimal_version_to_string(#[case] input: &str) -> TestResult {
454        assert_eq!(format!("{}", MinimalVersion::from_str(input)?), input);
455        Ok(())
456    }
457
458    /// Ensures that [`MinimalVersion`]s can be compared.
459    ///
460    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
461    /// [`PackageRelease`].
462    #[rstest]
463    #[case::minimal_equal("1.0.0", "1.0.0", Ordering::Equal)]
464    #[case::minimal_less("1.0.0", "2.0.0", Ordering::Less)]
465    #[case::minimal_greater("2.0.0", "1.0.0", Ordering::Greater)]
466    #[case::minimal_with_epoch_equal("1:1.0.0", "1:1.0.0", Ordering::Equal)]
467    #[case::minimal_with_epoch_less("1.0.0", "1:1.0.0", Ordering::Less)]
468    #[case::minimal_with_epoch_less("1:1.0.0", "2:1.0.0", Ordering::Less)]
469    #[case::minimal_with_epoch_greater("1:1.0.0", "1.0.0", Ordering::Greater)]
470    #[case::minimal_with_epoch_greater("2:1.0.0", "1:1.0.0", Ordering::Greater)]
471    fn minimal_version_comparison(
472        #[case] version_a: &str,
473        #[case] version_b: &str,
474        #[case] expected: Ordering,
475    ) -> TestResult {
476        let version_a = MinimalVersion::from_str(version_a)?;
477        let version_b = MinimalVersion::from_str(version_b)?;
478
479        // Derive the expected vercmp binary exitcode from the expected Ordering.
480        let vercmp_result = match &expected {
481            Ordering::Equal => 0,
482            Ordering::Greater => 1,
483            Ordering::Less => -1,
484        };
485
486        let ordering = version_a.cmp(&version_b);
487        assert_eq!(
488            ordering, expected,
489            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
490        );
491
492        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
493
494        // If we find the `vercmp` binary, also run the test against the actual binary.
495        #[cfg(feature = "compatibility_tests")]
496        {
497            let output = std::process::Command::new("vercmp")
498                .arg(version_a.to_string())
499                .arg(version_b.to_string())
500                .output()?;
501            let result = String::from_utf8_lossy(&output.stdout);
502            assert_eq!(result.trim(), vercmp_result.to_string());
503        }
504
505        // Now check that the opposite holds true as well.
506        let reverse_vercmp_result = match &expected {
507            Ordering::Equal => 0,
508            Ordering::Greater => -1,
509            Ordering::Less => 1,
510        };
511        let reverse_expected = match &expected {
512            Ordering::Equal => Ordering::Equal,
513            Ordering::Greater => Ordering::Less,
514            Ordering::Less => Ordering::Greater,
515        };
516
517        let reverse_ordering = version_b.cmp(&version_a);
518        assert_eq!(
519            reverse_ordering, reverse_expected,
520            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
521        );
522
523        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
524
525        Ok(())
526    }
527}