Skip to main content

anodizer_core/git/
conventional.rs

1//! Conventional-Commits → release-level classification.
2//!
3//! The single classifier behind both release-inference surfaces: the `tag`
4//! command's auto-bump (which layers `#token` / `#none` / `default_bump`
5//! precedence on top) and the `bump` command's per-crate inference. Both
6//! must answer "what does this commit imply for the next version?"
7//! identically, or `bump --dry-run` previews a different release than
8//! auto-tag actually cuts.
9
10/// Release level implied by a single Conventional-Commits message.
11///
12/// Variants are ordered `Patch < Minor < Major` so a range of commits can be
13/// folded with `max()` — the strongest signal in the range wins.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum ConventionalLevel {
16    /// `fix:` / `perf:` / `revert:` (and scoped variants).
17    Patch,
18    /// `feat:` (and scoped variants).
19    Minor,
20    /// A line containing `BREAKING CHANGE` / `BREAKING-CHANGE`, or a
21    /// `<type>!:` breaking shorthand.
22    Major,
23}
24
25/// Classify one commit message (subject + body) against the
26/// Conventional-Commits release rules.
27///
28/// Returns `None` for non-release-worthy commits: `chore:` / `docs:` /
29/// `style:` / `refactor:` / `test:` / `build:` / `ci:` typed subjects and
30/// unstructured messages.
31///
32/// Rules:
33/// - `BREAKING CHANGE` / `BREAKING-CHANGE` anywhere in the message → major.
34///   Matched as a bare substring (no trailing colon required) so a footer
35///   like `BREAKING CHANGE removed the old endpoint` still majors.
36/// - `<type>!:` / `<type>(scope)!:` breaking shorthand → major, for ANY type
37///   (`refactor!:` is still a breaking change).
38/// - `feat:` / `feat(scope):` → minor.
39/// - `fix:` / `perf:` / `revert:` (and scoped variants) → patch.
40pub fn classify_commit(msg: &str) -> Option<ConventionalLevel> {
41    if msg.contains("BREAKING CHANGE") || msg.contains("BREAKING-CHANGE") {
42        return Some(ConventionalLevel::Major);
43    }
44    // Only the subject line carries the type prefix.
45    let subject = msg.lines().next().unwrap_or("").trim_start();
46    let (ty, _rest) = subject.split_once(':')?;
47    // Strip a `(scope)` suffix and capture the post-scope `!` breaking marker.
48    let (head, marker) = ty.split_once('(').map_or((ty, ""), |(h, scope_rest)| {
49        // scope_rest is like `scope)!` or `scope)` — extract the post-`)` part.
50        let after_scope = scope_rest.split_once(')').map_or("", |x| x.1);
51        (h, after_scope)
52    });
53    if marker.starts_with('!') || ty.ends_with('!') {
54        return Some(ConventionalLevel::Major);
55    }
56    match head.trim() {
57        "feat" => Some(ConventionalLevel::Minor),
58        "fix" | "perf" | "revert" => Some(ConventionalLevel::Patch),
59        _ => None,
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn breaking_change_footer_is_major() {
69        assert_eq!(
70            classify_commit("feat(api): new endpoint\n\nBREAKING CHANGE: old endpoint removed"),
71            Some(ConventionalLevel::Major)
72        );
73    }
74
75    #[test]
76    fn breaking_change_without_colon_is_major() {
77        assert_eq!(
78            classify_commit("feat: x\n\nBREAKING CHANGE removed the old endpoint"),
79            Some(ConventionalLevel::Major)
80        );
81        assert_eq!(
82            classify_commit("fix: y\n\nBREAKING-CHANGE dropped the flag"),
83            Some(ConventionalLevel::Major)
84        );
85    }
86
87    #[test]
88    fn bang_shorthand_is_major_for_any_type() {
89        assert_eq!(
90            classify_commit("feat!: drop legacy auth"),
91            Some(ConventionalLevel::Major)
92        );
93        assert_eq!(
94            classify_commit("feat(core)!: rewrite pipeline"),
95            Some(ConventionalLevel::Major)
96        );
97        assert_eq!(
98            classify_commit("refactor!: drop the shim"),
99            Some(ConventionalLevel::Major)
100        );
101    }
102
103    #[test]
104    fn feat_is_minor() {
105        assert_eq!(
106            classify_commit("feat: new stage"),
107            Some(ConventionalLevel::Minor)
108        );
109        assert_eq!(
110            classify_commit("feat(build): add cache key"),
111            Some(ConventionalLevel::Minor)
112        );
113    }
114
115    #[test]
116    fn fix_perf_and_revert_are_patch() {
117        assert_eq!(classify_commit("fix: race"), Some(ConventionalLevel::Patch));
118        assert_eq!(
119            classify_commit("perf: faster loop"),
120            Some(ConventionalLevel::Patch)
121        );
122        assert_eq!(
123            classify_commit("revert: undo broken feature"),
124            Some(ConventionalLevel::Patch)
125        );
126    }
127
128    #[test]
129    fn non_release_worthy_types_are_none() {
130        assert_eq!(classify_commit("chore: update deps"), None);
131        assert_eq!(classify_commit("docs: fix link"), None);
132        assert_eq!(classify_commit("refactor: rename"), None);
133        assert_eq!(classify_commit("ci: pin runner"), None);
134    }
135
136    #[test]
137    fn unstructured_message_is_none() {
138        assert_eq!(classify_commit("random subject"), None);
139    }
140
141    #[test]
142    fn levels_order_by_strength_for_max_folds() {
143        assert!(ConventionalLevel::Patch < ConventionalLevel::Minor);
144        assert!(ConventionalLevel::Minor < ConventionalLevel::Major);
145    }
146}