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 fn versioning_for(grammar: VersionGrammar) -> Option<&'static dyn Versioning> {
22 match grammar {
23 VersionGrammar::SemVer => Some(&SEMVER_VERSIONING),
24 _ => None,
25 }
26}
27
28impl Versioning for SemVerVersioning {
29 fn grammar(&self) -> VersionGrammar {
30 VersionGrammar::SemVer
31 }
32
33 fn bump(&self, current: &Version, severity: Severity) -> Result<Version, BumpError> {
34 if current.grammar() != VersionGrammar::SemVer {
35 return Err(BumpError::NotSemVer {
36 raw: current.render().to_string(),
37 grammar: current.grammar(),
38 });
39 }
40
41 if severity == Severity::None {
42 return Ok(current.clone());
43 }
44
45 let major = current.major().unwrap_or(0);
46 let minor = current.minor().unwrap_or(0);
47 let patch = current.patch().unwrap_or(0);
48
49 if current.is_prerelease() {
50 let base = Version::semver(major, minor, patch);
51 let bumped = match severity {
52 Severity::Patch => base,
53 Severity::Minor => {
54 if patch == 0 {
55 base
56 } else {
57 Version::semver(major, minor + 1, 0)
58 }
59 }
60 Severity::Major => {
61 if minor == 0 && patch == 0 {
62 base
63 } else {
64 Version::semver(major + 1, 0, 0)
65 }
66 }
67 Severity::None => current.clone(),
68 };
69 return Ok(bumped);
70 }
71
72 let bumped = match severity {
73 Severity::Major => Version::semver(major + 1, 0, 0),
74 Severity::Minor => Version::semver(major, minor + 1, 0),
75 Severity::Patch => Version::semver(major, minor, patch + 1),
76 Severity::None => current.clone(),
77 };
78
79 Ok(bumped)
80 }
81
82 fn bump_prerelease(
83 &self,
84 base: &Version,
85 severity: Severity,
86 tag: &str,
87 current: &Version,
88 ) -> Result<Version, BumpError> {
89 if severity == Severity::None {
90 return Ok(current.clone());
91 }
92
93 let release = self.bump(base, severity)?;
94
95 let mut counter = 0;
96 if current.is_prerelease() {
97 let current_raw = current.render();
98 if let Some((rel_part, pre_part)) = current_raw.split_once('-') {
99 if rel_part == release.render() {
100 let expected_prefix = format!("{tag}.");
101 if let Some(num_str) = pre_part.strip_prefix(&expected_prefix) {
102 if let Ok(num) = num_str.parse::<u64>() {
103 counter = num + 1;
104 }
105 }
106 }
107 }
108 }
109
110 let prerelease_str = format!("{}-{tag}.{counter}", release.render());
111 let final_version =
112 Version::parse(&prerelease_str, VersionGrammar::SemVer).map_err(|_err| {
113 BumpError::NotSemVer {
114 raw: prerelease_str,
115 grammar: VersionGrammar::SemVer,
116 }
117 })?;
118
119 Ok(final_version)
120 }
121}
122
123pub fn bump_version(current: &Version, severity: Severity) -> Result<Version, BumpError> {
124 SEMVER_VERSIONING.bump(current, severity)
125}
126
127#[derive(Clone, Debug, thiserror::Error, miette::Diagnostic, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum BumpError {
130 #[error("bump_version requires a SemVer version; `{raw}` was parsed as {grammar:?}")]
131 #[diagnostic(code(E035))]
132 NotSemVer {
133 raw: String,
134 grammar: VersionGrammar,
135 },
136 #[error("no versioning implementation exists for {grammar:?}")]
137 #[diagnostic(code(E036))]
138 UnsupportedGrammar { grammar: VersionGrammar },
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use rstest::rstest;
145
146 #[rstest]
147 #[case("1.2.3", Severity::Patch, "1.2.4")]
148 #[case("1.2.3", Severity::Minor, "1.3.0")]
149 #[case("1.2.3", Severity::Major, "2.0.0")]
150 #[case("1.2.3", Severity::None, "1.2.3")]
151 #[case("1.2.3-alpha.0", Severity::Patch, "1.2.3")]
152 #[case("1.2.3-alpha.0", Severity::Minor, "1.3.0")]
153 #[case("1.2.3-alpha.0", Severity::Major, "2.0.0")]
154 #[case("1.0.0-beta.1", Severity::Patch, "1.0.0")]
155 #[case("1.0.0-beta.1", Severity::Minor, "1.0.0")]
156 #[case("1.0.0-beta.1", Severity::Major, "1.0.0")]
157 #[case("0.5.2", Severity::Major, "1.0.0")]
158 fn test_semver_bump_matrix(#[case] input: &str, #[case] sev: Severity, #[case] expected: &str) {
159 let v = Version::parse(input, VersionGrammar::SemVer).unwrap();
160 assert_eq!(bump_version(&v, sev).unwrap().render(), expected);
161 }
162
163 #[test]
164 fn bump_prerelease_monotonic_counter() {
165 let base = Version::parse("1.1.0", VersionGrammar::SemVer).unwrap();
166 let cur = Version::parse("1.1.0", VersionGrammar::SemVer).unwrap();
167 let pre0 = SEMVER_VERSIONING
168 .bump_prerelease(&base, Severity::Patch, "next", &cur)
169 .unwrap();
170 assert_eq!(pre0.render(), "1.1.1-next.0");
171
172 let pre1 = SEMVER_VERSIONING
173 .bump_prerelease(&base, Severity::Patch, "next", &pre0)
174 .unwrap();
175 assert_eq!(pre1.render(), "1.1.1-next.1");
176 }
177}