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    // yes, valid
349    #[case::minimal_dot(
350        ".",
351        MinimalVersion {
352            pkgver: PackageVersion::from_str(".")?,
353            epoch: None,
354            }
355    )]
356    fn minimal_version_from_str_succeeds(
357        #[case] version: &str,
358        #[case] expected: MinimalVersion,
359    ) -> TestResult {
360        init_logger();
361
362        assert_eq!(
363            MinimalVersion::from_str(version),
364            Ok(expected),
365            "Expected valid parsing for MinimalVersion {version}"
366        );
367
368        Ok(())
369    }
370
371    /// Ensures that invalid [`MinimalVersion`] strings lead to parse errors.
372    #[rstest]
373    #[case::two_pkgrel(
374        "1:foo-1-1",
375        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
376    )]
377    #[case::two_epoch(
378        "1:1:foo-1",
379        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
380    )]
381    #[case::empty_string(
382        "",
383        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
384    )]
385    #[case::colon(
386        ":",
387        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
388    )]
389    #[case::full_with_epoch(
390        "1:1.0.0-1",
391        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
392    )]
393    #[case::full(
394        "1.0.0-1",
395        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
396    )]
397    #[case::no_pkgrel_dash_end(
398        "1.0.0-",
399        "invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
400    )]
401    #[case::starts_with_dash(
402        "-1foo:1",
403        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
404    )]
405    #[case::ends_with_colon(
406        "1-foo:",
407        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
408    )]
409    #[case::ends_with_colon_number(
410        "1-foo:1",
411        "invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
412    )]
413    fn minimal_version_from_str_parse_error(#[case] version: &str, #[case] err_snippet: &str) {
414        init_logger();
415
416        let Err(Error::ParseError(err_msg)) = MinimalVersion::from_str(version) else {
417            panic!("parsing '{version}' as MinimalVersion did not fail as expected")
418        };
419        assert!(
420            err_msg.contains(err_snippet),
421            "Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
422        );
423    }
424
425    /// Ensures that [`MinimalVersion`] can be created from valid/compatible [`Version`] (and
426    /// [`Version`] reference) and fails otherwise.
427    #[rstest]
428    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Ok(MinimalVersion::from_str("1:1.0.0")?))]
429    #[case::minimal(Version::from_str("1.0.0")?, Ok(MinimalVersion::from_str("1.0.0")?))]
430    #[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")}))]
431    #[case::full(Version::from_str("1.0.0-1")?, Err(Error::InvalidComponent{component: "pkgrel", context: t!("error-context-convert-full-to-minimal")}))]
432    fn minimal_version_try_from_version(
433        #[case] version: Version,
434        #[case] expected: Result<MinimalVersion, Error>,
435    ) -> TestResult {
436        assert_eq!(MinimalVersion::try_from(&version), expected);
437        Ok(())
438    }
439
440    /// Ensures that [`Version`] can be created from [`MinimalVersion`] (and [`MinimalVersion`]
441    /// reference).
442    #[rstest]
443    #[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, MinimalVersion::from_str("1:1.0.0")?)]
444    #[case::minimal(Version::from_str("1.0.0")?, MinimalVersion::from_str("1.0.0")?)]
445    fn version_from_minimal_version(
446        #[case] version: Version,
447        #[case] full_version: MinimalVersion,
448    ) -> TestResult {
449        assert_eq!(Version::from(&full_version), version);
450        Ok(())
451    }
452
453    /// Ensures that [`MinimalVersion`] is properly serialized back to its string representation.
454    #[rstest]
455    #[case::with_epoch("1:1.0.0")]
456    #[case::plain("1.0.0")]
457    fn minimal_version_to_string(#[case] input: &str) -> TestResult {
458        assert_eq!(format!("{}", MinimalVersion::from_str(input)?), input);
459        Ok(())
460    }
461
462    /// Ensures that [`MinimalVersion`]s can be compared.
463    ///
464    /// For more detailed version comparison tests refer to the unit tests for [`Version`] and
465    /// [`PackageRelease`].
466    #[rstest]
467    #[case::minimal_equal("1.0.0", "1.0.0", Ordering::Equal)]
468    #[case::minimal_less("1.0.0", "2.0.0", Ordering::Less)]
469    #[case::minimal_greater("2.0.0", "1.0.0", Ordering::Greater)]
470    #[case::minimal_with_epoch_equal("1:1.0.0", "1:1.0.0", Ordering::Equal)]
471    #[case::minimal_with_epoch_less("1.0.0", "1:1.0.0", Ordering::Less)]
472    #[case::minimal_with_epoch_less("1:1.0.0", "2:1.0.0", Ordering::Less)]
473    #[case::minimal_with_epoch_greater("1:1.0.0", "1.0.0", Ordering::Greater)]
474    #[case::minimal_with_epoch_greater("2:1.0.0", "1:1.0.0", Ordering::Greater)]
475    fn minimal_version_comparison(
476        #[case] version_a: &str,
477        #[case] version_b: &str,
478        #[case] expected: Ordering,
479    ) -> TestResult {
480        let version_a = MinimalVersion::from_str(version_a)?;
481        let version_b = MinimalVersion::from_str(version_b)?;
482
483        // Derive the expected vercmp binary exitcode from the expected Ordering.
484        let vercmp_result = match &expected {
485            Ordering::Equal => 0,
486            Ordering::Greater => 1,
487            Ordering::Less => -1,
488        };
489
490        let ordering = version_a.cmp(&version_b);
491        assert_eq!(
492            ordering, expected,
493            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
494        );
495
496        assert_eq!(version_a.vercmp(&version_b), vercmp_result);
497
498        // If we find the `vercmp` binary, also run the test against the actual binary.
499        #[cfg(feature = "compatibility_tests")]
500        {
501            let output = std::process::Command::new("vercmp")
502                .arg(version_a.to_string())
503                .arg(version_b.to_string())
504                .output()?;
505            let result = String::from_utf8_lossy(&output.stdout);
506            assert_eq!(result.trim(), vercmp_result.to_string());
507        }
508
509        // Now check that the opposite holds true as well.
510        let reverse_vercmp_result = match &expected {
511            Ordering::Equal => 0,
512            Ordering::Greater => -1,
513            Ordering::Less => 1,
514        };
515        let reverse_expected = match &expected {
516            Ordering::Equal => Ordering::Equal,
517            Ordering::Greater => Ordering::Less,
518            Ordering::Less => Ordering::Greater,
519        };
520
521        let reverse_ordering = version_b.cmp(&version_a);
522        assert_eq!(
523            reverse_ordering, reverse_expected,
524            "Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
525        );
526
527        assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
528
529        Ok(())
530    }
531}