Skip to main content

standard_version/
bump.rs

1//! Semantic version bump calculation from conventional commits.
2//!
3//! Computes the next version from a list of parsed conventional commits and
4//! bump rules.
5//!
6//! # Main entry points
7//!
8//! - [`determine_bump`] -- analyse commits and return the bump level
9//! - [`apply_bump`] -- apply a bump level to a semver version
10//! - [`apply_prerelease`] -- bump with a pre-release tag (e.g. `rc.0`)
11//! - [`summarise`] -- count commits by category for display
12
13use standard_commit::ConventionalCommit;
14
15/// The level of version bump to apply.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum BumpLevel {
18    /// Bug fix -- increment the patch component.
19    Patch,
20    /// New feature -- increment the minor component.
21    Minor,
22    /// Breaking change -- increment the major component.
23    Major,
24}
25
26/// Analyse a list of conventional commits and return the highest applicable
27/// bump level.
28///
29/// Bump rules follow the [Conventional Commits](https://www.conventionalcommits.org/)
30/// specification:
31/// - `feat` → [`BumpLevel::Minor`]
32/// - `fix` or `perf` → [`BumpLevel::Patch`]
33/// - `BREAKING CHANGE` footer or `!` suffix → [`BumpLevel::Major`]
34///
35/// Returns `None` when no bump-worthy commits exist (e.g. only `chore`,
36/// `docs`, `refactor`).
37pub fn determine_bump(commits: &[ConventionalCommit]) -> Option<BumpLevel> {
38    let mut level: Option<BumpLevel> = None;
39
40    for commit in commits {
41        let bump = commit_bump(commit);
42        if let Some(b) = bump {
43            level = Some(match level {
44                Some(current) => current.max(b),
45                None => b,
46            });
47        }
48    }
49
50    level
51}
52
53/// Determine the bump level for a single commit.
54fn commit_bump(commit: &ConventionalCommit) -> Option<BumpLevel> {
55    // Breaking change (footer or `!` suffix) always yields Major.
56    if commit.is_breaking {
57        return Some(BumpLevel::Major);
58    }
59    for footer in &commit.footers {
60        if footer.token == "BREAKING CHANGE" || footer.token == "BREAKING-CHANGE" {
61            return Some(BumpLevel::Major);
62        }
63    }
64
65    match commit.r#type.as_str() {
66        "feat" => Some(BumpLevel::Minor),
67        "fix" | "perf" | "revert" => Some(BumpLevel::Patch),
68        _ => None,
69    }
70}
71
72/// Apply a bump level to a semver version, returning the new version.
73///
74/// Resets lower components to zero (e.g. minor bump `1.2.3` → `1.3.0`).
75///
76/// For versions `< 1.0.0`, bump levels are downshifted following the
77/// Rust/Cargo pre-1.0 convention:
78/// - `Major` (breaking) → bumps minor (`0.10.2` → `0.11.0`)
79/// - `Minor` (feature) → bumps patch (`0.10.2` → `0.10.3`)
80/// - `Patch` (fix) → bumps patch (`0.10.2` → `0.10.3`)
81///
82/// For versions `>= 1.0.0`, behaviour is unchanged.
83pub fn apply_bump(current: &semver::Version, level: BumpLevel) -> semver::Version {
84    let mut next = current.clone();
85    // Clear any pre-release or build metadata.
86    next.pre = semver::Prerelease::EMPTY;
87    next.build = semver::BuildMetadata::EMPTY;
88
89    // Pre-1.0: downshift bump levels (Major→Minor, Minor→Patch, Patch→Patch).
90    let effective = if current.major == 0 {
91        match level {
92            BumpLevel::Major => BumpLevel::Minor,
93            BumpLevel::Minor | BumpLevel::Patch => BumpLevel::Patch,
94        }
95    } else {
96        level
97    };
98
99    match effective {
100        BumpLevel::Major => {
101            next.major += 1;
102            next.minor = 0;
103            next.patch = 0;
104        }
105        BumpLevel::Minor => {
106            next.minor += 1;
107            next.patch = 0;
108        }
109        BumpLevel::Patch => {
110            next.patch += 1;
111        }
112    }
113
114    next
115}
116
117/// Apply a pre-release bump. If the current version already has a pre-release
118/// tag matching `tag`, the numeric suffix is incremented. Otherwise, `.0` is
119/// appended to the bumped version.
120///
121/// Example: `1.0.0` + Minor + tag `"rc"` → `1.1.0-rc.0`
122/// Example: `1.1.0-rc.0` + tag `"rc"` → `1.1.0-rc.1`
123pub fn apply_prerelease(current: &semver::Version, level: BumpLevel, tag: &str) -> semver::Version {
124    // If already a pre-release with the same tag prefix, just bump the counter.
125    if !current.pre.is_empty() {
126        let pre_str = current.pre.as_str();
127        if let Some(rest) = pre_str.strip_prefix(tag)
128            && let Some(num_str) = rest.strip_prefix('.')
129            && let Ok(n) = num_str.parse::<u64>()
130        {
131            let mut next = current.clone();
132            next.pre = semver::Prerelease::new(&format!("{tag}.{}", n + 1)).unwrap_or_default();
133            next.build = semver::BuildMetadata::EMPTY;
134            return next;
135        }
136    }
137
138    // Otherwise, bump normally then append the pre-release tag.
139    let mut next = apply_bump(current, level);
140    next.pre = semver::Prerelease::new(&format!("{tag}.0")).unwrap_or_default();
141    next
142}
143
144/// Summary of analysed commits for display purposes.
145///
146/// Counts commits by category. A single commit may increment both
147/// `breaking_count` and its type count (e.g. a breaking `feat` increments
148/// both `feat_count` and `breaking_count`).
149#[derive(Debug, Default)]
150pub struct BumpSummary {
151    /// Count of `feat` commits.
152    pub feat_count: usize,
153    /// Count of `fix` commits.
154    pub fix_count: usize,
155    /// Count of commits with breaking changes.
156    pub breaking_count: usize,
157    /// Count of other conventional commits (perf, refactor, etc.).
158    pub other_count: usize,
159}
160
161/// Summarise a list of conventional commits for display purposes.
162pub fn summarise(commits: &[ConventionalCommit]) -> BumpSummary {
163    let mut summary = BumpSummary::default();
164    for commit in commits {
165        let is_breaking = commit.is_breaking
166            || commit
167                .footers
168                .iter()
169                .any(|f| f.token == "BREAKING CHANGE" || f.token == "BREAKING-CHANGE");
170        if is_breaking {
171            summary.breaking_count += 1;
172        }
173        match commit.r#type.as_str() {
174            "feat" => summary.feat_count += 1,
175            "fix" => summary.fix_count += 1,
176            _ => summary.other_count += 1,
177        }
178    }
179    summary
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use standard_commit::Footer;
186
187    fn commit(typ: &str, breaking: bool) -> ConventionalCommit {
188        ConventionalCommit {
189            r#type: typ.to_string(),
190            scope: None,
191            description: "test".to_string(),
192            body: None,
193            footers: vec![],
194            is_breaking: breaking,
195        }
196    }
197
198    fn commit_with_footer(typ: &str, footer_token: &str) -> ConventionalCommit {
199        ConventionalCommit {
200            r#type: typ.to_string(),
201            scope: None,
202            description: "test".to_string(),
203            body: None,
204            footers: vec![Footer {
205                token: footer_token.to_string(),
206                value: "some breaking change".to_string(),
207            }],
208            is_breaking: false,
209        }
210    }
211
212    #[test]
213    fn no_commits_returns_none() {
214        assert_eq!(determine_bump(&[]), None);
215    }
216
217    #[test]
218    fn non_bump_commits_return_none() {
219        let commits = vec![commit("chore", false), commit("docs", false)];
220        assert_eq!(determine_bump(&commits), None);
221    }
222
223    #[test]
224    fn fix_yields_patch() {
225        let commits = vec![commit("fix", false)];
226        assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
227    }
228
229    #[test]
230    fn perf_yields_patch() {
231        let commits = vec![commit("perf", false)];
232        assert_eq!(determine_bump(&commits), Some(BumpLevel::Patch));
233    }
234
235    #[test]
236    fn feat_yields_minor() {
237        let commits = vec![commit("feat", false)];
238        assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
239    }
240
241    #[test]
242    fn breaking_bang_yields_major() {
243        let commits = vec![commit("feat", true)];
244        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
245    }
246
247    #[test]
248    fn breaking_footer_yields_major() {
249        let commits = vec![commit_with_footer("fix", "BREAKING CHANGE")];
250        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
251    }
252
253    #[test]
254    fn breaking_change_hyphenated_footer() {
255        let commits = vec![commit_with_footer("fix", "BREAKING-CHANGE")];
256        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
257    }
258
259    #[test]
260    fn highest_bump_wins() {
261        let commits = vec![commit("fix", false), commit("feat", false)];
262        assert_eq!(determine_bump(&commits), Some(BumpLevel::Minor));
263    }
264
265    #[test]
266    fn breaking_beats_all() {
267        let commits = vec![
268            commit("fix", false),
269            commit("feat", false),
270            commit("chore", true),
271        ];
272        assert_eq!(determine_bump(&commits), Some(BumpLevel::Major));
273    }
274
275    #[test]
276    fn apply_bump_patch() {
277        let v = semver::Version::new(1, 2, 3);
278        assert_eq!(
279            apply_bump(&v, BumpLevel::Patch),
280            semver::Version::new(1, 2, 4)
281        );
282    }
283
284    #[test]
285    fn apply_bump_minor() {
286        let v = semver::Version::new(1, 2, 3);
287        assert_eq!(
288            apply_bump(&v, BumpLevel::Minor),
289            semver::Version::new(1, 3, 0)
290        );
291    }
292
293    #[test]
294    fn apply_bump_major() {
295        let v = semver::Version::new(1, 2, 3);
296        assert_eq!(
297            apply_bump(&v, BumpLevel::Major),
298            semver::Version::new(2, 0, 0)
299        );
300    }
301
302    #[test]
303    fn apply_bump_clears_prerelease() {
304        let v = semver::Version::parse("1.2.3-rc.1").unwrap();
305        assert_eq!(
306            apply_bump(&v, BumpLevel::Patch),
307            semver::Version::new(1, 2, 4)
308        );
309    }
310
311    #[test]
312    fn apply_prerelease_new() {
313        let v = semver::Version::new(1, 0, 0);
314        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
315        assert_eq!(next, semver::Version::parse("1.1.0-rc.0").unwrap());
316    }
317
318    #[test]
319    fn apply_prerelease_increment() {
320        let v = semver::Version::parse("1.1.0-rc.0").unwrap();
321        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
322        assert_eq!(next, semver::Version::parse("1.1.0-rc.1").unwrap());
323    }
324
325    #[test]
326    fn apply_prerelease_different_tag() {
327        let v = semver::Version::parse("1.1.0-alpha.2").unwrap();
328        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
329        // Different tag → bump normally and start at 0.
330        assert_eq!(next, semver::Version::parse("1.2.0-rc.0").unwrap());
331    }
332
333    #[test]
334    fn summarise_counts() {
335        let commits = vec![
336            commit("feat", false),
337            commit("feat", false),
338            commit("fix", false),
339            commit("chore", true),
340            commit("refactor", false),
341        ];
342        let s = summarise(&commits);
343        assert_eq!(s.feat_count, 2);
344        assert_eq!(s.fix_count, 1);
345        assert_eq!(s.breaking_count, 1);
346        assert_eq!(s.other_count, 2); // chore + refactor
347    }
348
349    #[test]
350    fn bump_level_ordering() {
351        assert!(BumpLevel::Major > BumpLevel::Minor);
352        assert!(BumpLevel::Minor > BumpLevel::Patch);
353    }
354
355    // ── Pre-1.0 semver convention ──────────────────────────────────
356
357    #[test]
358    fn pre1_breaking_bumps_minor() {
359        let v = semver::Version::new(0, 10, 2);
360        assert_eq!(
361            apply_bump(&v, BumpLevel::Major),
362            semver::Version::new(0, 11, 0)
363        );
364    }
365
366    #[test]
367    fn pre1_feat_bumps_patch() {
368        let v = semver::Version::new(0, 10, 2);
369        assert_eq!(
370            apply_bump(&v, BumpLevel::Minor),
371            semver::Version::new(0, 10, 3)
372        );
373    }
374
375    #[test]
376    fn pre1_fix_bumps_patch() {
377        let v = semver::Version::new(0, 10, 2);
378        assert_eq!(
379            apply_bump(&v, BumpLevel::Patch),
380            semver::Version::new(0, 10, 3)
381        );
382    }
383
384    #[test]
385    fn pre1_zero_minor_breaking_bumps_minor() {
386        let v = semver::Version::new(0, 0, 5);
387        assert_eq!(
388            apply_bump(&v, BumpLevel::Major),
389            semver::Version::new(0, 1, 0)
390        );
391    }
392
393    #[test]
394    fn pre1_zero_minor_feat_bumps_patch() {
395        let v = semver::Version::new(0, 0, 5);
396        assert_eq!(
397            apply_bump(&v, BumpLevel::Minor),
398            semver::Version::new(0, 0, 6)
399        );
400    }
401
402    #[test]
403    fn post1_major_unchanged() {
404        let v = semver::Version::new(1, 2, 3);
405        assert_eq!(
406            apply_bump(&v, BumpLevel::Major),
407            semver::Version::new(2, 0, 0)
408        );
409    }
410
411    #[test]
412    fn pre1_clears_prerelease_metadata() {
413        let v = semver::Version::parse("0.3.0-rc.2").unwrap();
414        assert_eq!(
415            apply_bump(&v, BumpLevel::Major),
416            semver::Version::new(0, 4, 0)
417        );
418    }
419
420    #[test]
421    fn pre1_prerelease_breaking() {
422        let v = semver::Version::new(0, 5, 0);
423        let next = apply_prerelease(&v, BumpLevel::Major, "rc");
424        assert_eq!(next, semver::Version::parse("0.6.0-rc.0").unwrap());
425    }
426
427    #[test]
428    fn pre1_prerelease_feat() {
429        let v = semver::Version::new(0, 5, 0);
430        let next = apply_prerelease(&v, BumpLevel::Minor, "rc");
431        assert_eq!(next, semver::Version::parse("0.5.1-rc.0").unwrap());
432    }
433}