Skip to main content

callisto_model/
version.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3
4use crate::Ecosystem;
5
6/// §7.7. `SemVer` is the only grammar with an implementation in the committed v0.1–v0.4
7/// scope; the rest are declared so `Ecosystem::version_grammar` is total.
8#[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    /// PEP 440 (§7.7) — declared, not implemented.
14    Pep440,
15    /// Maven's qualifier-ordering comparator (§7.7) — declared, not implemented.
16    Maven,
17}
18
19/// The parsed form, kept alongside `raw` so comparison and component access are cheap.
20#[derive(Clone, Debug, PartialEq, Eq, Hash)]
21pub(crate) enum ParsedVersion {
22    SemVer(semver::Version),
23    Pep440(pep440_rs::Version),
24}
25
26/// A parsed version, tagged with the grammar it was parsed under. §7.7, P4.
27#[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                // PEP 440 defines multiple equivalent spellings for the same
58                // version (e.g. `1.0.0-alpha1`, `1.0.0_alpha1`, `1.0.0a1`).
59                // Normalize `raw` to pep440_rs's canonical rendering so that
60                // logically-equal inputs produce identical `raw` values, and
61                // therefore compare `==` and hash equal (derived PartialEq/Eq/
62                // Hash include `raw`). SemVer has no analogous normalization
63                // requirement (each version already has one canonical form),
64                // so the caller's literal input is preserved for that grammar.
65                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    /// Generic (grammar-unaware) deserialization. `Version`'s serialized
165    /// form is just its raw string, carrying no grammar info, so this can
166    /// only guess by trying grammars in turn -- same as
167    /// `VersionReq::deserialize` does for Cargo/Npm/Pypi.
168    ///
169    /// SemVer is tried first (strictest, most unambiguous); PEP 440 only
170    /// if that fails. A string valid under both (e.g. `1.2.3`) always
171    /// resolves to `VersionGrammar::SemVer`, never `Pep440` -- same
172    /// residual-ambiguity tradeoff `VersionReq::deserialize` accepts.
173    ///
174    /// Callers who know the intended grammar ahead of time (a sibling
175    /// field names it, or the value's from a known-SemVer-only source
176    /// like a git tag) should use [`Version::parse`] with an explicit
177    /// [`VersionGrammar`] instead.
178    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/// Parsed version requirement.
196#[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                // Normalize raw to pep440_rs's canonical rendering so that
232                // logically-equal specifiers (e.g. ">=1.0.0A1" vs ">=1.0.0a1")
233                // produce identical raw values and therefore compare == and hash
234                // equal (derived PartialEq/Eq/Hash include raw).
235                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        // PEP 440 dev releases (.devN) are pre-release in the sense that they
359        // precede the final release and must be finalized before shipping.
360        // is_prerelease() must return true for them, not just for alpha/beta/rc.
361        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    /// Gap 6: a genuinely malformed PEP 440 string returns a proper `Err`
388    /// from the public parse entry point, never a panic.
389    #[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    /// Gap 7: PEP 440 pre-release markers are case-insensitive per the spec
399    /// (`pep440_rs` normalizes both spellings to the same canonical `a1`
400    /// form), so `A1` and `a1` parse to equal, identically-rendered versions.
401    /// Verified empirically, not assumed.
402    #[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    /// Gap 8: whitespace-padded input. Verified empirically: `pep440_rs`
412    /// trims surrounding whitespace and accepts the input, normalizing `raw`
413    /// to the trimmed canonical form; `semver` does not trim and rejects
414    /// whitespace-padded input with a parse error. The two grammars behave
415    /// differently here, so both are pinned explicitly.
416    #[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    /// Gap 9 (RESOLVED): `Version::deserialize` now mirrors
429    /// `VersionReq::deserialize`'s multi-grammar fallback (§ see doc comment
430    /// on the `Deserialize` impl): SemVer is tried first since it is the
431    /// strictest, most unambiguous grammar, and PEP 440 is tried only if
432    /// SemVer parsing fails. A PEP-440-only version string (e.g. `1.2.3a1`,
433    /// which SemVer rejects because of the bare `a1` suffix) now round-trips
434    /// through `Version`'s serde impls and is recovered with
435    /// `VersionGrammar::Pep440`.
436    #[test]
437    fn version_deserialize_falls_back_to_pep440_for_pep440_only_strings() {
438        // Valid under PEP 440, but not valid SemVer.
439        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        // PEP 440 orders 1.2.3.post1.dev1 ABOVE 1.2.3 (it is a dev build of a
453        // post-release, not a pre-release of 1.2.3). is_prerelease() returning
454        // true here causes bump() to finalize-in-place to 1.2.3, which is lower
455        // than the input — wrong direction.
456        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        // VersionReq::parse stores raw: raw.to_string() (un-normalized) in the
466        // Pep440 arm. Two equivalent specifiers written differently hash/eq
467        // differently, causing silent dedup failures in callers that use
468        // VersionReq as a map key.
469        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    /// A string that a genuinely malformed value under both grammars still
478    /// produces a clear parse error, not a panic, and the error surfaces
479    /// after both attempts have failed.
480    #[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    /// Residual ambiguity, documented on the `Deserialize` impl: a string
488    /// that parses successfully under both grammars (e.g. plain
489    /// `major.minor.patch`) always resolves to `VersionGrammar::SemVer`,
490    /// since SemVer is tried first. This mirrors the same tradeoff already
491    /// accepted by `VersionReq::deserialize`'s Cargo/Npm/Pypi fallback chain.
492    #[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}