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