use regex::Regex;
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommitType {
Feat,
Fix,
Docs,
Style,
Refactor,
Perf,
Test,
Build,
Ci,
Chore,
Revert,
Custom(String),
}
impl fmt::Display for CommitType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommitType::Feat => write!(f, "feat"),
CommitType::Fix => write!(f, "fix"),
CommitType::Docs => write!(f, "docs"),
CommitType::Style => write!(f, "style"),
CommitType::Refactor => write!(f, "refactor"),
CommitType::Perf => write!(f, "perf"),
CommitType::Test => write!(f, "test"),
CommitType::Build => write!(f, "build"),
CommitType::Ci => write!(f, "ci"),
CommitType::Chore => write!(f, "chore"),
CommitType::Revert => write!(f, "revert"),
CommitType::Custom(s) => write!(f, "{}", s),
}
}
}
impl From<&str> for CommitType {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"feat" => CommitType::Feat,
"fix" => CommitType::Fix,
"docs" => CommitType::Docs,
"style" => CommitType::Style,
"refactor" => CommitType::Refactor,
"perf" => CommitType::Perf,
"test" => CommitType::Test,
"build" => CommitType::Build,
"ci" => CommitType::Ci,
"chore" => CommitType::Chore,
"revert" => CommitType::Revert,
_ => CommitType::Custom(s.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct ConventionalCommit {
pub commit_type: CommitType,
pub scope: Option<String>,
pub breaking_change: bool,
pub description: String,
pub body: Option<String>,
pub footers: Vec<String>,
}
impl ConventionalCommit {
pub fn is_breaking_change(&self) -> bool {
self.breaking_change
|| self
.footers
.iter()
.any(|f| f.starts_with("BREAKING CHANGE:") || f.starts_with("BREAKING-CHANGE:"))
}
}
#[derive(Debug)]
pub struct ValidationErrors(pub Vec<CommitValidationError>);
impl fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for (i, e) in self.0.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, " - {}", e)?;
}
Ok(())
}
}
#[derive(Error, Debug)]
pub enum CommitValidationError {
#[error("Commit message is empty")]
EmptyMessage,
#[error("Invalid format: expected 'type(scope): description'")]
InvalidFormat,
#[error("Missing commit type")]
MissingType,
#[error("Missing description")]
MissingDescription,
#[error("Description must start with lowercase letter")]
DescriptionNotLowercase,
#[error("Description must not end with a period")]
DescriptionEndsWithPeriod,
#[error("Description is too long (max 72 characters)")]
DescriptionTooLong,
#[error("Description is too short (min {0} characters)")]
DescriptionTooShort(usize),
#[error("Commit type '{0}' is not recognized")]
UnknownType(String),
#[error("Type '{0}' is not allowed. Allowed types are: {1}")]
TypeNotAllowed(String, String),
#[error("A blank line is required between the header and the body")]
MissingBlankLine,
#[error("Scope '{0}' is not in the allowed scopes list")]
ScopeNotAllowed(String),
#[error("Scope '{0}' must be lowercase")]
ScopeNotLowercase(String),
#[error("A scope is required")]
ScopeRequired,
#[error("No footer matches the required issue pattern '{0}'")]
MissingIssueRef(String),
#[error("Invalid issue pattern regex '{0}': {1}")]
InvalidIssuePattern(String, String),
#[error("Multiple validation errors:\n{0}")]
Multiple(ValidationErrors),
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub max_description_length: usize,
pub allow_custom_types: bool,
pub enforce_lowercase_description: bool,
pub disallow_description_period: bool,
pub allowed_scopes: Option<Vec<String>>,
pub enforce_lowercase_scope: bool,
pub require_scope: bool,
pub issue_pattern: Option<String>,
pub min_description_length: usize,
pub allowed_types: Option<Vec<String>>,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
max_description_length: 72,
allow_custom_types: true,
enforce_lowercase_description: true,
disallow_description_period: true,
allowed_scopes: None,
enforce_lowercase_scope: false,
require_scope: false,
issue_pattern: None,
min_description_length: 0,
allowed_types: None,
}
}
}
fn parse_header(
header: &str,
) -> Result<(String, Option<String>, bool, String), CommitValidationError> {
let bytes = header.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i == 0 {
return Err(CommitValidationError::MissingType);
}
let type_str = header[..i].to_string();
let scope = if bytes.get(i) == Some(&b'(') {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b')' {
i += 1;
}
if i >= bytes.len() {
return Err(CommitValidationError::InvalidFormat);
}
let sc = header[start..i].to_string();
i += 1;
Some(sc)
} else {
None
};
let breaking = if bytes.get(i) == Some(&b'!') {
i += 1;
true
} else {
false
};
if bytes.get(i) != Some(&b':') || bytes.get(i + 1) != Some(&b' ') {
return Err(CommitValidationError::InvalidFormat);
}
i += 2;
let description = header[i..].to_string();
if description.is_empty() {
return Err(CommitValidationError::MissingDescription);
}
Ok((type_str, scope, breaking, description))
}
fn parse_header_loose(header: &str) -> Option<(String, Option<String>, bool, String)> {
let bytes = header.as_bytes();
let mut i = 0;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
i += 1;
}
if i == 0 {
return None;
}
let type_str = header[..i].to_string();
let scope = if bytes.get(i) == Some(&b'(') {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b')' {
i += 1;
}
if i >= bytes.len() {
return None;
}
let sc = header[start..i].to_string();
i += 1;
Some(sc)
} else {
None
};
let breaking = if bytes.get(i) == Some(&b'!') {
i += 1;
true
} else {
false
};
if bytes.get(i) != Some(&b':') {
return None;
}
i += 1;
while i < bytes.len() && bytes[i] == b' ' {
i += 1;
}
let description = header[i..].to_string();
if description.is_empty() {
return None;
}
Some((type_str, scope, breaking, description))
}
pub fn validate_commit(message: &str) -> Result<ConventionalCommit, CommitValidationError> {
validate_commit_with_config(message, &ValidationConfig::default())
}
pub fn validate_commit_with_config(
message: &str,
config: &ValidationConfig,
) -> Result<ConventionalCommit, CommitValidationError> {
if message.trim().is_empty() {
return Err(CommitValidationError::EmptyMessage);
}
let lines: Vec<&str> = message.lines().collect();
let header = lines[0];
let (type_str, scope, breaking_change, description) = parse_header(header)?;
let mut errors: Vec<CommitValidationError> = Vec::new();
if lines.len() > 1 && !lines[1].trim().is_empty() {
errors.push(CommitValidationError::MissingBlankLine);
}
let commit_type = CommitType::from(type_str.as_str());
if !config.allow_custom_types {
if let CommitType::Custom(_) = commit_type {
errors.push(CommitValidationError::UnknownType(type_str.clone()));
}
}
if let Some(ref allowed) = config.allowed_types {
if !allowed.contains(&type_str) {
errors.push(CommitValidationError::TypeNotAllowed(
type_str.clone(),
allowed.join(", "),
));
}
}
if let Some(ref s) = scope {
if let Some(ref allowed) = config.allowed_scopes {
if !allowed.iter().any(|a| a == s) {
errors.push(CommitValidationError::ScopeNotAllowed(s.clone()));
}
}
if config.enforce_lowercase_scope && s.chars().any(|c| c.is_uppercase()) {
errors.push(CommitValidationError::ScopeNotLowercase(s.clone()));
}
} else if config.require_scope {
errors.push(CommitValidationError::ScopeRequired);
}
if config.enforce_lowercase_description {
if let Some(first) = description.chars().next() {
if !first.is_lowercase() {
errors.push(CommitValidationError::DescriptionNotLowercase);
}
}
}
if config.disallow_description_period && description.ends_with('.') {
errors.push(CommitValidationError::DescriptionEndsWithPeriod);
}
if description.len() > config.max_description_length {
errors.push(CommitValidationError::DescriptionTooLong);
}
if config.min_description_length > 0 && description.len() < config.min_description_length {
errors.push(CommitValidationError::DescriptionTooShort(
config.min_description_length,
));
}
let mut body_lines: Vec<String> = Vec::new();
let mut footer_lines: Vec<String> = Vec::new();
let mut is_breaking = breaking_change;
if lines.len() > 2 {
for line in lines[2..].iter() {
if is_breaking_footer(line) || is_footer(line) {
footer_lines.push(line.to_string());
if is_breaking_footer(line) {
is_breaking = true;
}
} else {
if !footer_lines.is_empty() {
body_lines.append(&mut footer_lines);
}
body_lines.push((*line).to_string());
}
}
}
if let Some(ref pattern) = config.issue_pattern {
let re = Regex::new(pattern).map_err(|e| {
CommitValidationError::InvalidIssuePattern(pattern.clone(), e.to_string())
})?;
if !footer_lines.iter().any(|f| re.is_match(f)) {
errors.push(CommitValidationError::MissingIssueRef(pattern.clone()));
}
}
if errors.len() == 1 {
return Err(errors.remove(0));
} else if errors.len() > 1 {
return Err(CommitValidationError::Multiple(ValidationErrors(errors)));
}
let body = if body_lines.is_empty() {
None
} else {
Some(body_lines.join("\n").trim().to_string())
};
Ok(ConventionalCommit {
commit_type,
scope,
breaking_change: is_breaking,
description,
body,
footers: footer_lines,
})
}
pub fn fix_commit_message(message: &str) -> String {
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return message.to_string();
}
let header = lines[0];
let fixed_header = match parse_header_loose(header) {
None => return message.to_string(),
Some((type_str, scope, breaking, description)) => {
let type_lower = type_str.to_lowercase();
let breaking_str = if breaking { "!" } else { "" };
let fixed_desc = fix_description(&description);
match scope {
Some(s) => format!("{}({}){}: {}", type_lower, s, breaking_str, fixed_desc),
None => format!("{}{}: {}", type_lower, breaking_str, fixed_desc),
}
}
};
if lines.len() == 1 {
fixed_header
} else {
format!("{}\n{}", fixed_header, lines[1..].join("\n"))
}
}
fn fix_description(desc: &str) -> String {
let desc = desc.trim();
let desc = desc.strip_suffix('.').unwrap_or(desc);
let mut chars = desc.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let lower: String = first.to_lowercase().collect();
lower + chars.as_str()
}
}
}
#[derive(Debug, Clone)]
pub struct CleanResult {
pub cleaned_message: String,
pub removed_lines: Vec<String>,
}
pub fn clean_commit_body(
message: &str,
starts_with_rules: &[&str],
regex_rules: &[&str],
) -> Result<CleanResult, regex::Error> {
let compiled: Vec<Regex> = regex_rules
.iter()
.map(|p| Regex::new(p))
.collect::<Result<Vec<_>, _>>()?;
let lines: Vec<&str> = message.lines().collect();
if lines.len() <= 1 || (starts_with_rules.is_empty() && compiled.is_empty()) {
return Ok(CleanResult {
cleaned_message: message.to_string(),
removed_lines: vec![],
});
}
let header = lines[0].to_string();
let mut removed_lines: Vec<String> = Vec::new();
let mut kept: Vec<String> = Vec::new();
for line in &lines[1..] {
let trimmed = line.trim_end();
let matches = starts_with_rules.iter().any(|r| trimmed.starts_with(r))
|| compiled.iter().any(|re| re.is_match(trimmed));
if matches {
removed_lines.push(line.to_string());
} else {
kept.push(trimmed.to_string());
}
}
let mut collapsed: Vec<String> = Vec::new();
let mut last_blank = false;
for line in kept {
let blank = line.is_empty();
if blank && last_blank {
continue;
}
last_blank = blank;
collapsed.push(line);
}
while collapsed
.last()
.map(|l: &String| l.is_empty())
.unwrap_or(false)
{
collapsed.pop();
}
let mut result_lines = vec![header];
result_lines.extend(collapsed);
Ok(CleanResult {
cleaned_message: result_lines.join("\n"),
removed_lines,
})
}
fn is_footer(line: &str) -> bool {
let token_end = line
.find(|c: char| !c.is_ascii_alphabetic() && c != '-')
.unwrap_or(0);
if token_end == 0 {
return false;
}
let rest = &line[token_end..];
(rest.starts_with(": ") && rest.len() > 2) || rest.starts_with(" #")
}
fn is_breaking_footer(line: &str) -> bool {
line.starts_with("BREAKING CHANGE: ") || line.starts_with("BREAKING-CHANGE: ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_commits() {
let valid_commits = vec![
"feat: add user authentication",
"feat(auth): add OAuth support",
"feat!: breaking change",
"fix: bug\n\nBody message", ];
for commit in valid_commits {
let res = validate_commit(commit);
assert!(res.is_ok(), "Failed on: {}\nError: {:?}", commit, res.err());
}
}
#[test]
fn test_invalid_commits() {
let invalid = vec![
("feat:no-space", "Missing space after colon"),
(
"feat: description\nNo blank line",
"Missing blank line rule",
),
];
for (msg, reason) in invalid {
let res = validate_commit(msg);
assert!(res.is_err(), "Should fail for {}: {}", reason, msg);
}
}
#[test]
fn test_breaking_change_footer() {
let msg = "feat: logic\n\nBREAKING CHANGE: this is a footer";
let commit = validate_commit(msg).unwrap();
assert!(
commit.breaking_change,
"Footer should trigger breaking_change flag"
);
}
#[test]
fn test_breaking_change_detection() {
let commit = validate_commit("feat!: breaking change").unwrap();
assert!(commit.breaking_change);
let commit_with_footer =
validate_commit("feat: new feature\n\nBREAKING CHANGE: this breaks something").unwrap();
assert!(commit_with_footer.is_breaking_change());
}
#[test]
fn test_commit_types() {
assert_eq!(CommitType::from("feat"), CommitType::Feat);
assert_eq!(CommitType::from("fix"), CommitType::Fix);
assert_eq!(
CommitType::from("custom"),
CommitType::Custom("custom".to_string())
);
}
#[test]
fn test_fix_missing_space() {
assert_eq!(fix_commit_message("feat:add thing"), "feat: add thing");
assert_eq!(fix_commit_message("fix:resolve bug"), "fix: resolve bug");
}
#[test]
fn test_fix_uppercase_description() {
assert_eq!(fix_commit_message("feat: Add thing"), "feat: add thing");
assert_eq!(fix_commit_message("fix: Resolve bug"), "fix: resolve bug");
}
#[test]
fn test_fix_trailing_period() {
assert_eq!(fix_commit_message("feat: add thing."), "feat: add thing");
assert_eq!(fix_commit_message("fix: resolve bug."), "fix: resolve bug");
}
#[test]
fn test_fix_all_at_once() {
assert_eq!(fix_commit_message("feat:Add thing."), "feat: add thing");
assert_eq!(
fix_commit_message("fix(parser):Resolve bug."),
"fix(parser): resolve bug"
);
}
#[test]
fn test_fix_preserves_valid_message() {
let msg = "feat: add thing";
assert_eq!(fix_commit_message(msg), msg);
}
#[test]
fn test_fix_with_scope_and_breaking() {
assert_eq!(
fix_commit_message("feat(auth)!:Add OAuth."),
"feat(auth)!: add OAuth"
);
}
#[test]
fn test_fix_preserves_body() {
let msg = "feat:Add thing.\n\nThis is the body.";
let fixed = fix_commit_message(msg);
assert_eq!(fixed, "feat: add thing\n\nThis is the body.");
}
#[test]
fn test_fix_type_casing() {
assert_eq!(fix_commit_message("Feat: add thing"), "feat: add thing");
assert_eq!(fix_commit_message("FIX: resolve bug"), "fix: resolve bug");
assert_eq!(
fix_commit_message("CHORE(deps): bump version"),
"chore(deps): bump version"
);
}
#[test]
fn test_fix_description_whitespace() {
assert_eq!(fix_commit_message("feat: add thing "), "feat: add thing");
assert_eq!(
fix_commit_message("fix: resolve bug "),
"fix: resolve bug"
);
}
#[test]
fn test_fix_unparseable_returns_original() {
let msg = "not a conventional commit at all";
assert_eq!(fix_commit_message(msg), msg);
}
fn config_with_scopes(scopes: &[&str]) -> ValidationConfig {
ValidationConfig {
allowed_scopes: Some(scopes.iter().map(|s| s.to_string()).collect()),
..ValidationConfig::default()
}
}
#[test]
fn test_allowed_types_validation() {
let config = ValidationConfig {
allowed_types: Some(vec!["feat".to_string(), "fix".to_string()]),
..ValidationConfig::default()
};
let res = validate_commit_with_config("feat: add feature", &config);
assert!(res.is_ok());
let res = validate_commit_with_config("chore: update deps", &config);
assert!(res.is_err());
if let Err(CommitValidationError::TypeNotAllowed(t, list)) = res {
assert_eq!(t, "chore");
assert!(list.contains("feat, fix"));
} else {
panic!("Expected TypeNotAllowed error");
}
}
#[test]
fn test_scope_allowed() {
let cfg = config_with_scopes(&["api", "client"]);
assert!(validate_commit_with_config("feat(api): add endpoint", &cfg).is_ok());
assert!(validate_commit_with_config("fix(client): handle timeout", &cfg).is_ok());
}
#[test]
fn test_scope_not_allowed() {
let cfg = config_with_scopes(&["api", "client"]);
let err = validate_commit_with_config("feat(ui): add button", &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::ScopeNotAllowed(ref s) if s == "ui"),
"unexpected error: {err}"
);
}
#[test]
fn test_no_scope_passes_when_scopes_restricted() {
let cfg = config_with_scopes(&["api"]);
assert!(validate_commit_with_config("feat: add thing", &cfg).is_ok());
}
#[test]
fn test_no_scope_restriction_accepts_anything() {
assert!(validate_commit("feat(whatever): add thing").is_ok());
}
fn config_with_issue_pattern(pattern: &str) -> ValidationConfig {
ValidationConfig {
issue_pattern: Some(pattern.to_string()),
..ValidationConfig::default()
}
}
#[test]
fn test_issue_pattern_matched_by_footer() {
let cfg = config_with_issue_pattern(r"^Refs: #\d+");
let msg = "feat: add feature\n\nRefs: #123";
assert!(validate_commit_with_config(msg, &cfg).is_ok());
}
#[test]
fn test_issue_pattern_matched_jira_style() {
let cfg = config_with_issue_pattern(r"^Fixes: [A-Z]+-\d+");
let msg = "fix: resolve crash\n\nFixes: PROJ-456";
assert!(validate_commit_with_config(msg, &cfg).is_ok());
}
#[test]
fn test_issue_pattern_no_matching_footer_fails() {
let cfg = config_with_issue_pattern(r"^Refs: #\d+");
let msg = "feat: add feature\n\nReviewed-by: Alice";
let err = validate_commit_with_config(msg, &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::MissingIssueRef(_)),
"unexpected error: {err}"
);
}
#[test]
fn test_issue_pattern_no_footers_at_all_fails() {
let cfg = config_with_issue_pattern(r"^Refs: #\d+");
let msg = "feat: add feature";
let err = validate_commit_with_config(msg, &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::MissingIssueRef(_)),
"unexpected error: {err}"
);
}
#[test]
fn test_issue_pattern_none_always_passes() {
assert!(validate_commit("feat: add feature").is_ok());
assert!(validate_commit("fix: resolve crash\n\nReviewed-by: Alice").is_ok());
}
#[test]
fn test_issue_pattern_invalid_regex_returns_error() {
let cfg = config_with_issue_pattern(r"[invalid(");
let msg = "feat: add feature\n\nRefs: #123";
let err = validate_commit_with_config(msg, &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::InvalidIssuePattern(_, _)),
"unexpected error: {err}"
);
}
#[test]
fn test_issue_pattern_one_of_multiple_footers_matches() {
let cfg = config_with_issue_pattern(r"^Closes: #\d+");
let msg = "feat: add feature\n\nReviewed-by: Bob\nCloses: #42";
assert!(validate_commit_with_config(msg, &cfg).is_ok());
}
#[test]
fn test_require_scope_missing() {
let cfg = ValidationConfig {
require_scope: true,
..ValidationConfig::default()
};
let err = validate_commit_with_config("feat: add thing", &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::ScopeRequired),
"unexpected error: {err}"
);
}
#[test]
fn test_require_scope_present_passes() {
let cfg = ValidationConfig {
require_scope: true,
..ValidationConfig::default()
};
assert!(validate_commit_with_config("feat(api): add thing", &cfg).is_ok());
}
#[test]
fn test_require_scope_not_enforced_by_default() {
assert!(validate_commit("feat: add thing").is_ok());
}
#[test]
fn test_scope_lowercase_enforced() {
let cfg = ValidationConfig {
enforce_lowercase_scope: true,
..ValidationConfig::default()
};
let err = validate_commit_with_config("feat(API): add endpoint", &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::ScopeNotLowercase(ref s) if s == "API"),
"unexpected error: {err}"
);
}
#[test]
fn test_scope_lowercase_not_enforced_by_default() {
assert!(validate_commit("feat(API): add endpoint").is_ok());
}
#[test]
fn test_scope_already_lowercase_passes() {
let cfg = ValidationConfig {
enforce_lowercase_scope: true,
..ValidationConfig::default()
};
assert!(validate_commit_with_config("feat(api): add endpoint", &cfg).is_ok());
}
#[test]
fn test_multiple_errors_collected() {
let err = validate_commit("feat: Add thing.").unwrap_err();
assert!(
matches!(err, CommitValidationError::Multiple(_)),
"expected Multiple, got: {err}"
);
}
#[test]
fn test_min_description_length_too_short() {
let cfg = ValidationConfig {
min_description_length: 20,
..ValidationConfig::default()
};
let err = validate_commit_with_config("feat: add thing", &cfg).unwrap_err();
assert!(
matches!(err, CommitValidationError::DescriptionTooShort(20)),
"unexpected error: {err}"
);
}
#[test]
fn test_min_description_length_ok() {
let cfg = ValidationConfig {
min_description_length: 5,
..ValidationConfig::default()
};
assert!(validate_commit_with_config("feat: add thing", &cfg).is_ok());
}
#[test]
fn test_min_description_length_zero_default() {
assert!(validate_commit("fix: x").is_ok());
}
#[test]
fn test_single_error_not_wrapped_in_multiple() {
let err = validate_commit("feat: Add thing").unwrap_err();
assert!(
matches!(err, CommitValidationError::DescriptionNotLowercase),
"expected DescriptionNotLowercase, got: {err}"
);
}
#[test]
fn clean_no_rules_returns_message_unchanged() {
let msg = "feat: add thing\n\nBody line.\nCo-authored-by: Alice";
let result = clean_commit_body(msg, &[], &[]).unwrap();
assert_eq!(result.cleaned_message, msg);
assert!(result.removed_lines.is_empty());
}
#[test]
fn clean_starts_with_removes_matching_lines() {
let msg =
"feat: add thing\n\nBody text.\nCo-authored-by: Alice <a@x.com>\nSigned-off-by: Bob";
let result = clean_commit_body(msg, &["Co-authored-by", "Signed-off-by"], &[]).unwrap();
assert_eq!(result.cleaned_message, "feat: add thing\n\nBody text.");
assert_eq!(result.removed_lines.len(), 2);
assert!(result.removed_lines[0].contains("Co-authored-by"));
assert!(result.removed_lines[1].contains("Signed-off-by"));
}
#[test]
fn clean_regex_removes_matching_lines() {
let msg = "fix: crash\n\nFixed the thing.\nCo-authored-by: Alice <a@x.com>";
let result = clean_commit_body(msg, &[], &[r"^Co-authored-by:"]).unwrap();
assert_eq!(result.cleaned_message, "fix: crash\n\nFixed the thing.");
assert_eq!(result.removed_lines.len(), 1);
}
#[test]
fn clean_both_rule_types_combined() {
let msg = "feat: thing\n\nBody.\nCo-authored-by: Alice\nSigned-off-by: Bob\n🤖 generated";
let result =
clean_commit_body(msg, &["Co-authored-by", "Signed-off-by"], &[r"^🤖"]).unwrap();
assert_eq!(result.cleaned_message, "feat: thing\n\nBody.");
assert_eq!(result.removed_lines.len(), 3);
}
#[test]
fn clean_collapses_consecutive_blank_lines() {
let msg = "feat: thing\n\nParagraph one.\n\n\nParagraph two.";
let result = clean_commit_body(msg, &["__no_match__"], &[]).unwrap();
assert_eq!(
result.cleaned_message,
"feat: thing\n\nParagraph one.\n\nParagraph two."
);
}
#[test]
fn clean_strips_trailing_blank_lines() {
let msg = "feat: thing\n\nBody.\n\n";
let result = clean_commit_body(msg, &["__no_match__"], &[]).unwrap();
assert_eq!(result.cleaned_message, "feat: thing\n\nBody.");
}
#[test]
fn clean_trims_trailing_whitespace_per_line() {
let msg = "feat: thing\n\nLine with spaces. \nAnother line. ";
let result = clean_commit_body(msg, &["__no_match__"], &[]).unwrap();
assert_eq!(
result.cleaned_message,
"feat: thing\n\nLine with spaces.\nAnother line."
);
}
#[test]
fn clean_header_only_message_is_unchanged() {
let msg = "feat: add thing";
let result = clean_commit_body(msg, &["Co-authored-by"], &[]).unwrap();
assert_eq!(result.cleaned_message, msg);
assert!(result.removed_lines.is_empty());
}
#[test]
fn clean_invalid_regex_returns_error() {
let msg = "feat: thing\n\nBody.";
let err = clean_commit_body(msg, &[], &[r"[invalid("]);
assert!(err.is_err());
}
#[test]
fn clean_does_not_touch_header() {
let msg = "feat: add thing\n\nCo-authored-by: Alice";
let result = clean_commit_body(msg, &["feat"], &[]).unwrap();
assert!(result.cleaned_message.starts_with("feat: add thing"));
}
#[test]
fn clean_preserves_message_when_no_lines_removed() {
let msg = "fix: resolve crash\n\nFixed the null pointer.\n\nRefs: #42";
let result = clean_commit_body(msg, &["Co-authored-by"], &[]).unwrap();
assert_eq!(result.cleaned_message, msg);
assert!(result.removed_lines.is_empty());
}
#[test]
fn test_allowed_types_with_custom_type() {
let config = ValidationConfig {
allowed_types: Some(vec!["api".to_string()]),
..ValidationConfig::default()
};
let res = validate_commit_with_config("api: new endpoint", &config);
assert!(res.is_ok());
let res = validate_commit_with_config("feat: add feature", &config);
assert!(res.is_err());
}
}