use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
pub enum BumpType {
None,
Patch,
Minor,
Major,
}
impl std::fmt::Display for BumpType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BumpType::None => write!(f, "none"),
BumpType::Patch => write!(f, "patch"),
BumpType::Minor => write!(f, "minor"),
BumpType::Major => write!(f, "major"),
}
}
}
static BREAKING_RE: OnceLock<Regex> = OnceLock::new();
static FEAT_RE: OnceLock<Regex> = OnceLock::new();
fn breaking_header_re() -> &'static Regex {
BREAKING_RE.get_or_init(|| {
Regex::new(r"^(feat|fix|refactor|perf|build|chore|docs|style|test|ci)(\(.+\))?!:").unwrap()
})
}
fn feat_header_re() -> &'static Regex {
FEAT_RE.get_or_init(|| Regex::new(r"^feat(\(.+\))?:").unwrap())
}
static BREAKING_FOOTER_RE: OnceLock<Regex> = OnceLock::new();
fn breaking_footer_re() -> &'static Regex {
BREAKING_FOOTER_RE.get_or_init(|| Regex::new(r"(?mi)^BREAKING[ -]CHANGE: ").unwrap())
}
static BREAKING_SCOPE_BANG_RE: OnceLock<Regex> = OnceLock::new();
fn breaking_scope_bang_re() -> &'static Regex {
BREAKING_SCOPE_BANG_RE.get_or_init(|| {
Regex::new(r"^(feat|fix|refactor|perf|build|chore|docs|style|test|ci)\([^()]*!\):").unwrap()
})
}
pub fn determine_bump(message: &str) -> BumpType {
match classify_commit(message) {
CommitCategory::Breaking => BumpType::Major,
CommitCategory::Feature => BumpType::Minor,
CommitCategory::Fix | CommitCategory::Refactor => BumpType::Patch,
CommitCategory::Other => BumpType::None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommitCategory {
Breaking,
Feature,
Fix,
Refactor,
Other,
}
pub fn classify_commit(message: &str) -> CommitCategory {
let header = parse_subject(message);
if breaking_header_re().is_match(header)
|| breaking_scope_bang_re().is_match(header)
|| breaking_footer_re().is_match(message)
{
return CommitCategory::Breaking;
}
if feat_header_re().is_match(header) {
return CommitCategory::Feature;
}
if fix_perf_header_re().is_match(header) {
return CommitCategory::Fix;
}
if refactor_header_re().is_match(header) {
return CommitCategory::Refactor;
}
CommitCategory::Other
}
static FIX_PERF_RE: OnceLock<Regex> = OnceLock::new();
static REFACTOR_RE: OnceLock<Regex> = OnceLock::new();
fn fix_perf_header_re() -> &'static Regex {
FIX_PERF_RE.get_or_init(|| Regex::new(r"^(fix|perf)(\(.+\))?:").unwrap())
}
fn refactor_header_re() -> &'static Regex {
REFACTOR_RE.get_or_init(|| Regex::new(r"^refactor(\(.+\))?:").unwrap())
}
pub fn parse_subject(message: &str) -> &str {
message.lines().next().unwrap_or("").trim()
}
static HEADER_RE: OnceLock<Regex> = OnceLock::new();
fn header_re() -> &'static Regex {
HEADER_RE.get_or_init(|| {
Regex::new(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^()]+)\))?(?P<bang>!)?:\s*(?P<desc>.*)$")
.unwrap()
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedHeader<'a> {
pub commit_type: &'a str,
pub scope: Option<&'a str>,
pub breaking_bang: bool,
pub description: &'a str,
}
pub fn parse_header(message: &str) -> Option<ParsedHeader<'_>> {
let subject = parse_subject(message);
let caps = header_re().captures(subject)?;
let raw_scope = caps.name("scope").map(|m| m.as_str());
let scope_bang = raw_scope.is_some_and(|s| s.ends_with('!'));
let scope = raw_scope
.map(|s| s.strip_suffix('!').unwrap_or(s))
.filter(|s| !s.is_empty());
Some(ParsedHeader {
commit_type: caps.name("type")?.as_str(),
scope,
breaking_bang: caps.name("bang").is_some() || scope_bang,
description: caps.name("desc").map(|m| m.as_str()).unwrap_or(""),
})
}
pub fn is_breaking(message: &str) -> bool {
matches!(classify_commit(message), CommitCategory::Breaking)
}
pub fn breaking_footer_body(message: &str) -> Option<String> {
let re = breaking_footer_re();
let mat = re.find(message)?;
let body = message[mat.end()..].trim();
if body.is_empty() {
None
} else {
Some(body.lines().next().unwrap_or("").trim().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_patch() {
assert_eq!(determine_bump("fix: correct typo"), BumpType::Patch);
assert_eq!(determine_bump("perf: faster query"), BumpType::Patch);
assert_eq!(determine_bump("refactor: clean up"), BumpType::Patch);
}
#[test]
fn test_minor() {
assert_eq!(determine_bump("feat: add login"), BumpType::Minor);
assert_eq!(determine_bump("feat(auth): add JWT"), BumpType::Minor);
}
#[test]
fn test_major() {
assert_eq!(determine_bump("feat!: breaking change"), BumpType::Major);
assert_eq!(
determine_bump("fix(api)!: remove endpoint"),
BumpType::Major
);
assert_eq!(
determine_bump("BREAKING CHANGE: removed X"),
BumpType::Major
);
}
#[test]
fn test_none() {
assert_eq!(determine_bump("chore: update deps"), BumpType::None);
assert_eq!(determine_bump("docs: update readme"), BumpType::None);
assert_eq!(determine_bump("ci: fix pipeline"), BumpType::None);
}
#[test]
fn test_parse_subject() {
assert_eq!(parse_subject("feat: add login"), "feat: add login");
assert_eq!(
parse_subject("feat: add login\n\nbody text"),
"feat: add login"
);
assert_eq!(parse_subject(" spaced "), "spaced");
assert_eq!(parse_subject(""), "");
}
#[test]
fn test_scoped_commits() {
assert_eq!(determine_bump("fix(api): null check"), BumpType::Patch);
assert_eq!(determine_bump("feat(ui): new button"), BumpType::Minor);
assert_eq!(determine_bump("refactor(db): simplify"), BumpType::Patch);
}
#[test]
fn test_breaking_change_in_body() {
let msg = "feat: something\n\nBREAKING CHANGE: removed old API";
assert_eq!(determine_bump(msg), BumpType::Major);
}
#[test]
fn test_breaking_change_hyphen_footer() {
let msg = "feat: something\n\nBREAKING-CHANGE: removed old API";
assert_eq!(determine_bump(msg), BumpType::Major);
}
#[test]
fn test_breaking_change_prose_is_not_major() {
assert_eq!(
determine_bump("docs: note that BREAKING CHANGES are coming in v2"),
BumpType::None
);
let body = "feat: add flag\n\nBREAKING CHANGE will be handled later, not yet";
assert_eq!(determine_bump(body), BumpType::Minor);
let plural = "chore: cleanup\n\nBREAKING CHANGES: none in this one";
assert_eq!(determine_bump(plural), BumpType::None);
}
#[test]
fn test_breaking_change_footer_missing_space_after_colon() {
let msg = "feat: x\n\nBREAKING CHANGE:no-space-description";
assert_eq!(determine_bump(msg), BumpType::Minor);
}
#[test]
fn test_bump_ordering() {
assert!(BumpType::Major > BumpType::Minor);
assert!(BumpType::Minor > BumpType::Patch);
assert!(BumpType::Patch > BumpType::None);
}
#[test]
fn test_empty_message() {
assert_eq!(determine_bump(""), BumpType::None);
}
#[test]
fn test_whitespace_only_message() {
assert_eq!(determine_bump(" \n\n "), BumpType::None);
}
#[test]
fn test_non_conventional_message() {
assert_eq!(determine_bump("update readme"), BumpType::None);
assert_eq!(determine_bump("fixed the thing"), BumpType::None);
assert_eq!(determine_bump("WIP"), BumpType::None);
}
#[test]
fn test_all_patch_types() {
assert_eq!(determine_bump("fix: something"), BumpType::Patch);
assert_eq!(determine_bump("perf: something"), BumpType::Patch);
assert_eq!(determine_bump("refactor: something"), BumpType::Patch);
}
#[test]
fn test_all_none_types() {
assert_eq!(determine_bump("chore: something"), BumpType::None);
assert_eq!(determine_bump("docs: something"), BumpType::None);
assert_eq!(determine_bump("ci: something"), BumpType::None);
assert_eq!(determine_bump("style: something"), BumpType::None);
assert_eq!(determine_bump("test: something"), BumpType::None);
assert_eq!(determine_bump("build: something"), BumpType::None);
}
#[test]
fn test_breaking_all_types() {
assert_eq!(determine_bump("fix!: breaking fix"), BumpType::Major);
assert_eq!(determine_bump("refactor!: breaking"), BumpType::Major);
assert_eq!(determine_bump("perf!: breaking"), BumpType::Major);
assert_eq!(determine_bump("chore!: breaking"), BumpType::Major);
assert_eq!(determine_bump("docs!: breaking"), BumpType::Major);
assert_eq!(determine_bump("style!: breaking"), BumpType::Major);
assert_eq!(determine_bump("test!: breaking"), BumpType::Major);
assert_eq!(determine_bump("build!: breaking"), BumpType::Major);
assert_eq!(determine_bump("ci!: breaking"), BumpType::Major);
}
#[test]
fn test_breaking_with_scope() {
assert_eq!(determine_bump("chore(deps)!: breaking"), BumpType::Major);
assert_eq!(determine_bump("build(npm)!: breaking"), BumpType::Major);
}
#[test]
fn test_breaking_change_in_body_multiline() {
let msg = "feat: add feature\n\nSome description.\n\nBREAKING CHANGE: removed old API";
assert_eq!(determine_bump(msg), BumpType::Major);
}
#[test]
fn test_parse_subject_multiline() {
assert_eq!(
parse_subject("first line\nsecond line\nthird line"),
"first line"
);
}
#[test]
fn test_parse_subject_empty() {
assert_eq!(parse_subject(""), "");
}
#[test]
fn test_bump_type_display() {
assert_eq!(format!("{}", BumpType::None), "none");
assert_eq!(format!("{}", BumpType::Patch), "patch");
assert_eq!(format!("{}", BumpType::Minor), "minor");
assert_eq!(format!("{}", BumpType::Major), "major");
}
#[test]
fn test_feat_not_in_middle_of_word() {
assert_eq!(determine_bump("featured something"), BumpType::None);
}
#[test]
fn test_deep_nested_scope() {
assert_eq!(
determine_bump("feat(api/auth/jwt): add token"),
BumpType::Minor
);
assert_eq!(
determine_bump("fix(ui/modal): close on escape"),
BumpType::Patch
);
}
#[test]
fn test_uppercase_types_not_matched() {
assert_eq!(determine_bump("FEAT: add login"), BumpType::None);
assert_eq!(determine_bump("FIX: bug"), BumpType::None);
assert_eq!(determine_bump("Feat: add login"), BumpType::None);
}
#[test]
fn test_missing_colon() {
assert_eq!(determine_bump("feat add login"), BumpType::None);
assert_eq!(determine_bump("fix something"), BumpType::None);
}
#[test]
fn test_extra_space_after_type() {
assert_eq!(determine_bump("feat : add login"), BumpType::None);
}
#[test]
fn test_empty_scope() {
assert_eq!(determine_bump("feat(): add login"), BumpType::None);
assert_eq!(determine_bump("fix(): bug"), BumpType::None);
}
#[test]
fn test_breaking_change_not_at_line_start() {
let msg = "feat: something\n\nnot a BREAKING CHANGE here";
assert_eq!(determine_bump(msg), BumpType::Minor);
}
#[test]
fn test_parse_subject_crlf() {
assert_eq!(parse_subject("feat: add\r\nbody text"), "feat: add");
}
#[test]
fn test_parse_subject_only_newlines() {
assert_eq!(parse_subject("\n\n\n"), "");
}
#[test]
fn test_multiline_body_feat_in_body_does_not_match() {
let msg = "chore: update deps\n\nfeat: this is in the body";
assert_eq!(determine_bump(msg), BumpType::None);
}
#[test]
fn test_multiline_body_fix_in_body_does_not_match() {
let msg = "chore: update deps\n\nfix: this is in the body";
assert_eq!(determine_bump(msg), BumpType::None);
}
#[test]
fn test_multiline_body_breaking_marker_in_body_does_not_match() {
let msg = "chore: update deps\n\nfeat!: this is in the body";
assert_eq!(determine_bump(msg), BumpType::None);
}
#[test]
fn test_parse_header_type_scope_desc() {
let h = parse_header("feat(api): add events endpoint").unwrap();
assert_eq!(h.commit_type, "feat");
assert_eq!(h.scope, Some("api"));
assert!(!h.breaking_bang);
assert_eq!(h.description, "add events endpoint");
}
#[test]
fn test_parse_header_breaking_bang() {
let h = parse_header("feat!: drop flag").unwrap();
assert_eq!(h.commit_type, "feat");
assert_eq!(h.scope, None);
assert!(h.breaking_bang);
assert_eq!(h.description, "drop flag");
}
#[test]
fn test_parse_header_scoped_bang() {
let h = parse_header("fix(security)!: patch").unwrap();
assert_eq!(h.commit_type, "fix");
assert_eq!(h.scope, Some("security"));
assert!(h.breaking_bang);
}
#[test]
fn test_parse_header_non_conventional() {
assert!(parse_header("just a message").is_none());
assert!(parse_header("feat add no colon").is_none());
}
#[test]
fn test_breaking_footer_body_extracts_description() {
let msg = "feat: add x\n\nBREAKING CHANGE: the old endpoint is gone";
assert_eq!(
breaking_footer_body(msg).as_deref(),
Some("the old endpoint is gone")
);
}
#[test]
fn test_breaking_footer_body_none_when_absent() {
assert_eq!(breaking_footer_body("feat: add x"), None);
assert_eq!(breaking_footer_body("feat!: add x"), None);
}
#[test]
fn test_is_breaking() {
assert!(is_breaking("feat!: x"));
assert!(is_breaking("feat: x\n\nBREAKING CHANGE: y"));
assert!(!is_breaking("feat: x"));
assert!(!is_breaking("fix: y"));
}
#[test]
fn breaking_footer_case_and_hyphen_variants() {
for footer in [
"BREAKING CHANGE: gone",
"BREAKING-CHANGE: gone",
"breaking-change: gone",
"breaking change: gone",
"Breaking Change: gone",
] {
let msg = format!("feat: x\n\n{footer}");
assert_eq!(determine_bump(&msg), BumpType::Major, "footer: {footer:?}");
}
}
#[test]
fn breaking_footer_stays_strict_on_malformed_shapes() {
assert_eq!(
determine_bump("feat: x\n\nBreaking change:nospace"),
BumpType::Minor
);
assert_eq!(
determine_bump("chore: x\n\nbreaking changes: none"),
BumpType::None
);
assert_eq!(
determine_bump("feat: x\n\nthis is a breaking change: really"),
BumpType::Minor
);
}
#[test]
fn bang_inside_scope_is_breaking() {
assert_eq!(
determine_bump("feat(api!): remove endpoint"),
BumpType::Major
);
assert_eq!(determine_bump("fix(db!): drop table"), BumpType::Major);
assert_eq!(determine_bump("feat(a!b): middle"), BumpType::Minor);
}
#[test]
fn parse_header_normalizes_scope_internal_bang() {
let h = parse_header("feat(api!): remove endpoint").unwrap();
assert_eq!(h.commit_type, "feat");
assert_eq!(
h.scope,
Some("api"),
"the trailing ! is stripped from scope"
);
assert!(h.breaking_bang);
let empty = parse_header("feat(!): drop flag").unwrap();
assert_eq!(empty.scope, None);
assert!(empty.breaking_bang);
}
#[test]
fn fixtures_classify_by_directory() {
use std::path::Path;
let root =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/conventional_commits");
let mut checked = 0;
for (dir, expect_breaking) in [("breaking", true), ("not_breaking", false)] {
let subdir = root.join(dir);
let entries =
std::fs::read_dir(&subdir).unwrap_or_else(|e| panic!("read {subdir:?}: {e}"));
for entry in entries {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("txt") {
continue;
}
let message = std::fs::read_to_string(&path).unwrap();
assert_eq!(
is_breaking(&message),
expect_breaking,
"fixture {:?} should classify breaking={expect_breaking}",
path.file_name().unwrap()
);
checked += 1;
}
}
assert!(
checked >= 14,
"fixture corpus looks unloaded — only {checked} messages checked"
);
}
}