use crate::core::semver::BumpType;
pub const STANDARD_TYPES: &[&str] = &[
"feat", "fix", "perf", "refactor", "chore", "docs", "style", "test", "build", "ci",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionalCommit {
pub commit_type: String,
pub scope: Option<String>,
pub is_breaking: bool,
pub description: String,
}
pub fn parse_conventional_commit(message: &str) -> Option<ConventionalCommit> {
let first_line = message.lines().next()?.trim();
let (header_prefix, description) = first_line.split_once(": ")?;
let description = description.trim();
if description.is_empty() {
return None;
}
let (prefix_without_bang, bang) = if let Some(stripped) = header_prefix.strip_suffix('!') {
(stripped, true)
} else {
(header_prefix, false)
};
let (commit_type, scope) = if let Some((t, rest)) = prefix_without_bang.split_once('(') {
let scope_val = rest.strip_suffix(')')?;
(t.trim(), Some(scope_val.trim().to_string()))
} else {
(prefix_without_bang.trim(), None)
};
if !STANDARD_TYPES.contains(&commit_type) {
return None;
}
let is_breaking = bang
|| message.contains("BREAKING CHANGE:")
|| message.contains("BREAKING-CHANGE:")
|| message.contains("BREAKING CHANGE ");
Some(ConventionalCommit {
commit_type: commit_type.to_string(),
scope,
is_breaking,
description: description.to_string(),
})
}
pub fn determine_bump(commits: &[ConventionalCommit]) -> BumpType {
if commits.iter().any(|c| c.is_breaking) {
BumpType::Major
} else if commits.iter().any(|c| c.commit_type == "feat") {
BumpType::Minor
} else {
BumpType::Patch
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_feat() {
let commit = parse_conventional_commit("feat: add search endpoint").unwrap();
assert_eq!(commit.commit_type, "feat");
assert_eq!(commit.scope, None);
assert!(!commit.is_breaking);
assert_eq!(commit.description, "add search endpoint");
}
#[test]
fn test_parse_scoped_fix() {
let commit = parse_conventional_commit("fix(router): resolve redirect loop").unwrap();
assert_eq!(commit.commit_type, "fix");
assert_eq!(commit.scope, Some("router".to_string()));
assert!(!commit.is_breaking);
assert_eq!(commit.description, "resolve redirect loop");
}
#[test]
fn test_parse_breaking_with_bang() {
let commit = parse_conventional_commit("feat!: drop v1 API support").unwrap();
assert_eq!(commit.commit_type, "feat");
assert_eq!(commit.scope, None);
assert!(commit.is_breaking);
assert_eq!(commit.description, "drop v1 API support");
let commit_scoped = parse_conventional_commit("refactor(db)!: change schema").unwrap();
assert_eq!(commit_scoped.commit_type, "refactor");
assert_eq!(commit_scoped.scope, Some("db".to_string()));
assert!(commit_scoped.is_breaking);
}
#[test]
fn test_parse_breaking_in_body() {
let msg = "fix: update auth flow\n\nBREAKING CHANGE: credentials format changed";
let commit = parse_conventional_commit(msg).unwrap();
assert_eq!(commit.commit_type, "fix");
assert!(commit.is_breaking);
let msg_hyphen = "chore: update deps\n\nBREAKING-CHANGE: requires node 20";
let commit_hyphen = parse_conventional_commit(msg_hyphen).unwrap();
assert!(commit_hyphen.is_breaking);
}
#[test]
fn test_parse_non_conventional() {
assert!(parse_conventional_commit("Initial commit").is_none());
assert!(parse_conventional_commit("Merge branch 'main'").is_none());
assert!(parse_conventional_commit("customtype: something").is_none());
assert!(parse_conventional_commit("feat: ").is_none());
}
#[test]
fn test_determine_bump() {
let fix = ConventionalCommit {
commit_type: "fix".to_string(),
scope: None,
is_breaking: false,
description: "bug fix".to_string(),
};
let feat = ConventionalCommit {
commit_type: "feat".to_string(),
scope: None,
is_breaking: false,
description: "new feature".to_string(),
};
let breaking = ConventionalCommit {
commit_type: "fix".to_string(),
scope: None,
is_breaking: true,
description: "breaking fix".to_string(),
};
assert_eq!(determine_bump(&[]), BumpType::Patch);
assert_eq!(determine_bump(std::slice::from_ref(&fix)), BumpType::Patch);
assert_eq!(
determine_bump(&[fix.clone(), feat.clone()]),
BumpType::Minor
);
assert_eq!(determine_bump(&[fix, feat, breaking]), BumpType::Major);
}
}