Skip to main content

callisto_format/
bump.rs

1use callisto_model::{Severity, Version, VersionGrammar};
2
3/// Trait for version arithmetic per versioning grammar.
4pub trait Versioning: Send + Sync {
5    fn grammar(&self) -> VersionGrammar;
6    fn bump(&self, current: &Version, severity: Severity) -> Result<Version, BumpError>;
7    fn bump_prerelease(
8        &self,
9        base: &Version,
10        severity: Severity,
11        tag: &str,
12        current: &Version,
13    ) -> Result<Version, BumpError>;
14}
15
16/// SemVer versioning arithmetic implementation matching @changesets/cli semantics.
17pub struct SemVerVersioning;
18
19static SEMVER_VERSIONING: SemVerVersioning = SemVerVersioning;
20
21/// PEP 440 versioning arithmetic implementation.
22///
23/// Bump semantics (deliberate, see the test matrix below):
24/// - Release segment is `major.minor.patch`, matching SemVer's shape; any
25///   4th+ segment is dropped by a bump.
26/// - Epoch (`N!`) has no SemVer analogue -- carries through unchanged.
27/// - Pre-releases (`aN`/`bN`/`rcN`) and dev-releases (`.devN`) sort
28///   *before* the final release (PEP 440: `devN < aN < bN < rcN < final`),
29///   so a bump finalizes them in place like the SemVer prerelease branch:
30///   `Patch` drops the tag; `Minor`/`Major` advance the next component up
31///   only when the lower one is already zero.
32/// - Post-releases (`.postN`) sort *after* the final release (`final <
33///   .postN`), so the base release is already shipped: a bump increments
34///   normally (as if no pre/dev/post suffix existed) and drops the post
35///   segment, rather than continuing the post sequence.
36/// - Local version labels (`+localsuffix`) are always dropped.
37pub struct Pep440Versioning;
38
39static PEP440_VERSIONING: Pep440Versioning = Pep440Versioning;
40
41pub fn versioning_for(grammar: VersionGrammar) -> Option<&'static dyn Versioning> {
42    match grammar {
43        VersionGrammar::SemVer => Some(&SEMVER_VERSIONING),
44        VersionGrammar::Pep440 => Some(&PEP440_VERSIONING),
45        _ => None,
46    }
47}
48
49impl Versioning for SemVerVersioning {
50    fn grammar(&self) -> VersionGrammar {
51        VersionGrammar::SemVer
52    }
53
54    fn bump(&self, current: &Version, severity: Severity) -> Result<Version, BumpError> {
55        if current.grammar() != VersionGrammar::SemVer {
56            return Err(BumpError::NotSemVer {
57                raw: current.render().to_string(),
58                grammar: current.grammar(),
59            });
60        }
61
62        if severity == Severity::None {
63            return Ok(current.clone());
64        }
65
66        let major = current.major().unwrap_or(0);
67        let minor = current.minor().unwrap_or(0);
68        let patch = current.patch().unwrap_or(0);
69
70        let overflow = || BumpError::Overflow {
71            raw: current.render().to_string(),
72        };
73
74        if current.is_prerelease() {
75            let base = Version::semver(major, minor, patch);
76            let bumped = match severity {
77                Severity::Patch => base,
78                Severity::Minor => {
79                    if patch == 0 {
80                        base
81                    } else {
82                        Version::semver(major, minor.checked_add(1).ok_or_else(overflow)?, 0)
83                    }
84                }
85                Severity::Major => {
86                    if minor == 0 && patch == 0 {
87                        base
88                    } else {
89                        Version::semver(major.checked_add(1).ok_or_else(overflow)?, 0, 0)
90                    }
91                }
92                Severity::None => current.clone(),
93            };
94            return Ok(bumped);
95        }
96
97        let bumped = match severity {
98            Severity::Major => Version::semver(major.checked_add(1).ok_or_else(overflow)?, 0, 0),
99            Severity::Minor => Version::semver(major, minor.checked_add(1).ok_or_else(overflow)?, 0),
100            Severity::Patch => Version::semver(major, minor, patch.checked_add(1).ok_or_else(overflow)?),
101            Severity::None => current.clone(),
102        };
103
104        Ok(bumped)
105    }
106
107    fn bump_prerelease(
108        &self,
109        base: &Version,
110        severity: Severity,
111        tag: &str,
112        current: &Version,
113    ) -> Result<Version, BumpError> {
114        if severity == Severity::None {
115            return Ok(current.clone());
116        }
117
118        let release = self.bump(base, severity)?;
119
120        let mut counter = 0;
121        if current.is_prerelease() {
122            let current_raw = current.render();
123            if let Some((rel_part, pre_part)) = current_raw.split_once('-') {
124                if rel_part == release.render() {
125                    let dotted_prefix = format!("{tag}.");
126                    if let Some(num_str) = pre_part.strip_prefix(&dotted_prefix) {
127                        // Dotted form: e.g. "alpha.3" with tag "alpha" → num_str = "3"
128                        if let Ok(num) = num_str.parse::<u64>() {
129                            counter = num.checked_add(1).ok_or_else(|| BumpError::Overflow {
130                                raw: current.render().to_string(),
131                            })?;
132                        }
133                    } else if let Some(num_str) = pre_part.strip_prefix(tag) {
134                        // Undotted form: e.g. "alpha1" with tag "alpha" → num_str = "1"
135                        if let Ok(num) = num_str.parse::<u64>() {
136                            counter = num.checked_add(1).ok_or_else(|| BumpError::Overflow {
137                                raw: current.render().to_string(),
138                            })?;
139                        }
140                    }
141                }
142            }
143        }
144
145        let prerelease_str = format!("{}-{tag}.{counter}", release.render());
146        let final_version =
147            Version::parse(&prerelease_str, VersionGrammar::SemVer).map_err(|_err| BumpError::NotSemVer {
148                raw: prerelease_str,
149                grammar: VersionGrammar::SemVer,
150            })?;
151
152        Ok(final_version)
153    }
154}
155
156impl Versioning for Pep440Versioning {
157    fn grammar(&self) -> VersionGrammar {
158        VersionGrammar::Pep440
159    }
160
161    fn bump(&self, current: &Version, severity: Severity) -> Result<Version, BumpError> {
162        if current.grammar() != VersionGrammar::Pep440 {
163            return Err(BumpError::NotPep440 {
164                raw: current.render().to_string(),
165                grammar: current.grammar(),
166            });
167        }
168
169        if severity == Severity::None {
170            return Ok(current.clone());
171        }
172
173        let parsed = parse_pep440(current)?;
174        let epoch = parsed.epoch();
175        let (major, minor, patch) = release_triple(&parsed);
176
177        // Pre-releases and dev-releases both precede the release they're attached
178        // to, so a bump finalizes them in place rather than continuing forward.
179        // But a post-release segment (even combined with a dev segment, e.g.
180        // `1.2.3.post1.dev1`) outranks the final release (PEP 440 ordering:
181        // dev < pre < release < post), so any version carrying a post segment
182        // must bump forward normally, same as the post-only case, never
183        // finalize in place.
184        let finalize_in_place = !parsed.is_post() && (parsed.is_pre() || parsed.is_dev());
185
186        let overflow = || BumpError::Overflow {
187            raw: current.render().to_string(),
188        };
189
190        let (new_major, new_minor, new_patch) = if finalize_in_place {
191            match severity {
192                Severity::Patch => (major, minor, patch),
193                Severity::Minor => {
194                    if patch == 0 {
195                        (major, minor, patch)
196                    } else {
197                        (major, minor.checked_add(1).ok_or_else(overflow)?, 0)
198                    }
199                }
200                Severity::Major => {
201                    if minor == 0 && patch == 0 {
202                        (major, minor, patch)
203                    } else {
204                        (major.checked_add(1).ok_or_else(overflow)?, 0, 0)
205                    }
206                }
207                Severity::None => unreachable!("handled above"),
208            }
209        } else {
210            match severity {
211                Severity::Major => (major.checked_add(1).ok_or_else(overflow)?, 0, 0),
212                Severity::Minor => (major, minor.checked_add(1).ok_or_else(overflow)?, 0),
213                Severity::Patch => (major, minor, patch.checked_add(1).ok_or_else(overflow)?),
214                Severity::None => unreachable!("handled above"),
215            }
216        };
217
218        let bumped = pep440_rs::Version::new([new_major, new_minor, new_patch]).with_epoch(epoch);
219        render_pep440(bumped)
220    }
221
222    fn bump_prerelease(
223        &self,
224        base: &Version,
225        severity: Severity,
226        tag: &str,
227        current: &Version,
228    ) -> Result<Version, BumpError> {
229        if severity == Severity::None {
230            return Ok(current.clone());
231        }
232
233        let release = self.bump(base, severity)?;
234        let release_str = release.render();
235
236        // PEP 440 pre-release labels are restricted to alpha/beta/rc; an arbitrary
237        // caller-supplied tag (e.g. "next") has no PEP 440 equivalent, so it falls
238        // back to a dev-release, the closest "not yet final" concept PEP 440 has.
239        let letter = pep440_prerelease_letter(tag);
240
241        let mut counter = 0u64;
242        let current_raw = current.render();
243        if let Some(rest) = current_raw.strip_prefix(release_str) {
244            let num_str = match letter {
245                Some(letter) => rest.strip_prefix(letter),
246                None => rest.strip_prefix(".dev"),
247            };
248            if let Some(num_str) = num_str {
249                if let Ok(num) = num_str.parse::<u64>() {
250                    counter = num.checked_add(1).ok_or_else(|| BumpError::Overflow {
251                        raw: current.render().to_string(),
252                    })?;
253                }
254            }
255        }
256
257        let prerelease_str = match letter {
258            Some(letter) => format!("{release_str}{letter}{counter}"),
259            None => format!("{release_str}.dev{counter}"),
260        };
261
262        Version::parse(&prerelease_str, VersionGrammar::Pep440).map_err(|e| BumpError::ComputedVersionInvalid {
263            raw: prerelease_str,
264            message: e.message,
265        })
266    }
267}
268
269/// Maps common pre-release tag spellings to their PEP 440 letter. PEP 440 only
270/// recognizes alpha/beta/rc pre-releases; anything else has no direct mapping.
271fn pep440_prerelease_letter(tag: &str) -> Option<&'static str> {
272    match tag.to_ascii_lowercase().as_str() {
273        "a" | "alpha" => Some("a"),
274        "b" | "beta" => Some("b"),
275        "rc" | "c" | "pre" | "preview" => Some("rc"),
276        _ => None,
277    }
278}
279
280fn parse_pep440(v: &Version) -> Result<pep440_rs::Version, BumpError> {
281    v.raw()
282        .parse::<pep440_rs::Version>()
283        .map_err(|e| BumpError::ComputedVersionInvalid {
284            raw: v.raw().to_string(),
285            message: e.to_string(),
286        })
287}
288
289fn release_triple(v: &pep440_rs::Version) -> (u64, u64, u64) {
290    let release = v.release();
291    (
292        release.first().copied().unwrap_or(0),
293        release.get(1).copied().unwrap_or(0),
294        release.get(2).copied().unwrap_or(0),
295    )
296}
297
298fn render_pep440(v: pep440_rs::Version) -> Result<Version, BumpError> {
299    let rendered = v.to_string();
300    Version::parse(&rendered, VersionGrammar::Pep440).map_err(|e| BumpError::ComputedVersionInvalid {
301        raw: rendered,
302        message: e.message,
303    })
304}
305
306pub fn bump_version(current: &Version, severity: Severity) -> Result<Version, BumpError> {
307    SEMVER_VERSIONING.bump(current, severity)
308}
309
310#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
311#[non_exhaustive]
312pub enum BumpError {
313    #[error("bump_version requires a SemVer version; `{raw}` was parsed as {grammar:?}")]
314    #[diagnostic(code(E035))]
315    NotSemVer { raw: String, grammar: VersionGrammar },
316    #[error("no versioning implementation exists for {grammar:?}")]
317    #[diagnostic(code(E036))]
318    UnsupportedGrammar { grammar: VersionGrammar },
319    #[error("bump requires a PEP 440 version; `{raw}` was parsed as {grammar:?}")]
320    #[diagnostic(code(E037))]
321    NotPep440 { raw: String, grammar: VersionGrammar },
322    #[error("internal error computing bumped version `{raw}`: {message}")]
323    #[diagnostic(code(E038))]
324    ComputedVersionInvalid { raw: String, message: String },
325    #[error("version component overflow while bumping `{raw}`")]
326    #[diagnostic(code(E039))]
327    Overflow { raw: String },
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use rstest::rstest;
334
335    #[rstest]
336    #[case("1.2.3", Severity::Patch, "1.2.4")]
337    #[case("1.2.3", Severity::Minor, "1.3.0")]
338    #[case("1.2.3", Severity::Major, "2.0.0")]
339    #[case("1.2.3", Severity::None, "1.2.3")]
340    #[case("1.2.3-alpha.0", Severity::Patch, "1.2.3")]
341    #[case("1.2.3-alpha.0", Severity::Minor, "1.3.0")]
342    #[case("1.2.3-alpha.0", Severity::Major, "2.0.0")]
343    #[case("1.0.0-beta.1", Severity::Patch, "1.0.0")]
344    #[case("1.0.0-beta.1", Severity::Minor, "1.0.0")]
345    #[case("1.0.0-beta.1", Severity::Major, "1.0.0")]
346    #[case("0.5.2", Severity::Major, "1.0.0")]
347    fn test_semver_bump_matrix(#[case] input: &str, #[case] sev: Severity, #[case] expected: &str) {
348        let v = Version::parse(input, VersionGrammar::SemVer).unwrap();
349        assert_eq!(bump_version(&v, sev).unwrap().render(), expected);
350    }
351
352    #[test]
353    fn bump_prerelease_monotonic_counter() {
354        let base = Version::parse("1.1.0", VersionGrammar::SemVer).unwrap();
355        let cur = Version::parse("1.1.0", VersionGrammar::SemVer).unwrap();
356        let pre0 = SEMVER_VERSIONING
357            .bump_prerelease(&base, Severity::Patch, "next", &cur)
358            .unwrap();
359        assert_eq!(pre0.render(), "1.1.1-next.0");
360
361        let pre1 = SEMVER_VERSIONING
362            .bump_prerelease(&base, Severity::Patch, "next", &pre0)
363            .unwrap();
364        assert_eq!(pre1.render(), "1.1.1-next.1");
365    }
366
367    #[test]
368    fn versioning_for_pep440_returns_an_impl() {
369        assert!(versioning_for(VersionGrammar::Pep440).is_some());
370    }
371
372    #[rstest]
373    // Plain releases: ordinary major/minor/patch arithmetic, same as SemVer.
374    #[case("1.2.3", Severity::Patch, "1.2.4")]
375    #[case("1.2.3", Severity::Minor, "1.3.0")]
376    #[case("1.2.3", Severity::Major, "2.0.0")]
377    #[case("1.2.3", Severity::None, "1.2.3")]
378    #[case("0.5.2", Severity::Major, "1.0.0")]
379    // Pre-releases (aN/bN/rcN) finalize toward the base release, mirroring the
380    // SemVer prerelease branch: a patch bump just drops the prerelease tag,
381    // minor/major only increment when the lower component is non-zero.
382    #[case("1.2.3a1", Severity::Patch, "1.2.3")]
383    #[case("1.2.3a1", Severity::Minor, "1.3.0")]
384    #[case("1.2.3b2", Severity::Major, "2.0.0")]
385    #[case("1.0.0rc1", Severity::Minor, "1.0.0")]
386    #[case("1.0.0rc1", Severity::Major, "1.0.0")]
387    // Dev-releases (.devN) precede the final release (PEP 440 ordering:
388    // devN < aN < bN < rcN < final), so they finalize the same way pre-releases do.
389    #[case("1.2.3.dev0", Severity::Patch, "1.2.3")]
390    #[case("1.2.3.dev0", Severity::Minor, "1.3.0")]
391    // Post-releases (.postN) come *after* an already-shipped final release
392    // (PEP 440 ordering: final < .postN), so a bump treats the release as
393    // already final: it increments normally and drops the post segment,
394    // rather than finalizing in place like a pre/dev release would.
395    #[case("1.2.3.post1", Severity::Patch, "1.2.4")]
396    #[case("1.2.3.post1", Severity::Minor, "1.3.0")]
397    // A post-release combined with a dev-release (e.g. `.post1.dev1`) still
398    // carries a post segment, which PEP 440 ranks *above* the final release
399    // even with a trailing dev segment attached (dev < pre < release < post).
400    // It must bump forward exactly like the post-only case above, never
401    // finalize in place (which would otherwise produce a version lower than
402    // the input).
403    #[case("1.2.3.post1.dev1", Severity::Patch, "1.2.4")]
404    // Epoch (N!) has no SemVer analogue and carries through unchanged.
405    #[case("1!2.0.0", Severity::Major, "1!3.0.0")]
406    #[case("1!2.0.0", Severity::Patch, "1!2.0.1")]
407    fn test_pep440_bump_matrix(#[case] input: &str, #[case] sev: Severity, #[case] expected: &str) {
408        let v = Version::parse(input, VersionGrammar::Pep440).unwrap();
409        let versioning = versioning_for(VersionGrammar::Pep440).expect("pep440 versioning should be registered");
410        assert_eq!(versioning.bump(&v, sev).unwrap().render(), expected);
411    }
412
413    #[test]
414    fn pep440_bump_rejects_non_pep440_grammar() {
415        let v = Version::parse("1.2.3", VersionGrammar::SemVer).unwrap();
416        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
417        let err = versioning.bump(&v, Severity::Patch).unwrap_err();
418        assert!(matches!(err, BumpError::NotPep440 { .. }));
419    }
420
421    #[test]
422    fn pep440_bump_prerelease_uses_recognized_letter_and_is_monotonic() {
423        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
424        let base = Version::parse("1.1.0", VersionGrammar::Pep440).unwrap();
425        let cur = Version::parse("1.1.0", VersionGrammar::Pep440).unwrap();
426
427        let pre0 = versioning.bump_prerelease(&base, Severity::Patch, "rc", &cur).unwrap();
428        assert_eq!(pre0.render(), "1.1.1rc0");
429
430        let pre1 = versioning.bump_prerelease(&base, Severity::Patch, "rc", &pre0).unwrap();
431        assert_eq!(pre1.render(), "1.1.1rc1");
432    }
433
434    #[test]
435    fn pep440_bump_prerelease_falls_back_to_dev_for_unrecognized_tag() {
436        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
437        let base = Version::parse("1.1.0", VersionGrammar::Pep440).unwrap();
438        let cur = Version::parse("1.1.0", VersionGrammar::Pep440).unwrap();
439
440        let pre0 = versioning
441            .bump_prerelease(&base, Severity::Patch, "next", &cur)
442            .unwrap();
443        assert_eq!(pre0.render(), "1.1.1.dev0");
444
445        let pre1 = versioning
446            .bump_prerelease(&base, Severity::Patch, "next", &pre0)
447            .unwrap();
448        assert_eq!(pre1.render(), "1.1.1.dev1");
449    }
450
451    /// Gap 1: epoch + post + dev combined in a single version. The epoch-carry
452    /// logic and the "post outranks dev/pre, always bump forward" branch have
453    /// only ever been exercised separately (see `1!2.0.0` and `.post1.dev1`
454    /// cases above); this exercises both simultaneously. Observed/expected:
455    /// the epoch is preserved unchanged and the release bumps forward exactly
456    /// as the post-only case does (post always wins over the attached dev tag).
457    #[rstest]
458    #[case("1!2.3.post1.dev1", Severity::Patch, "1!2.3.1")]
459    #[case("1!2.3.post1.dev1", Severity::Minor, "1!2.4.0")]
460    #[case("1!2.3.post1.dev1", Severity::Major, "1!3.0.0")]
461    fn test_pep440_bump_epoch_post_dev_combined(#[case] input: &str, #[case] sev: Severity, #[case] expected: &str) {
462        let v = Version::parse(input, VersionGrammar::Pep440).unwrap();
463        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
464        assert_eq!(versioning.bump(&v, sev).unwrap().render(), expected);
465    }
466
467    /// Gap 2: a PEP 440 local version identifier (`+build.5`). Verified
468    /// empirically (not assumed from the doc comment): the local segment is
469    /// dropped cleanly by a bump, and the release arithmetic proceeds
470    /// normally on the release triple, matching the module doc comment's
471    /// claim that local labels are "always dropped by a bump".
472    #[rstest]
473    #[case("1.2.3+build.5", Severity::Patch, "1.2.4")]
474    #[case("1.2.3+build.5", Severity::Minor, "1.3.0")]
475    #[case("1.2.3+build.5", Severity::Major, "2.0.0")]
476    fn test_pep440_bump_drops_local_version_label(#[case] input: &str, #[case] sev: Severity, #[case] expected: &str) {
477        let v = Version::parse(input, VersionGrammar::Pep440).unwrap();
478        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
479        assert_eq!(versioning.bump(&v, sev).unwrap().render(), expected);
480    }
481
482    /// Gap 3: all-zero `0.0.0` base version bumped by Minor/Major. Plain
483    /// (non-prerelease) releases go through the ordinary forward-bump branch,
484    /// which has no zero-guard and behaves like any other release. The
485    /// `patch == 0` / `minor == 0 && patch == 0` boundary guards only exist in
486    /// the prerelease-finalization branch, so they are exercised here with an
487    /// all-zero *prerelease* base (`0.0.0-alpha.0` / `0.0.0a1`), where the
488    /// guard causes the bump to collapse back to the unchanged base `0.0.0`
489    /// instead of advancing past it.
490    #[test]
491    fn semver_zero_base_plain_bump() {
492        let v = Version::parse("0.0.0", VersionGrammar::SemVer).unwrap();
493        assert_eq!(bump_version(&v, Severity::Minor).unwrap().render(), "0.1.0");
494        let v = Version::parse("0.0.0", VersionGrammar::SemVer).unwrap();
495        assert_eq!(bump_version(&v, Severity::Major).unwrap().render(), "1.0.0");
496    }
497
498    #[test]
499    fn semver_zero_base_prerelease_bump_hits_zero_guards() {
500        let v = Version::parse("0.0.0-alpha.0", VersionGrammar::SemVer).unwrap();
501        assert_eq!(bump_version(&v, Severity::Minor).unwrap().render(), "0.0.0");
502        let v = Version::parse("0.0.0-alpha.0", VersionGrammar::SemVer).unwrap();
503        assert_eq!(bump_version(&v, Severity::Major).unwrap().render(), "0.0.0");
504    }
505
506    #[test]
507    fn pep440_zero_base_plain_bump() {
508        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
509        let v = Version::parse("0.0.0", VersionGrammar::Pep440).unwrap();
510        assert_eq!(versioning.bump(&v, Severity::Minor).unwrap().render(), "0.1.0");
511        let v = Version::parse("0.0.0", VersionGrammar::Pep440).unwrap();
512        assert_eq!(versioning.bump(&v, Severity::Major).unwrap().render(), "1.0.0");
513    }
514
515    #[test]
516    fn pep440_zero_base_prerelease_bump_hits_zero_guards() {
517        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
518        let v = Version::parse("0.0.0a1", VersionGrammar::Pep440).unwrap();
519        assert_eq!(versioning.bump(&v, Severity::Minor).unwrap().render(), "0.0.0");
520        let v = Version::parse("0.0.0a1", VersionGrammar::Pep440).unwrap();
521        assert_eq!(versioning.bump(&v, Severity::Major).unwrap().render(), "0.0.0");
522    }
523
524    /// Gap 4: a genuinely malformed PEP 440 string passed to the public parse
525    /// entry point returns a proper `Err`, never panics.
526    /// Regression test: an undotted prerelease suffix such as `alpha1` (common in
527    /// packages migrated from other toolchains) must have its numeric suffix
528    /// carried forward rather than reset to 0.  Before the fix, the only prefix
529    /// pattern tested was the dotted form `"{tag}."`, so `strip_prefix("alpha.")`
530    /// on `"alpha1"` returned `None`, the counter stayed at 0, and the output was
531    /// `1.2.3-alpha.0` — meaning consecutive bumps would silently lose the
532    /// existing sequence number.
533    ///
534    /// Note on SemVer ordering: `"alpha"` < `"alpha1"` lexicographically (the
535    /// dotted identifier `alpha` is a strict ASCII prefix of `alpha1`), so
536    /// `1.2.3-alpha.2 < 1.2.3-alpha1` per spec.  The invariant we can assert is
537    /// that the counter increments from the existing numeric suffix (not from 0),
538    /// so future same-format bumps remain monotonically increasing.
539    #[test]
540    fn bump_prerelease_from_undotted_tag_produces_higher_version() {
541        // Use base="1.2.2" + Patch so that bump(base, Patch)="1.2.3", matching
542        // the release segment of the current undotted prerelease versions below.
543        let base = Version::parse("1.2.2", VersionGrammar::SemVer).unwrap();
544        let buggy_base = Version::parse("1.2.3-alpha.0", VersionGrammar::SemVer).unwrap();
545
546        // 1.2.3-alpha1 → 1.2.3-alpha.2  (numeric suffix 1 → counter = 2)
547        let cur = Version::parse("1.2.3-alpha1", VersionGrammar::SemVer).unwrap();
548        let result = SEMVER_VERSIONING
549            .bump_prerelease(&base, Severity::Patch, "alpha", &cur)
550            .unwrap();
551        assert_eq!(result.render(), "1.2.3-alpha.2");
552        // The result must be greater than the buggy alpha.0 output that the
553        // pre-fix code would have produced, ensuring the sequence moves forward.
554        assert_eq!(
555            result.compare(&buggy_base).unwrap(),
556            std::cmp::Ordering::Greater,
557            "1.2.3-alpha.2 must be SemVer-greater than the buggy 1.2.3-alpha.0"
558        );
559
560        // 1.2.3-alpha9 → 1.2.3-alpha.10  (no off-by-one at boundary 9 → 10)
561        let cur9 = Version::parse("1.2.3-alpha9", VersionGrammar::SemVer).unwrap();
562        let result9 = SEMVER_VERSIONING
563            .bump_prerelease(&base, Severity::Patch, "alpha", &cur9)
564            .unwrap();
565        assert_eq!(result9.render(), "1.2.3-alpha.10");
566        assert_eq!(
567            result9.compare(&buggy_base).unwrap(),
568            std::cmp::Ordering::Greater,
569            "1.2.3-alpha.10 must be SemVer-greater than the buggy 1.2.3-alpha.0"
570        );
571    }
572
573    #[test]
574    fn pep440_parse_malformed_string_returns_err_not_panic() {
575        let result = Version::parse("not-a-version", VersionGrammar::Pep440);
576        assert!(result.is_err());
577    }
578
579    /// Gap 5: `Severity::None` applied to a post-tagged or dev-tagged input
580    /// leaves the version completely unchanged (early-return before any
581    /// finalize/bump-forward branch runs), same as it does for a plain
582    /// release.
583    #[rstest]
584    #[case("1.2.3.post1")]
585    #[case("1.2.3.dev0")]
586    #[case("1!2.3.post1.dev1")]
587    fn pep440_severity_none_leaves_post_and_dev_tagged_input_unchanged(#[case] input: &str) {
588        let v = Version::parse(input, VersionGrammar::Pep440).unwrap();
589        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
590        assert_eq!(versioning.bump(&v, Severity::None).unwrap().render(), v.render());
591    }
592
593    #[test]
594    fn test_semver_bump_major_at_max_returns_error() {
595        let input = format!("{}.0.0", u64::MAX);
596        let v = Version::parse(&input, VersionGrammar::SemVer).unwrap();
597        let err = bump_version(&v, Severity::Major).unwrap_err();
598        assert!(matches!(err, BumpError::Overflow { .. }));
599    }
600
601    #[test]
602    fn test_semver_bump_patch_at_max_returns_error() {
603        let input = format!("1.2.{}", u64::MAX);
604        let v = Version::parse(&input, VersionGrammar::SemVer).unwrap();
605        let err = bump_version(&v, Severity::Patch).unwrap_err();
606        assert!(matches!(err, BumpError::Overflow { .. }));
607    }
608
609    #[test]
610    fn test_pep440_bump_at_max_returns_error() {
611        let input = format!("{}.0.0", u64::MAX);
612        let v = Version::parse(&input, VersionGrammar::Pep440).unwrap();
613        let versioning = versioning_for(VersionGrammar::Pep440).unwrap();
614        let err = versioning.bump(&v, Severity::Major).unwrap_err();
615        assert!(matches!(err, BumpError::Overflow { .. }));
616    }
617}