use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::error::ReleaseError;
use crate::version::BumpLevel;
#[derive(Debug, Clone)]
pub struct Commit {
pub sha: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ConventionalCommit {
pub sha: String,
pub r#type: String,
pub scope: Option<String>,
pub description: String,
pub body: Option<String>,
pub breaking: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CommitType {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bump: Option<BumpLevel>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
pub trait CommitClassifier: Send + Sync {
fn types(&self) -> &[CommitType];
fn pattern(&self) -> &str;
fn bump_level(&self, type_name: &str, breaking: bool) -> Option<BumpLevel> {
if breaking {
return Some(BumpLevel::Major);
}
self.types().iter().find(|t| t.name == type_name)?.bump
}
fn changelog_section(&self, type_name: &str) -> Option<&str> {
self.types()
.iter()
.find(|t| t.name == type_name)?
.section
.as_deref()
}
fn is_allowed(&self, type_name: &str) -> bool {
self.types().iter().any(|t| t.name == type_name)
}
}
pub const DEFAULT_COMMIT_PATTERN: &str =
r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)";
pub struct DefaultCommitClassifier {
types: Vec<CommitType>,
pattern: String,
}
impl DefaultCommitClassifier {
pub fn new(types: Vec<CommitType>, pattern: String) -> Self {
Self { types, pattern }
}
}
impl Default for DefaultCommitClassifier {
fn default() -> Self {
Self::new(default_commit_types(), DEFAULT_COMMIT_PATTERN.into())
}
}
impl CommitClassifier for DefaultCommitClassifier {
fn types(&self) -> &[CommitType] {
&self.types
}
fn pattern(&self) -> &str {
&self.pattern
}
}
pub fn default_commit_types() -> Vec<CommitType> {
vec![
CommitType {
name: "feat".into(),
bump: Some(BumpLevel::Minor),
section: Some("Features".into()),
pattern: None,
},
CommitType {
name: "fix".into(),
bump: Some(BumpLevel::Patch),
section: Some("Bug Fixes".into()),
pattern: None,
},
CommitType {
name: "perf".into(),
bump: Some(BumpLevel::Patch),
section: Some("Performance".into()),
pattern: None,
},
CommitType {
name: "docs".into(),
bump: None,
section: Some("Documentation".into()),
pattern: None,
},
CommitType {
name: "refactor".into(),
bump: Some(BumpLevel::Patch),
section: Some("Refactoring".into()),
pattern: None,
},
CommitType {
name: "revert".into(),
bump: None,
section: Some("Reverts".into()),
pattern: None,
},
CommitType {
name: "chore".into(),
bump: None,
section: None,
pattern: None,
},
CommitType {
name: "ci".into(),
bump: None,
section: None,
pattern: None,
},
CommitType {
name: "test".into(),
bump: None,
section: None,
pattern: None,
},
CommitType {
name: "build".into(),
bump: None,
section: None,
pattern: None,
},
CommitType {
name: "style".into(),
bump: None,
section: None,
pattern: None,
},
]
}
pub trait CommitParser: Send + Sync {
fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError>;
}
pub struct DefaultCommitParser;
impl CommitParser for DefaultCommitParser {
fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
let re =
Regex::new(DEFAULT_COMMIT_PATTERN).map_err(|e| ReleaseError::Config(e.to_string()))?;
let caps = re.captures(&commit.message).ok_or_else(|| {
ReleaseError::Config(format!("not a conventional commit: {}", commit.message))
})?;
let r#type = caps.name("type").unwrap().as_str().to_string();
let scope = caps.name("scope").map(|m| m.as_str().to_string());
let breaking = caps.name("breaking").is_some();
let description = caps.name("description").unwrap().as_str().to_string();
let body = commit
.message
.split_once("\n\n")
.map(|x| x.1)
.map(|b| b.to_string());
let breaking = breaking
|| body.as_deref().is_some_and(|b| {
b.lines().any(|line| {
line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:")
})
});
Ok(ConventionalCommit {
sha: commit.sha.clone(),
r#type,
scope,
description,
body,
breaking,
})
}
}
pub struct ConfiguredCommitParser {
types: Vec<CommitType>,
commit_pattern: String,
}
impl ConfiguredCommitParser {
pub fn new(types: Vec<CommitType>, commit_pattern: String) -> Self {
Self {
types,
commit_pattern,
}
}
}
impl CommitParser for ConfiguredCommitParser {
fn parse(&self, commit: &Commit) -> Result<ConventionalCommit, ReleaseError> {
let re =
Regex::new(&self.commit_pattern).map_err(|e| ReleaseError::Config(e.to_string()))?;
let first_line = commit.message.lines().next().unwrap_or("");
if let Some(caps) = re.captures(first_line) {
let r#type = caps.name("type").unwrap().as_str().to_string();
let scope = caps.name("scope").map(|m| m.as_str().to_string());
let breaking = caps.name("breaking").is_some();
let description = caps.name("description").unwrap().as_str().to_string();
let body = commit
.message
.split_once("\n\n")
.map(|x| x.1)
.map(|b| b.to_string());
let breaking = breaking
|| body.as_deref().is_some_and(|b| {
b.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("BREAKING CHANGE:")
|| trimmed.starts_with("BREAKING CHANGE ")
|| trimmed.starts_with("BREAKING-CHANGE:")
|| trimmed.starts_with("BREAKING-CHANGE ")
})
});
return Ok(ConventionalCommit {
sha: commit.sha.clone(),
r#type,
scope,
description,
body,
breaking,
});
}
for ct in &self.types {
let Some(ref pat) = ct.pattern else {
continue;
};
let Ok(type_re) = Regex::new(pat) else {
continue;
};
if let Some(caps) = type_re.captures(first_line) {
let scope = caps.name("scope").map(|m| m.as_str().to_string());
let breaking = caps.name("breaking").is_some();
let description = caps
.name("description")
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| first_line.to_string());
let body = commit
.message
.split_once("\n\n")
.map(|x| x.1)
.map(|b| b.to_string());
return Ok(ConventionalCommit {
sha: commit.sha.clone(),
r#type: ct.name.clone(),
scope,
description,
body,
breaking,
});
}
}
Err(ReleaseError::Config(format!(
"not a conventional commit: {}",
commit.message
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(message: &str) -> Commit {
Commit {
sha: "abc1234".into(),
message: message.into(),
}
}
#[test]
fn parse_simple_feat() {
let result = DefaultCommitParser.parse(&raw("feat: add button")).unwrap();
assert_eq!(result.r#type, "feat");
assert_eq!(result.description, "add button");
assert_eq!(result.scope, None);
assert!(!result.breaking);
}
#[test]
fn parse_scoped_fix() {
let result = DefaultCommitParser
.parse(&raw("fix(core): null check"))
.unwrap();
assert_eq!(result.r#type, "fix");
assert_eq!(result.scope.as_deref(), Some("core"));
}
#[test]
fn parse_breaking_bang() {
let result = DefaultCommitParser.parse(&raw("feat!: new API")).unwrap();
assert!(result.breaking);
}
#[test]
fn parse_with_body() {
let result = DefaultCommitParser
.parse(&raw("fix: x\n\ndetails"))
.unwrap();
assert_eq!(result.body.as_deref(), Some("details"));
}
#[test]
fn parse_breaking_change_footer() {
let result = DefaultCommitParser
.parse(&raw(
"feat: new API\n\nBREAKING CHANGE: removed old endpoint",
))
.unwrap();
assert!(result.breaking);
assert_eq!(result.r#type, "feat");
}
#[test]
fn parse_breaking_change_hyphenated_footer() {
let result = DefaultCommitParser
.parse(&raw("fix: update schema\n\nBREAKING-CHANGE: field renamed"))
.unwrap();
assert!(result.breaking);
}
#[test]
fn parse_breaking_change_footer_with_bang() {
let result = DefaultCommitParser
.parse(&raw(
"feat!: overhaul\n\nBREAKING CHANGE: everything changed",
))
.unwrap();
assert!(result.breaking);
}
#[test]
fn parse_no_breaking_change_in_body() {
let result = DefaultCommitParser
.parse(&raw("fix: tweak\n\nThis is not a BREAKING CHANGE footer"))
.unwrap();
assert!(!result.breaking);
}
#[test]
fn parse_no_breaking_change_indented_bullet() {
let result = DefaultCommitParser
.parse(&raw(
"feat(mcp): add breaking flag\n\n- add `breaking` field — sets \"!\" and adds\n BREAKING CHANGE footer automatically",
))
.unwrap();
assert!(!result.breaking);
}
#[test]
fn parse_no_breaking_change_space_separator() {
let result = DefaultCommitParser
.parse(&raw("feat: something\n\nBREAKING CHANGE without colon"))
.unwrap();
assert!(!result.breaking);
}
#[test]
fn parse_invalid_message() {
let result = DefaultCommitParser.parse(&raw("not conventional"));
assert!(result.is_err());
}
#[test]
fn classifier_bump_level_feat() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.bump_level("feat", false), Some(BumpLevel::Minor));
}
#[test]
fn classifier_bump_level_fix() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.bump_level("fix", false), Some(BumpLevel::Patch));
}
#[test]
fn classifier_bump_level_breaking_overrides() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.bump_level("fix", true), Some(BumpLevel::Major));
assert_eq!(c.bump_level("chore", true), Some(BumpLevel::Major));
}
#[test]
fn classifier_bump_level_no_bump_type() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.bump_level("chore", false), None);
assert_eq!(c.bump_level("docs", false), None);
}
#[test]
fn classifier_bump_level_unknown_type() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.bump_level("unknown", false), None);
}
#[test]
fn classifier_changelog_section() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.changelog_section("feat"), Some("Features"));
assert_eq!(c.changelog_section("fix"), Some("Bug Fixes"));
assert_eq!(c.changelog_section("perf"), Some("Performance"));
assert_eq!(c.changelog_section("docs"), Some("Documentation"));
assert_eq!(c.changelog_section("refactor"), Some("Refactoring"));
assert_eq!(c.changelog_section("revert"), Some("Reverts"));
assert_eq!(c.changelog_section("chore"), None);
assert_eq!(c.changelog_section("unknown"), None);
}
#[test]
fn classifier_is_allowed() {
let c = DefaultCommitClassifier::default();
assert!(c.is_allowed("feat"));
assert!(c.is_allowed("chore"));
assert!(!c.is_allowed("unknown"));
}
#[test]
fn classifier_pattern() {
let c = DefaultCommitClassifier::default();
assert_eq!(c.pattern(), DEFAULT_COMMIT_PATTERN);
}
#[test]
fn default_commit_types_count() {
let types = default_commit_types();
assert_eq!(types.len(), 11);
}
#[test]
fn commit_type_serialization_roundtrip() {
let ct = CommitType {
name: "feat".into(),
bump: Some(BumpLevel::Minor),
section: Some("Features".into()),
pattern: None,
};
let yaml = serde_yaml_ng::to_string(&ct).unwrap();
let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(parsed, ct);
}
#[test]
fn commit_type_no_bump_no_section_roundtrip() {
let ct = CommitType {
name: "chore".into(),
bump: None,
section: None,
pattern: None,
};
let yaml = serde_yaml_ng::to_string(&ct).unwrap();
assert!(!yaml.contains("bump"));
assert!(!yaml.contains("section"));
assert!(!yaml.contains("pattern"));
let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(parsed, ct);
}
#[test]
fn commit_type_with_pattern_roundtrip() {
let ct = CommitType {
name: "deps".into(),
bump: Some(BumpLevel::Patch),
section: Some("Dependencies".into()),
pattern: Some(r"^Bump .+ from .+ to .+".into()),
};
let yaml = serde_yaml_ng::to_string(&ct).unwrap();
assert!(yaml.contains("pattern"));
let parsed: CommitType = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(parsed, ct);
}
fn configured_parser_with_deps() -> ConfiguredCommitParser {
let mut types = default_commit_types();
types.push(CommitType {
name: "deps".into(),
bump: Some(BumpLevel::Patch),
section: Some("Dependencies".into()),
pattern: Some(r"^Bump (?P<description>.+)".into()),
});
ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into())
}
#[test]
fn configured_parser_standard_match_preferred() {
let parser = configured_parser_with_deps();
let result = parser.parse(&raw("feat: add button")).unwrap();
assert_eq!(result.r#type, "feat");
assert_eq!(result.description, "add button");
}
#[test]
fn configured_parser_fallback_match() {
let parser = configured_parser_with_deps();
let result = parser
.parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
.unwrap();
assert_eq!(result.r#type, "deps");
assert_eq!(result.description, "serde from 1.0.0 to 1.1.0");
}
#[test]
fn configured_parser_fallback_no_named_groups() {
let mut types = default_commit_types();
types.push(CommitType {
name: "deps".into(),
bump: Some(BumpLevel::Patch),
section: Some("Dependencies".into()),
pattern: Some(r"^Bump .+ from .+ to .+".into()),
});
let parser = ConfiguredCommitParser::new(types, DEFAULT_COMMIT_PATTERN.into());
let result = parser
.parse(&raw("Bump serde from 1.0.0 to 1.1.0"))
.unwrap();
assert_eq!(result.r#type, "deps");
assert_eq!(result.description, "Bump serde from 1.0.0 to 1.1.0");
}
#[test]
fn configured_parser_no_match() {
let parser = configured_parser_with_deps();
let result = parser.parse(&raw("random garbage message"));
assert!(result.is_err());
}
}