#
cargo_util_schemas/core/
partial_version.rs

    1use std::fmt::{self, Display};
    2
    3use semver::{Comparator, Version, VersionReq};
    4use serde_untagged::UntaggedEnumVisitor;
    5
    6#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Debug)]
    7pub struct PartialVersion {
    8    pub major: u64,
    9    pub minor: Option<u64>,
    10    pub patch: Option<u64>,
    11    pub pre: Option<semver::Prerelease>,
    12    pub build: Option<semver::BuildMetadata>,
    13}
    14
    15impl PartialVersion {
    16    pub fn to_version(&self) -> Option<Version> {
    17        Some(Version {
    18            major: self.major,
    19            minor: self.minor?,
    20            patch: self.patch?,
    21            pre: self.pre.clone().unwrap_or_default(),
    22            build: self.build.clone().unwrap_or_default(),
    23        })
    24    }
    25
    26    pub fn to_caret_req(&self) -> VersionReq {
    27        VersionReq {
    28            comparators: vec![Comparator {
    29                op: semver::Op::Caret,
    30                major: self.major,
    31                minor: self.minor,
    32                patch: self.patch,
    33                pre: self.pre.as_ref().cloned().unwrap_or_default(),
    34            }],
    35        }
    36    }
    37
    38    /// Check if this matches a version, including build metadata
    39    ///
    40    /// Build metadata does not affect version precedence but may be necessary for uniquely
    41    /// identifying a package.
    42    pub fn matches(&self, version: &Version) -> bool {
    43        if !version.pre.is_empty() && self.pre.is_none() {
    44            // Pre-release versions must be explicitly opted into, if for no other reason than to
    45            // give us room to figure out and define the semantics
    46            return false;
    47        }
    48        self.major == version.major
    49            && self.minor.map(|f| f == version.minor).unwrap_or(true)
    50            && self.patch.map(|f| f == version.patch).unwrap_or(true)
    51            && self.pre.as_ref().map(|f| f == &version.pre).unwrap_or(true)
    52            && self
    53                .build
    54                .as_ref()
    55                .map(|f| f == &version.build)
    56                .unwrap_or(true)
    57    }
    58}
    59
    60impl From<semver::Version> for PartialVersion {
    61    fn from(ver: semver::Version) -> Self {
    62        let pre = if ver.pre.is_empty() {
    63            None
    64        } else {
    65            Some(ver.pre)
    66        };
    67        let build = if ver.build.is_empty() {
    68            None
    69        } else {
    70            Some(ver.build)
    71        };
    72        Self {
    73            major: ver.major,
    74            minor: Some(ver.minor),
    75            patch: Some(ver.patch),
    76            pre,
    77            build,
    78        }
    79    }
    80}
    81
    82impl std::str::FromStr for PartialVersion {
    83    type Err = PartialVersionError;
    84
    85    fn from_str(value: &str) -> Result<Self, Self::Err> {
    86        match semver::Version::parse(value) {
    87            Ok(ver) => Ok(ver.into()),
    88            Err(_) => {
    89                // HACK: Leverage `VersionReq` for partial version parsing
    90                let mut version_req = match semver::VersionReq::parse(value) {
    91                    Ok(req) => req,
    92                    Err(_) if value.contains('-') => return Err(ErrorKind::Prerelease.into()),
    93                    Err(_) if value.contains('+') => return Err(ErrorKind::BuildMetadata.into()),
    94                    Err(_) => return Err(ErrorKind::Unexpected.into()),
    95                };
    96                if version_req.comparators.len() != 1 {
    97                    return Err(ErrorKind::VersionReq.into());
    98                }
    99                let comp = version_req.comparators.pop().unwrap();
    100                if comp.op != semver::Op::Caret {
    101                    return Err(ErrorKind::VersionReq.into());
    102                } else if value.starts_with('^') {
    103                    // Can't distinguish between `^` present or not
    104                    return Err(ErrorKind::VersionReq.into());
    105                }
    106                let pre = if comp.pre.is_empty() {
    107                    None
    108                } else {
    109                    Some(comp.pre)
    110                };
    111                Ok(Self {
    112                    major: comp.major,
    113                    minor: comp.minor,
    114                    patch: comp.patch,
    115                    pre,
    116                    build: None,
    117                })
    118            }
    119        }
    120    }
    121}
    122
    123impl Display for PartialVersion {
    124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    125        let major = self.major;
    126        write!(f, "{major}")?;
    127        if let Some(minor) = self.minor {
    128            write!(f, ".{minor}")?;
    129        }
    130        if let Some(patch) = self.patch {
    131            write!(f, ".{patch}")?;
    132        }
    133        if let Some(pre) = self.pre.as_ref() {
    134            write!(f, "-{pre}")?;
    135        }
    136        if let Some(build) = self.build.as_ref() {
    137            write!(f, "+{build}")?;
    138        }
    139        Ok(())
    140    }
    141}
    142
    143impl serde::Serialize for PartialVersion {
    144    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    145    where
    146        S: serde::Serializer,
    147    {
    148        serializer.collect_str(self)
    149    }
    150}
    151
    152impl<'de> serde::Deserialize<'de> for PartialVersion {
    153    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    154    where
    155        D: serde::Deserializer<'de>,
    156    {
    157        UntaggedEnumVisitor::new()
    158            .expecting("SemVer version")
    159            .string(|value| value.parse().map_err(serde::de::Error::custom))
    160            .deserialize(deserializer)
    161    }
    162}
    163
    164/// Error parsing a [`PartialVersion`].
    165#[derive(Debug, thiserror::Error)]
    166#[error(transparent)]
    167pub struct PartialVersionError(#[from] ErrorKind);
    168
    169/// Non-public error kind for [`PartialVersionError`].
    170#[non_exhaustive]
    171#[derive(Debug, thiserror::Error)]
    172enum ErrorKind {
    173    #[error("unexpected version requirement, expected a version like \"1.32\"")]
    174    VersionReq,
    175
    176    #[error("unexpected prerelease field, expected a version like \"1.32\"")]
    177    Prerelease,
    178
    179    #[error("unexpected build field, expected a version like \"1.32\"")]
    180    BuildMetadata,
    181
    182    #[error("expected a version like \"1.32\"")]
    183    Unexpected,
    184}
    185
    186#[cfg(test)]
    187mod test {
    188    use super::*;
    189    use snapbox::prelude::*;
    190    use snapbox::str;
    191
    192    #[test]
    193    fn parse_success() {
    194        let cases = &[
    195            // Valid pre-release
    196            ("1.43.0-beta.1", str!["1.43.0-beta.1"]),
    197            // Valid pre-release with wildcard
    198            ("1.43.0-beta.1.x", str!["1.43.0-beta.1.x"]),
    199        ];
    200        for (input, expected) in cases {
    201            let actual: Result<PartialVersion, _> = input.parse();
    202            let actual = match actual {
    203                Ok(result) => result.to_string(),
    204                Err(err) => format!("didn't pass: {err}"),
    205            };
    206            snapbox::assert_data_eq!(actual, expected.clone().raw());
    207        }
    208    }
    209
    210    #[test]
    211    fn parse_errors() {
    212        let cases = &[
    213            // Disallow caret
    214            (
    215                "^1.43",
    216                str![[r#"unexpected version requirement, expected a version like "1.32""#]],
    217            ),
    218            // Bad pre-release
    219            (
    220                "1.43-beta.1",
    221                str![[r#"unexpected prerelease field, expected a version like "1.32""#]],
    222            ),
    223            // Weird wildcard
    224            (
    225                "x",
    226                str![[r#"unexpected version requirement, expected a version like "1.32""#]],
    227            ),
    228            (
    229                "1.x",
    230                str![[r#"unexpected version requirement, expected a version like "1.32""#]],
    231            ),
    232            (
    233                "1.1.x",
    234                str![[r#"unexpected version requirement, expected a version like "1.32""#]],
    235            ),
    236            // Non-sense
    237            ("foodaddle", str![[r#"expected a version like "1.32""#]]),
    238        ];
    239        for (input, expected) in cases {
    240            let actual: Result<PartialVersion, _> = input.parse();
    241            let actual = match actual {
    242                Ok(result) => format!("didn't fail: {result:?}"),
    243                Err(err) => err.to_string(),
    244            };
    245            snapbox::assert_data_eq!(actual, expected.clone().raw());
    246        }
    247    }
    248}