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}