1use callisto_model::{Severity, Version, VersionGrammar};
2
3pub 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
16pub struct SemVerVersioning;
18
19static SEMVER_VERSIONING: SemVerVersioning = SemVerVersioning;
20
21pub 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 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 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 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 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
269fn 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 #[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 #[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 #[case("1.2.3.dev0", Severity::Patch, "1.2.3")]
390 #[case("1.2.3.dev0", Severity::Minor, "1.3.0")]
391 #[case("1.2.3.post1", Severity::Patch, "1.2.4")]
396 #[case("1.2.3.post1", Severity::Minor, "1.3.0")]
397 #[case("1.2.3.post1.dev1", Severity::Patch, "1.2.4")]
404 #[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 #[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 #[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 #[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 #[test]
540 fn bump_prerelease_from_undotted_tag_produces_higher_version() {
541 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 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 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 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 #[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}