use regex::Regex;
use std::sync::OnceLock;
fn conventional_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<desc>.+?)(?:\s*\(#(?P<pr>\d+)\))?$"
).expect("Invalid conventional commit regex")
})
}
fn issue_ref_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?i)(?:fix(?:es)?|close[sd]?|resolve[sd]?)[:\s]+#?(\d+)")
.expect("Invalid issue ref regex")
})
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParsedCommit {
pub commit_type: Option<String>,
pub scope: Option<String>,
pub breaking: bool,
pub pr_ref: Option<i64>,
pub issue_refs: Vec<i64>,
}
impl ParsedCommit {
pub fn has_structure(&self) -> bool {
self.commit_type.is_some() || self.pr_ref.is_some() || !self.issue_refs.is_empty()
}
}
pub fn parse_conventional(message: &str) -> ParsedCommit {
let first_line = message.lines().next().unwrap_or("");
let (commit_type, scope, breaking, pr_ref) = conventional_regex()
.captures(first_line)
.map(|c| {
(
c.name("type").map(|m| m.as_str().to_string()),
c.name("scope").map(|m| m.as_str().to_string()),
c.name("breaking").is_some(),
c.name("pr").and_then(|m| m.as_str().parse().ok()),
)
})
.unwrap_or((None, None, false, None));
let issue_refs: Vec<i64> = issue_ref_regex()
.captures_iter(message)
.filter_map(|c| c.get(1)?.as_str().parse().ok())
.collect();
ParsedCommit {
commit_type,
scope,
breaking,
pr_ref,
issue_refs,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_full_conventional_commit() {
let parsed = parse_conventional("feat(sozo): add invoke command (#3384)");
assert_eq!(parsed.commit_type, Some("feat".to_string()));
assert_eq!(parsed.scope, Some("sozo".to_string()));
assert!(!parsed.breaking);
assert_eq!(parsed.pr_ref, Some(3384));
assert!(parsed.issue_refs.is_empty());
}
#[test]
fn test_breaking_change() {
let parsed = parse_conventional("feat(api)!: remove deprecated endpoint");
assert_eq!(parsed.commit_type, Some("feat".to_string()));
assert_eq!(parsed.scope, Some("api".to_string()));
assert!(parsed.breaking);
assert_eq!(parsed.pr_ref, None);
}
#[test]
fn test_no_scope() {
let parsed = parse_conventional("fix: correct typo in readme (#123)");
assert_eq!(parsed.commit_type, Some("fix".to_string()));
assert_eq!(parsed.scope, None);
assert_eq!(parsed.pr_ref, Some(123));
}
#[test]
fn test_issue_refs_in_body() {
let msg = "feat(auth): add login flow (#100)\n\nFixes #42\nCloses #43";
let parsed = parse_conventional(msg);
assert_eq!(parsed.commit_type, Some("feat".to_string()));
assert_eq!(parsed.pr_ref, Some(100));
assert_eq!(parsed.issue_refs, vec![42, 43]);
}
#[test]
fn test_issue_refs_various_formats() {
let msg =
"chore: cleanup\n\nFixes: #1\nfixes #2\nCloses #3\nclosed #4\nResolves #5\nresolved #6";
let parsed = parse_conventional(msg);
assert_eq!(parsed.issue_refs, vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn test_non_conventional_commit() {
let parsed = parse_conventional("Updated the README file");
assert_eq!(parsed.commit_type, None);
assert_eq!(parsed.scope, None);
assert_eq!(parsed.pr_ref, None);
assert!(!parsed.has_structure());
}
#[test]
fn test_pr_only_no_type() {
let parsed = parse_conventional("Add new feature (#456)");
assert_eq!(parsed.commit_type, None);
assert_eq!(parsed.pr_ref, None); }
#[test]
fn test_merge_commit() {
let parsed = parse_conventional("Merge pull request #789 from branch");
assert_eq!(parsed.commit_type, None);
}
#[test]
fn test_empty_message() {
let parsed = parse_conventional("");
assert!(!parsed.has_structure());
}
#[test]
fn test_complex_scope() {
let parsed = parse_conventional("feat(cli/commands): add new subcommand (#100)");
assert_eq!(parsed.commit_type, Some("feat".to_string()));
assert_eq!(parsed.scope, Some("cli/commands".to_string()));
assert_eq!(parsed.pr_ref, Some(100));
}
}