#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConventionalCommit<'a> {
pub commit_type: &'a str,
pub scope: Option<&'a str>,
pub breaking: bool,
pub description: &'a str,
}
impl<'a> ConventionalCommit<'a> {
pub fn parse(message: &'a str) -> Option<Self> {
let colon_pos = message.find(": ")?;
let prefix = &message[..colon_pos];
let description = &message[colon_pos + 2..];
let (type_and_scope, breaking) = if let Some(stripped) = prefix.strip_suffix('!') {
(stripped, true)
} else {
(prefix, false)
};
let (commit_type, scope) = if let Some(paren_start) = type_and_scope.find('(') {
if !type_and_scope.ends_with(')') {
return None;
}
let scope_content = &type_and_scope[paren_start + 1..type_and_scope.len() - 1];
let commit_type = &type_and_scope[..paren_start];
(commit_type, Some(scope_content))
} else {
(type_and_scope, None)
};
if commit_type.is_empty() || !commit_type.chars().all(|c| c.is_ascii_lowercase()) {
return None;
}
Some(ConventionalCommit {
commit_type,
scope,
breaking,
description,
})
}
pub fn emoji(&self) -> &'static str {
type_to_emoji(self.commit_type)
}
pub fn to_display(&self) -> String {
let emoji = self.emoji();
let breaking_emoji = if self.breaking { "๐ฅ" } else { "" };
match self.scope {
Some(scope) => {
format!("{emoji}({scope}){breaking_emoji} {}", self.description)
}
None => {
format!("{emoji}{breaking_emoji} {}", self.description)
}
}
}
}
fn type_to_emoji(commit_type: &str) -> &'static str {
match commit_type {
"feat" => "โจ",
"fix" => "๐ฉน",
"docs" => "๐",
"style" => "๐",
"refactor" => "๐๏ธ",
"perf" => "โก",
"test" => "๐งช",
"build" => "๐ฆ",
"ci" => "๐ท",
"chore" => "๐ง",
"revert" => "โช",
"wip" => "๐ง",
"hotfix" => "๐",
"security" => "๐",
"deps" => "โฌ๏ธ",
"release" => "๐",
"init" => "๐",
_ => "๐",
}
}
pub fn format_commit_message(message: &str) -> String {
ConventionalCommit::parse(message)
.map(|cc| cc.to_display())
.unwrap_or_else(|| message.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple() {
let cc = ConventionalCommit::parse("feat: add new feature").unwrap();
assert_eq!(cc.commit_type, "feat");
assert_eq!(cc.scope, None);
assert!(!cc.breaking);
assert_eq!(cc.description, "add new feature");
}
#[test]
fn test_parse_with_scope() {
let cc = ConventionalCommit::parse("fix(api): handle null").unwrap();
assert_eq!(cc.commit_type, "fix");
assert_eq!(cc.scope, Some("api"));
assert!(!cc.breaking);
assert_eq!(cc.description, "handle null");
}
#[test]
fn test_parse_breaking() {
let cc = ConventionalCommit::parse("feat!: breaking change").unwrap();
assert_eq!(cc.commit_type, "feat");
assert!(cc.breaking);
assert_eq!(cc.description, "breaking change");
}
#[test]
fn test_parse_scope_and_breaking() {
let cc = ConventionalCommit::parse("refactor(core)!: rewrite engine").unwrap();
assert_eq!(cc.commit_type, "refactor");
assert_eq!(cc.scope, Some("core"));
assert!(cc.breaking);
assert_eq!(cc.description, "rewrite engine");
}
#[test]
fn test_parse_invalid() {
assert!(ConventionalCommit::parse("just a message").is_none());
assert!(ConventionalCommit::parse("feat:no space").is_none());
assert!(ConventionalCommit::parse(": no type").is_none());
assert!(ConventionalCommit::parse("FEAT: uppercase").is_none());
assert!(ConventionalCommit::parse("feat(api: unclosed").is_none());
}
#[test]
fn test_parse_no_description() {
assert!(ConventionalCommit::parse("(no description)").is_none());
}
#[test]
fn test_to_display_simple() {
let cc = ConventionalCommit::parse("feat: blah").unwrap();
assert_eq!(cc.to_display(), "โจ blah");
}
#[test]
fn test_to_display_breaking() {
let cc = ConventionalCommit::parse("fix!: hoge").unwrap();
assert_eq!(cc.to_display(), "๐ฉน๐ฅ hoge");
}
#[test]
fn test_to_display_with_scope() {
let cc = ConventionalCommit::parse("fix(hoge): blah").unwrap();
assert_eq!(cc.to_display(), "๐ฉน(hoge) blah");
}
#[test]
fn test_to_display_scope_and_breaking() {
let cc = ConventionalCommit::parse("feat(api)!: xyz").unwrap();
assert_eq!(cc.to_display(), "โจ(api)๐ฅ xyz");
}
#[test]
fn test_format_commit_message_conventional() {
assert_eq!(format_commit_message("feat: new feature"), "โจ new feature");
assert_eq!(format_commit_message("fix!: breaking"), "๐ฉน๐ฅ breaking");
assert_eq!(
format_commit_message("docs(readme): update"),
"๐(readme) update"
);
}
#[test]
fn test_format_commit_message_non_conventional() {
assert_eq!(
format_commit_message("just a regular message"),
"just a regular message"
);
assert_eq!(
format_commit_message("(no description)"),
"(no description)"
);
assert_eq!(format_commit_message("WIP stuff"), "WIP stuff");
}
#[test]
fn test_emoji_mapping() {
assert_eq!(type_to_emoji("feat"), "โจ");
assert_eq!(type_to_emoji("fix"), "๐ฉน");
assert_eq!(type_to_emoji("docs"), "๐");
assert_eq!(type_to_emoji("style"), "๐");
assert_eq!(type_to_emoji("refactor"), "๐๏ธ");
assert_eq!(type_to_emoji("perf"), "โก");
assert_eq!(type_to_emoji("test"), "๐งช");
assert_eq!(type_to_emoji("build"), "๐ฆ");
assert_eq!(type_to_emoji("ci"), "๐ท");
assert_eq!(type_to_emoji("chore"), "๐ง");
assert_eq!(type_to_emoji("revert"), "โช");
assert_eq!(type_to_emoji("unknown"), "๐"); }
#[test]
fn test_edge_cases() {
let cc = ConventionalCommit::parse("feat: ๆฅๆฌ่ชใฎ่ชฌๆ").unwrap();
assert_eq!(cc.to_display(), "โจ ๆฅๆฌ่ชใฎ่ชฌๆ");
let cc = ConventionalCommit::parse("fix: ").unwrap();
assert_eq!(cc.to_display(), "๐ฉน ");
let cc = ConventionalCommit::parse("feat: time: 12:00").unwrap();
assert_eq!(cc.to_display(), "โจ time: 12:00");
let cc = ConventionalCommit::parse("fix(my-module): issue").unwrap();
assert_eq!(cc.scope, Some("my-module"));
}
}