Skip to main content

sr_core/
version.rs

1use std::fmt;
2
3use semver::Version;
4
5use crate::commit::{CommitClassifier, ConventionalCommit};
6
7/// The kind of version bump to apply.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum BumpLevel {
10    Patch,
11    Minor,
12    Major,
13}
14
15impl fmt::Display for BumpLevel {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            BumpLevel::Patch => write!(f, "patch"),
19            BumpLevel::Minor => write!(f, "minor"),
20            BumpLevel::Major => write!(f, "major"),
21        }
22    }
23}
24
25/// Determine the highest bump level from a set of conventional commits.
26///
27/// Returns `None` if no commits warrant a release.
28pub fn determine_bump(
29    commits: &[ConventionalCommit],
30    classifier: &dyn CommitClassifier,
31) -> Option<BumpLevel> {
32    commits
33        .iter()
34        .filter_map(|c| classifier.bump_level(&c.r#type, c.breaking))
35        .max()
36}
37
38/// Apply a bump level to a version, returning the new version.
39pub fn apply_bump(version: &Version, bump: BumpLevel) -> Version {
40    match bump {
41        BumpLevel::Major => Version::new(version.major + 1, 0, 0),
42        BumpLevel::Minor => Version::new(version.major, version.minor + 1, 0),
43        BumpLevel::Patch => Version::new(version.major, version.minor, version.patch + 1),
44    }
45}
46
47/// Apply a bump and produce a pre-release version.
48///
49/// Given a base version (the latest stable tag) and existing pre-release tags,
50/// computes the next pre-release: `X.Y.Z-<id>.N`.
51///
52/// - If the bumped version matches existing pre-release tags with the same id,
53///   increments N (e.g. `1.1.0-alpha.1` → `1.1.0-alpha.2`).
54/// - Otherwise starts at `.1`.
55pub fn apply_prerelease_bump(
56    version: &Version,
57    bump: BumpLevel,
58    prerelease_id: &str,
59    existing_tags: &[Version],
60) -> Version {
61    let base = apply_bump(version, bump);
62
63    // Find the highest existing pre-release number for this base + id
64    let max_n = existing_tags
65        .iter()
66        .filter(|v| v.major == base.major && v.minor == base.minor && v.patch == base.patch)
67        .filter_map(|v| {
68            let pre = v.pre.as_str();
69            let suffix = pre.strip_prefix(prerelease_id)?.strip_prefix('.')?;
70            suffix.parse::<u64>().ok()
71        })
72        .max()
73        .unwrap_or(0);
74
75    let mut result = base;
76    result.pre = semver::Prerelease::new(&format!("{prerelease_id}.{}", max_n + 1)).unwrap();
77    result
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::commit::{ConventionalCommit, DefaultCommitClassifier};
84
85    fn commit(type_: &str, breaking: bool) -> ConventionalCommit {
86        ConventionalCommit {
87            sha: "abc1234".into(),
88            r#type: type_.into(),
89            scope: None,
90            description: "test".into(),
91            body: None,
92            breaking,
93        }
94    }
95
96    fn classifier() -> DefaultCommitClassifier {
97        DefaultCommitClassifier::default()
98    }
99
100    #[test]
101    fn patch_bump() {
102        let v = Version::new(1, 2, 3);
103        assert_eq!(apply_bump(&v, BumpLevel::Patch), Version::new(1, 2, 4));
104    }
105
106    #[test]
107    fn minor_bump_resets_patch() {
108        let v = Version::new(1, 2, 3);
109        assert_eq!(apply_bump(&v, BumpLevel::Minor), Version::new(1, 3, 0));
110    }
111
112    #[test]
113    fn major_bump_resets_minor_and_patch() {
114        let v = Version::new(1, 2, 3);
115        assert_eq!(apply_bump(&v, BumpLevel::Major), Version::new(2, 0, 0));
116    }
117
118    #[test]
119    fn no_commits_returns_none() {
120        assert_eq!(determine_bump(&[], &classifier()), None);
121    }
122
123    #[test]
124    fn non_releasable_types_return_none() {
125        let commits = vec![
126            commit("chore", false),
127            commit("docs", false),
128            commit("ci", false),
129        ];
130        assert_eq!(determine_bump(&commits, &classifier()), None);
131    }
132
133    #[test]
134    fn single_fix_returns_patch() {
135        assert_eq!(
136            determine_bump(&[commit("fix", false)], &classifier()),
137            Some(BumpLevel::Patch)
138        );
139    }
140
141    #[test]
142    fn single_feat_returns_minor() {
143        assert_eq!(
144            determine_bump(&[commit("feat", false)], &classifier()),
145            Some(BumpLevel::Minor)
146        );
147    }
148
149    #[test]
150    fn perf_returns_patch() {
151        assert_eq!(
152            determine_bump(&[commit("perf", false)], &classifier()),
153            Some(BumpLevel::Patch)
154        );
155    }
156
157    #[test]
158    fn breaking_returns_major() {
159        assert_eq!(
160            determine_bump(&[commit("feat", true)], &classifier()),
161            Some(BumpLevel::Major)
162        );
163    }
164
165    #[test]
166    fn highest_bump_wins() {
167        let commits = vec![
168            commit("fix", false),
169            commit("feat", false),
170            commit("feat", true),
171        ];
172        assert_eq!(
173            determine_bump(&commits, &classifier()),
174            Some(BumpLevel::Major)
175        );
176    }
177
178    #[test]
179    fn feat_beats_fix() {
180        let commits = vec![commit("fix", false), commit("feat", false)];
181        assert_eq!(
182            determine_bump(&commits, &classifier()),
183            Some(BumpLevel::Minor)
184        );
185    }
186
187    // --- pre-release version tests ---
188
189    #[test]
190    fn prerelease_first_alpha() {
191        let v = Version::new(1, 0, 0);
192        let result = apply_prerelease_bump(&v, BumpLevel::Minor, "alpha", &[]);
193        assert_eq!(result.to_string(), "1.1.0-alpha.1");
194    }
195
196    #[test]
197    fn prerelease_increments_counter() {
198        let v = Version::new(1, 0, 0);
199        let existing = vec![
200            Version::parse("1.1.0-alpha.1").unwrap(),
201            Version::parse("1.1.0-alpha.2").unwrap(),
202        ];
203        let result = apply_prerelease_bump(&v, BumpLevel::Minor, "alpha", &existing);
204        assert_eq!(result.to_string(), "1.1.0-alpha.3");
205    }
206
207    #[test]
208    fn prerelease_different_id_starts_at_1() {
209        let v = Version::new(1, 0, 0);
210        let existing = vec![Version::parse("1.1.0-alpha.5").unwrap()];
211        let result = apply_prerelease_bump(&v, BumpLevel::Minor, "beta", &existing);
212        assert_eq!(result.to_string(), "1.1.0-beta.1");
213    }
214
215    #[test]
216    fn prerelease_different_base_starts_at_1() {
217        let v = Version::new(1, 0, 0);
218        let existing = vec![Version::parse("1.1.0-alpha.3").unwrap()];
219        // Major bump → 2.0.0-alpha.1 (not 1.1.0-alpha.4)
220        let result = apply_prerelease_bump(&v, BumpLevel::Major, "alpha", &existing);
221        assert_eq!(result.to_string(), "2.0.0-alpha.1");
222    }
223
224    #[test]
225    fn prerelease_rc_identifier() {
226        let v = Version::new(2, 3, 0);
227        let result = apply_prerelease_bump(&v, BumpLevel::Patch, "rc", &[]);
228        assert_eq!(result.to_string(), "2.3.1-rc.1");
229    }
230}