1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
54pub struct MinimalVersion {
55 pub pkgver: PackageVersion,
57 pub epoch: Option<Epoch>,
59}
60
61impl MinimalVersion {
62 pub fn new(pkgver: PackageVersion, epoch: Option<Epoch>) -> Self {
82 Self { pkgver, epoch }
83 }
84
85 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 pub fn parser(input: &mut &str) -> ModalResult<Self> {
143 let epoch = opt(terminated(take_till(1.., ':'), ':').and_then(
146 cut_err(Epoch::parser),
148 ))
149 .context(StrContext::Expected(StrContextValue::Description(
150 "followed by a ':'",
151 )))
152 .parse_next(input)?;
153
154 let pkgver: PackageVersion = cut_err(PackageVersion::parser)
157 .context(StrContext::Expected(StrContextValue::Description(
158 "alpm-pkgver string",
159 )))
160 .parse_next(input)?;
161
162 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 fn from_str(s: &str) -> Result<Self, Self::Err> {
193 Ok(Self::parser.parse(s)?)
194 }
195}
196
197impl Ord for MinimalVersion {
198 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 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 fn try_from(value: &Version) -> Result<Self, Self::Error> {
288 Self::try_from(value.clone())
289 }
290}
291
292impl From<MinimalVersion> for Version {
293 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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}