use std::{
cmp::Ordering,
fmt::{Display, Formatter},
str::FromStr,
};
use fluent_i18n::t;
use serde::{Deserialize, Serialize};
use winnow::{
ModalResult,
Parser,
combinator::{cut_err, eof, opt, terminated},
error::{StrContext, StrContextValue},
token::take_till,
};
use crate::{Epoch, Error, PackageVersion, Version};
#[cfg(doc)]
use crate::{FullVersion, PackageRelease};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct MinimalVersion {
pub pkgver: PackageVersion,
pub epoch: Option<Epoch>,
}
impl MinimalVersion {
pub fn new(pkgver: PackageVersion, epoch: Option<Epoch>) -> Self {
Self { pkgver, epoch }
}
pub fn vercmp(&self, other: &MinimalVersion) -> i8 {
match self.cmp(other) {
Ordering::Less => -1,
Ordering::Equal => 0,
Ordering::Greater => 1,
}
}
pub fn parser(input: &mut &str) -> ModalResult<Self> {
let epoch = opt(terminated(take_till(1.., ':'), ':').and_then(
cut_err(Epoch::parser),
))
.context(StrContext::Expected(StrContextValue::Description(
"followed by a ':'",
)))
.parse_next(input)?;
let pkgver: PackageVersion = cut_err(PackageVersion::parser)
.context(StrContext::Expected(StrContextValue::Description(
"alpm-pkgver string",
)))
.parse_next(input)?;
eof.context(StrContext::Expected(StrContextValue::Description(
"end of full alpm-package-version string",
)))
.parse_next(input)?;
Ok(Self { epoch, pkgver })
}
}
impl Display for MinimalVersion {
fn fmt(&self, fmt: &mut Formatter) -> std::fmt::Result {
if let Some(epoch) = self.epoch {
write!(fmt, "{epoch}:")?;
}
write!(fmt, "{}", self.pkgver)?;
Ok(())
}
}
impl FromStr for MinimalVersion {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::parser.parse(s)?)
}
}
impl Ord for MinimalVersion {
fn cmp(&self, other: &Self) -> Ordering {
match (self.epoch, other.epoch) {
(Some(self_epoch), Some(other_epoch)) if self_epoch.cmp(&other_epoch).is_ne() => {
return self_epoch.cmp(&other_epoch);
}
(Some(_), None) => return Ordering::Greater,
(None, Some(_)) => return Ordering::Less,
(_, _) => {}
}
self.pkgver.cmp(&other.pkgver)
}
}
impl PartialOrd for MinimalVersion {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl TryFrom<Version> for MinimalVersion {
type Error = crate::Error;
fn try_from(value: Version) -> Result<Self, Self::Error> {
if value.pkgrel.is_some() {
Err(Error::InvalidComponent {
component: "pkgrel",
context: t!("error-context-convert-full-to-minimal"),
})
} else {
Ok(Self {
pkgver: value.pkgver,
epoch: value.epoch,
})
}
}
}
impl TryFrom<&Version> for MinimalVersion {
type Error = crate::Error;
fn try_from(value: &Version) -> Result<Self, Self::Error> {
Self::try_from(value.clone())
}
}
impl From<MinimalVersion> for Version {
fn from(value: MinimalVersion) -> Self {
Self {
pkgver: value.pkgver,
pkgrel: None,
epoch: value.epoch,
}
}
}
impl From<&MinimalVersion> for Version {
fn from(value: &MinimalVersion) -> Self {
Self::from(value.clone())
}
}
#[cfg(test)]
mod tests {
use log::{LevelFilter, debug};
use rstest::rstest;
use simplelog::{ColorChoice, Config, TermLogger, TerminalMode};
use testresult::TestResult;
use super::*;
fn init_logger() {
if TermLogger::init(
LevelFilter::Trace,
Config::default(),
TerminalMode::Stderr,
ColorChoice::Auto,
)
.is_err()
{
debug!("Not initializing another logger, as one is initialized already.");
}
}
#[rstest]
#[case::minimal_with_epoch(
"1:foo",
MinimalVersion {
pkgver: PackageVersion::from_str("foo")?,
epoch: Some(Epoch::from_str("1")?),
},
)]
#[case::minimal(
"foo",
MinimalVersion {
pkgver: PackageVersion::from_str("foo")?,
epoch: None,
}
)]
#[case::minimal_dot(
".",
MinimalVersion {
pkgver: PackageVersion::from_str(".")?,
epoch: None,
}
)]
fn minimal_version_from_str_succeeds(
#[case] version: &str,
#[case] expected: MinimalVersion,
) -> TestResult {
init_logger();
assert_eq!(
MinimalVersion::from_str(version),
Ok(expected),
"Expected valid parsing for MinimalVersion {version}"
);
Ok(())
}
#[rstest]
#[case::two_pkgrel(
"1:foo-1-1",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::two_epoch(
"1:1:foo-1",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::empty_string(
"",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::colon(
":",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::full_with_epoch(
"1:1.0.0-1",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::full(
"1.0.0-1",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::no_pkgrel_dash_end(
"1.0.0-",
"invalid pkgver character\nexpected an ASCII character, except for ':', '/', '-', '<', '>', '=', or any whitespace characters, alpm-pkgver string"
)]
#[case::starts_with_dash(
"-1foo:1",
"invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
)]
#[case::ends_with_colon(
"1-foo:",
"invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
)]
#[case::ends_with_colon_number(
"1-foo:1",
"invalid package epoch\nexpected positive non-zero decimal integer, followed by a ':'"
)]
fn minimal_version_from_str_parse_error(#[case] version: &str, #[case] err_snippet: &str) {
init_logger();
let Err(Error::ParseError(err_msg)) = MinimalVersion::from_str(version) else {
panic!("parsing '{version}' as MinimalVersion did not fail as expected")
};
assert!(
err_msg.contains(err_snippet),
"Error:\n=====\n{err_msg}\n=====\nshould contain snippet:\n\n{err_snippet}"
);
}
#[rstest]
#[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, Ok(MinimalVersion::from_str("1:1.0.0")?))]
#[case::minimal(Version::from_str("1.0.0")?, Ok(MinimalVersion::from_str("1.0.0")?))]
#[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")}))]
#[case::full(Version::from_str("1.0.0-1")?, Err(Error::InvalidComponent{component: "pkgrel", context: t!("error-context-convert-full-to-minimal")}))]
fn minimal_version_try_from_version(
#[case] version: Version,
#[case] expected: Result<MinimalVersion, Error>,
) -> TestResult {
assert_eq!(MinimalVersion::try_from(&version), expected);
Ok(())
}
#[rstest]
#[case::minimal_with_epoch(Version::from_str("1:1.0.0")?, MinimalVersion::from_str("1:1.0.0")?)]
#[case::minimal(Version::from_str("1.0.0")?, MinimalVersion::from_str("1.0.0")?)]
fn version_from_minimal_version(
#[case] version: Version,
#[case] full_version: MinimalVersion,
) -> TestResult {
assert_eq!(Version::from(&full_version), version);
Ok(())
}
#[rstest]
#[case::with_epoch("1:1.0.0")]
#[case::plain("1.0.0")]
fn minimal_version_to_string(#[case] input: &str) -> TestResult {
assert_eq!(format!("{}", MinimalVersion::from_str(input)?), input);
Ok(())
}
#[rstest]
#[case::minimal_equal("1.0.0", "1.0.0", Ordering::Equal)]
#[case::minimal_less("1.0.0", "2.0.0", Ordering::Less)]
#[case::minimal_greater("2.0.0", "1.0.0", Ordering::Greater)]
#[case::minimal_with_epoch_equal("1:1.0.0", "1:1.0.0", Ordering::Equal)]
#[case::minimal_with_epoch_less("1.0.0", "1:1.0.0", Ordering::Less)]
#[case::minimal_with_epoch_less("1:1.0.0", "2:1.0.0", Ordering::Less)]
#[case::minimal_with_epoch_greater("1:1.0.0", "1.0.0", Ordering::Greater)]
#[case::minimal_with_epoch_greater("2:1.0.0", "1:1.0.0", Ordering::Greater)]
fn minimal_version_comparison(
#[case] version_a: &str,
#[case] version_b: &str,
#[case] expected: Ordering,
) -> TestResult {
let version_a = MinimalVersion::from_str(version_a)?;
let version_b = MinimalVersion::from_str(version_b)?;
let vercmp_result = match &expected {
Ordering::Equal => 0,
Ordering::Greater => 1,
Ordering::Less => -1,
};
let ordering = version_a.cmp(&version_b);
assert_eq!(
ordering, expected,
"Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
);
assert_eq!(version_a.vercmp(&version_b), vercmp_result);
#[cfg(feature = "compatibility_tests")]
{
let output = std::process::Command::new("vercmp")
.arg(version_a.to_string())
.arg(version_b.to_string())
.output()?;
let result = String::from_utf8_lossy(&output.stdout);
assert_eq!(result.trim(), vercmp_result.to_string());
}
let reverse_vercmp_result = match &expected {
Ordering::Equal => 0,
Ordering::Greater => -1,
Ordering::Less => 1,
};
let reverse_expected = match &expected {
Ordering::Equal => Ordering::Equal,
Ordering::Greater => Ordering::Less,
Ordering::Less => Ordering::Greater,
};
let reverse_ordering = version_b.cmp(&version_a);
assert_eq!(
reverse_ordering, reverse_expected,
"Failed to compare '{version_a}' and '{version_b}'. Expected {expected:?} got {ordering:?}"
);
assert_eq!(version_b.vercmp(&version_a), reverse_vercmp_result);
Ok(())
}
}