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