1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3
4use crate::Ecosystem;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
9#[serde(rename_all = "camelCase")]
10#[non_exhaustive]
11pub enum VersionGrammar {
12 SemVer,
13 Pep440,
15 Maven,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub(crate) enum ParsedVersion {
22 SemVer(semver::Version),
23 Pep440(pep440_rs::Version),
24}
25
26#[derive(Clone, Debug, PartialEq, Eq, Hash, JsonSchema)]
28#[schemars(with = "String")]
29pub struct Version {
30 pub(crate) grammar: VersionGrammar,
31 pub(crate) raw: String,
32 #[schemars(skip)]
33 pub(crate) parsed: ParsedVersion,
34}
35
36impl Version {
37 pub fn parse(raw: &str, grammar: VersionGrammar) -> Result<Self, VersionParseError> {
38 match grammar {
39 VersionGrammar::SemVer => {
40 let parsed = semver::Version::parse(raw).map_err(|e| VersionParseError {
41 raw: raw.to_string(),
42 grammar,
43 message: e.to_string(),
44 })?;
45 Ok(Version {
46 grammar,
47 raw: raw.to_string(),
48 parsed: ParsedVersion::SemVer(parsed),
49 })
50 }
51 VersionGrammar::Pep440 => {
52 let parsed = raw.parse::<pep440_rs::Version>().map_err(|e| VersionParseError {
53 raw: raw.to_string(),
54 grammar,
55 message: e.to_string(),
56 })?;
57 let canonical = parsed.to_string();
66 Ok(Version {
67 grammar,
68 raw: canonical,
69 parsed: ParsedVersion::Pep440(parsed),
70 })
71 }
72 VersionGrammar::Maven => Err(VersionParseError {
73 raw: raw.to_string(),
74 grammar,
75 message: format!("{grammar:?} has no versioning implementation yet (§7.7)"),
76 }),
77 }
78 }
79
80 pub fn semver(major: u64, minor: u64, patch: u64) -> Self {
81 let parsed = semver::Version::new(major, minor, patch);
82 Version {
83 grammar: VersionGrammar::SemVer,
84 raw: parsed.to_string(),
85 parsed: ParsedVersion::SemVer(parsed),
86 }
87 }
88
89 pub fn grammar(&self) -> VersionGrammar {
90 self.grammar
91 }
92
93 pub fn render(&self) -> &str {
94 &self.raw
95 }
96
97 pub fn raw(&self) -> &str {
98 &self.raw
99 }
100
101 pub fn major(&self) -> Option<u64> {
102 match &self.parsed {
103 ParsedVersion::SemVer(v) => Some(v.major),
104 ParsedVersion::Pep440(v) => v.release().first().copied(),
105 }
106 }
107
108 pub fn minor(&self) -> Option<u64> {
109 match &self.parsed {
110 ParsedVersion::SemVer(v) => Some(v.minor),
111 ParsedVersion::Pep440(v) => v.release().get(1).copied(),
112 }
113 }
114
115 pub fn patch(&self) -> Option<u64> {
116 match &self.parsed {
117 ParsedVersion::SemVer(v) => Some(v.patch),
118 ParsedVersion::Pep440(v) => v.release().get(2).copied(),
119 }
120 }
121
122 pub fn is_prerelease(&self) -> bool {
123 match &self.parsed {
124 ParsedVersion::SemVer(v) => !v.pre.is_empty(),
125 ParsedVersion::Pep440(v) => !v.is_post() && (v.is_pre() || v.is_dev()),
126 }
127 }
128
129 pub fn compare(&self, other: &Version) -> Result<std::cmp::Ordering, GrammarMismatch> {
130 if self.grammar != other.grammar {
131 return Err(GrammarMismatch {
132 left: self.grammar,
133 right: other.grammar,
134 });
135 }
136 match (&self.parsed, &other.parsed) {
137 (ParsedVersion::SemVer(a), ParsedVersion::SemVer(b)) => Ok(a.cmp(b)),
138 (ParsedVersion::Pep440(a), ParsedVersion::Pep440(b)) => Ok(a.cmp(b)),
139 _ => unreachable!(),
140 }
141 }
142
143 pub fn partial_compare(&self, other: &Version) -> Option<std::cmp::Ordering> {
144 self.compare(other).ok()
145 }
146}
147
148impl std::fmt::Display for Version {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 write!(f, "{}", self.raw)
151 }
152}
153
154impl Serialize for Version {
155 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
156 where
157 S: Serializer,
158 {
159 serializer.serialize_str(self.render())
160 }
161}
162
163impl<'de> Deserialize<'de> for Version {
164 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179 where
180 D: Deserializer<'de>,
181 {
182 let s = String::deserialize(deserializer)?;
183 Version::parse(&s, VersionGrammar::SemVer)
184 .or_else(|_| Version::parse(&s, VersionGrammar::Pep440))
185 .map_err(serde::de::Error::custom)
186 }
187}
188
189#[derive(Clone, Debug, PartialEq, Eq, Hash)]
190pub(crate) enum ParsedVersionReq {
191 SemVer(semver::VersionReq),
192 Pep440(pep440_rs::VersionSpecifiers),
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
197#[schemars(with = "String")]
198pub struct VersionReq {
199 grammar: VersionGrammar,
200 ecosystem: Ecosystem,
201 #[schemars(skip)]
202 req: ParsedVersionReq,
203 raw: String,
204}
205
206impl VersionReq {
207 pub fn parse(raw: &str, ecosystem: Ecosystem) -> Result<Self, VersionParseError> {
208 let grammar = ecosystem.version_grammar();
209 match grammar {
210 VersionGrammar::SemVer => {
211 let req = semver::VersionReq::parse(raw).map_err(|e| VersionParseError {
212 raw: raw.to_string(),
213 grammar,
214 message: e.to_string(),
215 })?;
216 Ok(VersionReq {
217 grammar,
218 ecosystem,
219 req: ParsedVersionReq::SemVer(req),
220 raw: raw.to_string(),
221 })
222 }
223 VersionGrammar::Pep440 => {
224 let req = raw
225 .parse::<pep440_rs::VersionSpecifiers>()
226 .map_err(|e| VersionParseError {
227 raw: raw.to_string(),
228 grammar,
229 message: e.to_string(),
230 })?;
231 let canonical = req.to_string();
236 Ok(VersionReq {
237 grammar,
238 ecosystem,
239 req: ParsedVersionReq::Pep440(req),
240 raw: canonical,
241 })
242 }
243 VersionGrammar::Maven => Err(VersionParseError {
244 raw: raw.to_string(),
245 grammar,
246 message: format!("{grammar:?} version requirements not implemented"),
247 }),
248 }
249 }
250
251 pub fn render(&self) -> &str {
252 &self.raw
253 }
254
255 pub fn ecosystem(&self) -> Ecosystem {
256 self.ecosystem
257 }
258
259 pub fn matches(&self, v: &Version) -> Result<bool, GrammarMismatch> {
260 if self.grammar != v.grammar() {
261 return Err(GrammarMismatch {
262 left: self.grammar,
263 right: v.grammar(),
264 });
265 }
266 match (&self.req, &v.parsed) {
267 (ParsedVersionReq::SemVer(req), ParsedVersion::SemVer(sv)) => Ok(req.matches(sv)),
268 (ParsedVersionReq::Pep440(req), ParsedVersion::Pep440(pv)) => Ok(req.contains(pv)),
269 _ => unreachable!(),
270 }
271 }
272}
273
274impl Serialize for VersionReq {
275 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
276 where
277 S: Serializer,
278 {
279 serializer.serialize_str(self.render())
280 }
281}
282
283impl<'de> Deserialize<'de> for VersionReq {
284 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
285 where
286 D: Deserializer<'de>,
287 {
288 let s = String::deserialize(deserializer)?;
289 VersionReq::parse(&s, Ecosystem::Cargo)
290 .or_else(|_| VersionReq::parse(&s, Ecosystem::Npm))
291 .or_else(|_| VersionReq::parse(&s, Ecosystem::Pypi))
292 .map_err(serde::de::Error::custom)
293 }
294}
295
296#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
297#[error("`{raw}` is not a valid {grammar:?} version: {message}")]
298#[diagnostic(
299 code(E029),
300 help("Ensure the version string strictly adheres to the {grammar:?} specification.")
301)]
302pub struct VersionParseError {
303 pub raw: String,
304 pub grammar: VersionGrammar,
305 pub message: String,
306}
307
308#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, miette::Diagnostic)]
309#[error("cannot compare a {left:?} version with a {right:?} version")]
310#[diagnostic(
311 code(E034),
312 help("All version comparisons in a cascade step must share the same version grammar.")
313)]
314pub struct GrammarMismatch {
315 pub left: VersionGrammar,
316 pub right: VersionGrammar,
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::hash::Hash as _;
323
324 #[test]
325 fn parses_valid_semver_and_exposes_components() {
326 let v = Version::parse("1.2.3", VersionGrammar::SemVer).unwrap();
327 assert_eq!(v.grammar(), VersionGrammar::SemVer);
328 assert_eq!(v.major(), Some(1));
329 assert_eq!(v.minor(), Some(2));
330 assert_eq!(v.patch(), Some(3));
331 assert!(!v.is_prerelease());
332 }
333
334 #[test]
335 fn serde_version_roundtrips() {
336 let v = Version::parse("1.2.3", VersionGrammar::SemVer).unwrap();
337 let json = serde_json::to_string(&v).unwrap();
338 assert_eq!(json, "\"1.2.3\"");
339 let deserialized: Version = serde_json::from_str(&json).unwrap();
340 assert_eq!(v, deserialized);
341 }
342
343 #[test]
344 fn parses_valid_pep440_and_prerelease() {
345 let v = Version::parse("0.3.2a1", VersionGrammar::Pep440).unwrap();
346 assert_eq!(v.grammar(), VersionGrammar::Pep440);
347 assert_eq!(v.major(), Some(0));
348 assert_eq!(v.minor(), Some(3));
349 assert_eq!(v.patch(), Some(2));
350 assert!(v.is_prerelease());
351
352 let req = VersionReq::parse(">=0.3.0", Ecosystem::Pypi).unwrap();
353 assert!(req.matches(&v).unwrap());
354 }
355
356 #[test]
357 fn pep440_dev_release_is_prerelease() {
358 let v = Version::parse("1.0.0.dev1", VersionGrammar::Pep440).unwrap();
362 assert!(v.is_prerelease(), "1.0.0.dev1 must be considered a pre-release");
363
364 let v2 = Version::parse("2.0.0.dev0", VersionGrammar::Pep440).unwrap();
365 assert!(v2.is_prerelease(), "2.0.0.dev0 must be considered a pre-release");
366 }
367
368 #[test]
369 fn pep440_non_canonical_inputs_normalize_to_equal_versions() {
370 let dash = Version::parse("1.0.0-alpha1", VersionGrammar::Pep440).unwrap();
371 let underscore = Version::parse("1.0.0_alpha1", VersionGrammar::Pep440).unwrap();
372 let canonical = Version::parse("1.0.0a1", VersionGrammar::Pep440).unwrap();
373
374 assert_eq!(dash, canonical);
375 assert_eq!(underscore, canonical);
376
377 let mut hasher_dash = std::collections::hash_map::DefaultHasher::new();
378 dash.hash(&mut hasher_dash);
379 let mut hasher_canonical = std::collections::hash_map::DefaultHasher::new();
380 canonical.hash(&mut hasher_canonical);
381 assert_eq!(
382 std::hash::Hasher::finish(&hasher_dash),
383 std::hash::Hasher::finish(&hasher_canonical)
384 );
385 }
386
387 #[test]
390 fn pep440_parse_malformed_string_returns_err_not_panic() {
391 let result = Version::parse("garbage-not-a-version", VersionGrammar::Pep440);
392 assert!(result.is_err());
393 let err = result.unwrap_err();
394 assert_eq!(err.grammar, VersionGrammar::Pep440);
395 assert_eq!(err.raw, "garbage-not-a-version");
396 }
397
398 #[test]
403 fn pep440_prerelease_marker_is_case_insensitive() {
404 let upper = Version::parse("1.0.0A1", VersionGrammar::Pep440).unwrap();
405 let lower = Version::parse("1.0.0a1", VersionGrammar::Pep440).unwrap();
406 assert_eq!(upper, lower);
407 assert_eq!(upper.render(), "1.0.0a1");
408 assert_eq!(lower.render(), "1.0.0a1");
409 }
410
411 #[test]
417 fn pep440_whitespace_padded_input_is_trimmed_and_accepted() {
418 let v = Version::parse(" 1.0.0a1 ", VersionGrammar::Pep440).unwrap();
419 assert_eq!(v.render(), "1.0.0a1");
420 }
421
422 #[test]
423 fn semver_whitespace_padded_input_is_rejected() {
424 let result = Version::parse(" 1.0.0 ", VersionGrammar::SemVer);
425 assert!(result.is_err());
426 }
427
428 #[test]
437 fn version_deserialize_falls_back_to_pep440_for_pep440_only_strings() {
438 let pep440_only = Version::parse("1.2.3a1", VersionGrammar::Pep440).unwrap();
440 assert_eq!(pep440_only.grammar(), VersionGrammar::Pep440);
441
442 let json = serde_json::to_string(&pep440_only).unwrap();
443 assert_eq!(json, "\"1.2.3a1\"");
444
445 let round_tripped: Version = serde_json::from_str(&json).unwrap();
446 assert_eq!(round_tripped, pep440_only);
447 assert_eq!(round_tripped.grammar(), VersionGrammar::Pep440);
448 }
449
450 #[test]
451 fn pep440_post_dev_version_is_not_prerelease() {
452 let v = Version::parse("1.2.3.post1.dev1", VersionGrammar::Pep440).unwrap();
457 assert!(
458 !v.is_prerelease(),
459 "1.2.3.post1.dev1 is above 1.2.3 in PEP 440 and must not be a pre-release"
460 );
461 }
462
463 #[test]
464 fn pep440_version_req_non_canonical_normalizes_for_eq() {
465 let upper = VersionReq::parse(">=1.0.0A1", Ecosystem::Pypi).unwrap();
470 let lower = VersionReq::parse(">=1.0.0a1", Ecosystem::Pypi).unwrap();
471 assert_eq!(
472 upper, lower,
473 ">=1.0.0A1 and >=1.0.0a1 are the same PEP 440 specifier and must be equal"
474 );
475 }
476
477 #[test]
481 fn version_deserialize_rejects_strings_invalid_under_both_grammars() {
482 let json = "\"not-a-version-at-all!!!\"";
483 let result: Result<Version, _> = serde_json::from_str(json);
484 assert!(result.is_err());
485 }
486
487 #[test]
493 fn version_deserialize_prefers_semver_when_string_is_valid_under_both_grammars() {
494 let json = "\"1.2.3\"";
495 let v: Version = serde_json::from_str(json).unwrap();
496 assert_eq!(v.grammar(), VersionGrammar::SemVer);
497 }
498}