use std::sync::LazyLock;
use regex::Regex;
use crate::data::check::{CommitIssue, IssueSeverity};
use crate::data::context::{CommitRules, ScopeDefinition};
static SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] Regex::new(
r"^(?P<type>[a-z]+)(?P<bang1>!)?(?:\((?P<scope>[^)]*)\))?(?P<bang2>!)?: (?P<desc>.*)$",
)
.expect("SUBJECT_RE is a valid compile-time constant regex")
});
static BAD_SCOPE_COMMA_RE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::expect_used)] Regex::new(r"\s,|,\s{2,}").expect("BAD_SCOPE_COMMA_RE is a valid compile-time constant regex")
});
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedSubject<'a> {
pub commit_type: &'a str,
pub scope: Option<&'a str>,
pub description: &'a str,
pub breaking: bool,
}
pub fn parse_subject(first_line: &str) -> Option<ParsedSubject<'_>> {
let caps = SUBJECT_RE.captures(first_line)?;
let commit_type = caps.name("type")?.as_str();
let description = caps.name("desc")?.as_str();
let breaking = caps.name("bang1").is_some() || caps.name("bang2").is_some();
let scope = caps
.name("scope")
.map(|m| m.as_str())
.filter(|s| !s.is_empty());
Some(ParsedSubject {
commit_type,
scope,
description,
breaking,
})
}
pub(crate) fn scope_comma_format_ok(scope: &str) -> bool {
!BAD_SCOPE_COMMA_RE.is_match(scope)
}
pub(crate) fn scope_parts_all_valid(scope: &str, valid_scopes: &[ScopeDefinition]) -> bool {
valid_scopes.is_empty() || invalid_scope_parts(scope, valid_scopes).is_empty()
}
fn invalid_scope_parts<'a>(scope: &'a str, valid_scopes: &[ScopeDefinition]) -> Vec<&'a str> {
scope
.split(',')
.map(str::trim)
.filter(|part| !valid_scopes.iter().any(|s| s.name == *part))
.collect()
}
fn issue(severity: IssueSeverity, section: &str, rule: &str, explanation: String) -> CommitIssue {
CommitIssue {
severity,
section: section.to_string(),
rule: rule.to_string(),
explanation,
}
}
fn line_has_forbidden_footer(line: &str, footer: &str) -> bool {
let Some(prefix) = line.get(..footer.len()) else {
return false;
};
prefix.eq_ignore_ascii_case(footer) && line[footer.len()..].starts_with(':')
}
pub fn lint_message(
message: &str,
rules: &CommitRules,
valid_scopes: &[ScopeDefinition],
) -> Vec<CommitIssue> {
let mut issues = Vec::new();
let first_line = message.lines().next().unwrap_or("");
let len = first_line.chars().count();
if len > rules.subject_max_len {
issues.push(issue(
IssueSeverity::Error,
"Subject Line",
"subject-length",
format!(
"Subject is {len} characters, which exceeds the {}-character limit",
rules.subject_max_len
),
));
}
if let Some(second_line) = message.lines().nth(1) {
if !second_line.is_empty() {
issues.push(issue(
IssueSeverity::Error,
"Commit Format",
"blank-line-after-subject",
"Line 2 must be blank when the commit message has a body".to_string(),
));
}
}
for line in message.lines() {
for footer in &rules.forbidden_footers {
if line_has_forbidden_footer(line, footer) {
issues.push(issue(
IssueSeverity::Warning,
"Body Guidelines",
"forbidden-footer",
format!("'{footer}' footer is not permitted"),
));
}
}
}
let Some(parsed) = parse_subject(first_line) else {
issues.push(issue(
IssueSeverity::Error,
"Commit Format",
"format",
"Subject must match '<type>(<scope>): <description>'".to_string(),
));
return issues;
};
if !rules.types.iter().any(|t| t == parsed.commit_type) {
issues.push(issue(
IssueSeverity::Error,
"Types",
"unknown-type",
format!(
"'{}' is not one of the accepted types: {}",
parsed.commit_type,
rules.types.join(", ")
),
));
}
match parsed.scope {
Some(scope) => {
if !scope_comma_format_ok(scope) {
issues.push(issue(
IssueSeverity::Error,
"Scopes",
"scope-comma-format",
format!("Scope '{scope}' must separate multiple scopes with ',' or ', ' only"),
));
}
let invalid = invalid_scope_parts(scope, valid_scopes);
if !valid_scopes.is_empty() && !invalid.is_empty() {
issues.push(issue(
IssueSeverity::Error,
"Scopes",
"unknown-scope",
format!(
"Scope(s) not in the valid scopes list: {}",
invalid.join(", ")
),
));
}
}
None if rules.require_scope => {
issues.push(issue(
IssueSeverity::Error,
"Scopes",
"missing-scope",
"A scope is required, e.g. 'type(scope): description'".to_string(),
));
}
None => {}
}
if parsed
.description
.chars()
.next()
.is_some_and(char::is_uppercase)
{
issues.push(issue(
IssueSeverity::Info,
"Subject Line Style",
"lowercase-description",
"Description should start with a lowercase letter".to_string(),
));
}
if first_line.trim_end().ends_with('.') {
issues.push(issue(
IssueSeverity::Info,
"Subject Line Style",
"no-trailing-period",
"Subject should not end with a period".to_string(),
));
}
issues
}
#[must_use]
pub fn passes(issues: &[CommitIssue]) -> bool {
!issues.iter().any(|i| i.severity == IssueSeverity::Error)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn scope(name: &str) -> ScopeDefinition {
ScopeDefinition {
name: name.to_string(),
description: String::new(),
examples: vec![],
file_patterns: vec![],
}
}
fn scopes(names: &[&str]) -> Vec<ScopeDefinition> {
names.iter().map(|n| scope(n)).collect()
}
fn issue_rules<'a>(rule: &str, issues: &'a [CommitIssue]) -> Vec<&'a CommitIssue> {
issues.iter().filter(|i| i.rule == rule).collect()
}
#[test]
fn parse_subject_simple() {
let p = parse_subject("feat(cli): add twiddle contextual options").unwrap();
assert_eq!(p.commit_type, "feat");
assert_eq!(p.scope, Some("cli"));
assert_eq!(p.description, "add twiddle contextual options");
assert!(!p.breaking);
}
#[test]
fn parse_subject_scope_less() {
let p = parse_subject("docs: clarify dry_run helper scope").unwrap();
assert_eq!(p.commit_type, "docs");
assert_eq!(p.scope, None);
}
#[test]
fn parse_subject_canonical_breaking_change() {
let p = parse_subject("feat(cli)!: change commit check output format").unwrap();
assert_eq!(p.commit_type, "feat");
assert_eq!(p.scope, Some("cli"));
assert!(p.breaking);
}
#[test]
fn parse_subject_lenient_legacy_breaking_change() {
let p = parse_subject("feat!(cli): add thing").unwrap();
assert_eq!(p.scope, Some("cli"));
assert!(p.breaking);
}
#[test]
fn parse_subject_both_bangs_accepted_leniently() {
assert!(parse_subject("feat!(cli)!: add thing").is_some());
}
#[test]
fn parse_subject_multi_scope() {
let p = parse_subject("feat(git,data): integrate branch analysis").unwrap();
assert_eq!(p.scope, Some("git,data"));
}
#[test]
fn parse_subject_rejects_missing_colon_space() {
assert!(parse_subject("feat(cli):no space").is_none());
}
#[test]
fn parse_subject_rejects_garbage() {
assert!(parse_subject("this is not a conventional commit").is_none());
}
#[test]
fn format_valid_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add twiddle contextual options", &rules, &[]);
assert!(issue_rules("format", &issues).is_empty());
assert!(passes(&issues));
}
#[test]
fn format_invalid_flags_error() {
let rules = CommitRules::default();
let issues = lint_message("not a conventional commit at all", &rules, &[]);
let found = issue_rules("format", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
assert_eq!(found[0].section, "Commit Format");
}
#[test]
fn known_type_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing", &rules, &[]);
assert!(issue_rules("unknown-type", &issues).is_empty());
}
#[test]
fn unknown_type_flags_error() {
let rules = CommitRules::default();
let issues = lint_message("feature(cli): add thing", &rules, &[]);
let found = issue_rules("unknown-type", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
assert_eq!(found[0].section, "Types");
}
#[test]
fn scope_comma_format_valid_passes() {
let rules = CommitRules::default();
let valid = scopes(&["cli", "claude"]);
let issues = lint_message("feat(cli,claude): add thing", &rules, &valid);
assert!(issue_rules("scope-comma-format", &issues).is_empty());
let issues_spaced = lint_message("feat(cli, claude): add thing", &rules, &valid);
assert!(issue_rules("scope-comma-format", &issues_spaced).is_empty());
}
#[test]
fn scope_comma_two_spaces_after_flags_error() {
let rules = CommitRules::default();
let valid = scopes(&["a", "b"]);
let issues = lint_message("feat(a, b): add thing", &rules, &valid);
let found = issue_rules("scope-comma-format", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
}
#[test]
fn scope_comma_space_before_flags_error() {
let rules = CommitRules::default();
let valid = scopes(&["a", "b"]);
let issues = lint_message("feat(a ,b): add thing", &rules, &valid);
assert_eq!(issue_rules("scope-comma-format", &issues).len(), 1);
}
#[test]
fn valid_scope_passes() {
let rules = CommitRules::default();
let valid = scopes(&["cli"]);
let issues = lint_message("feat(cli): add thing", &rules, &valid);
assert!(issue_rules("unknown-scope", &issues).is_empty());
}
#[test]
fn undefined_scope_flags_error() {
let rules = CommitRules::default();
let valid = scopes(&["cli"]);
let issues = lint_message("feat(bogus): add thing", &rules, &valid);
let found = issue_rules("unknown-scope", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
assert_eq!(found[0].section, "Scopes");
}
#[test]
fn empty_valid_scopes_list_accepts_anything() {
let rules = CommitRules::default();
let issues = lint_message("feat(anything): add thing", &rules, &[]);
assert!(issue_rules("unknown-scope", &issues).is_empty());
}
#[test]
fn missing_scope_allowed_by_default() {
let rules = CommitRules::default();
assert!(!rules.require_scope);
let issues = lint_message("docs: update issue references", &rules, &[]);
assert!(issue_rules("missing-scope", &issues).is_empty());
}
#[test]
fn missing_scope_flagged_when_required() {
let rules = CommitRules {
require_scope: true,
..CommitRules::default()
};
let issues = lint_message("docs: update issue references", &rules, &[]);
let found = issue_rules("missing-scope", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
}
#[test]
fn present_scope_not_flagged_when_required() {
let rules = CommitRules {
require_scope: true,
..CommitRules::default()
};
let issues = lint_message("docs(docs): update issue references", &rules, &[]);
assert!(issue_rules("missing-scope", &issues).is_empty());
}
#[test]
fn lowercase_description_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing", &rules, &[]);
assert!(issue_rules("lowercase-description", &issues).is_empty());
}
#[test]
fn uppercase_description_flags_info() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): Add thing", &rules, &[]);
let found = issue_rules("lowercase-description", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Info);
assert!(passes(&found_as_issues(&issues, "lowercase-description")));
}
fn found_as_issues(issues: &[CommitIssue], rule: &str) -> Vec<CommitIssue> {
issues.iter().filter(|i| i.rule == rule).cloned().collect()
}
#[test]
fn no_trailing_period_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing", &rules, &[]);
assert!(issue_rules("no-trailing-period", &issues).is_empty());
}
#[test]
fn trailing_period_flags_info() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing.", &rules, &[]);
let found = issue_rules("no-trailing-period", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Info);
}
#[test]
fn subject_within_limit_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing", &rules, &[]);
assert!(issue_rules("subject-length", &issues).is_empty());
}
#[test]
fn subject_over_limit_flags_error() {
let rules = CommitRules::default();
let long_desc = "x".repeat(rules.subject_max_len);
let msg = format!("feat(cli): {long_desc}");
assert!(msg.chars().count() > rules.subject_max_len);
let issues = lint_message(&msg, &rules, &[]);
let found = issue_rules("subject-length", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
}
#[test]
fn configured_limit_is_honoured() {
let prefix = "feat(cli): ";
let desc = "x".repeat(73 - prefix.len());
let msg = format!("{prefix}{desc}");
assert_eq!(msg.chars().count(), 73);
let rules_80 = CommitRules {
subject_max_len: 80,
..CommitRules::default()
};
assert!(issue_rules("subject-length", &lint_message(&msg, &rules_80, &[])).is_empty());
let rules_72 = CommitRules {
subject_max_len: 72,
..CommitRules::default()
};
assert_eq!(
issue_rules("subject-length", &lint_message(&msg, &rules_72, &[])).len(),
1
);
}
#[test]
fn blank_line_2_with_body_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing\n\nMore detail here.", &rules, &[]);
assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
}
#[test]
fn subject_only_no_body_passes() {
let rules = CommitRules::default();
let issues = lint_message("feat(cli): add thing", &rules, &[]);
assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
}
#[test]
fn non_blank_line_2_flags_error() {
let rules = CommitRules::default();
let issues = lint_message(
"fix(git): handle detached HEAD in branch analysis\nThis line should be blank.",
&rules,
&[],
);
let found = issue_rules("blank-line-after-subject", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Error);
assert_eq!(found[0].section, "Commit Format");
}
#[test]
fn no_footer_passes() {
let rules = CommitRules::default();
let issues = lint_message(
"feat(cli): add thing\n\nBody text.\n\nCloses #123",
&rules,
&[],
);
assert!(issue_rules("forbidden-footer", &issues).is_empty());
}
#[test]
fn co_authored_by_footer_flags_warning() {
let rules = CommitRules::default();
let issues = lint_message(
"feat(cli): add thing\n\nBody text.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
&rules,
&[],
);
let found = issue_rules("forbidden-footer", &issues);
assert_eq!(found.len(), 1);
assert_eq!(found[0].severity, IssueSeverity::Warning);
assert_eq!(found[0].section, "Body Guidelines");
assert!(passes(&found_as_issues(&issues, "forbidden-footer")));
}
#[test]
fn co_authored_by_footer_case_insensitive() {
let rules = CommitRules::default();
let issues = lint_message(
"feat(cli): add thing\n\nco-authored-by: someone",
&rules,
&[],
);
assert_eq!(issue_rules("forbidden-footer", &issues).len(), 1);
}
#[test]
fn ecosystem_default_scopes_pass_even_when_absent_from_scopes_yaml() {
let rules = CommitRules::default();
let merged = scopes(&["cli", "git", "lib", "cargo", "core", "test"]);
for s in ["lib", "cargo", "core", "test"] {
let msg = format!("chore({s}): bump dependency");
let issues = lint_message(&msg, &rules, &merged);
assert!(
issue_rules("unknown-scope", &issues).is_empty(),
"ecosystem default scope {s} should be accepted"
);
}
}
#[test]
fn positive_fixtures_from_history_pass_cleanly() {
let rules = CommitRules::default();
let valid = scopes(&["cli", "claude", "docs"]);
for msg in [
"feat(cli,claude): add twiddle contextual options",
"feat(cli)!: change commit check output format",
"docs(docs): add architecture overview document",
] {
let issues = lint_message(msg, &rules, &valid);
assert!(passes(&issues), "{msg:?} should pass cleanly: {issues:?}");
}
}
#[test]
fn multi_scope_breaking_change_records_both_checks() {
let rules = CommitRules::default();
let valid = scopes(&["cli", "claude"]);
let issues = lint_message("feat(cli,claude)!: add thing", &rules, &valid);
assert!(passes(&issues));
}
}