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 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}