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
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}