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}