use crate::config::commit_msg_rule::ParsedCommitMsgRule;
use crate::error::body_error::BodyError;
use crate::error::body_error::BodyError::{BodyLineLengthInvalid, EmptyBody, TrailingWhitespace};
use crate::error::commit_msg_error::CommitMsgError;
use crate::error::footer_error::FooterError;
use crate::error::footer_error::FooterError::{
FooterKeywordTypoError, FooterLineLengthInvalid, FooterStartKeywordInvalid,
FooterTrailingWhitespace,
};
use crate::error::header_error::HeaderError::{
EmptyAllowedScopes, EmptyAllowedTypes, EmptyScope, EmptySubject, InvalidSubjectLength,
NotAllowedScope, NotAllowedType, SpaceAfterColonNotMatch, SubjectEndsWithPeriod, TypeTypo,
};
use crate::parser::commit_msg::ParsedCommitMessage;
use crate::parser::header::ParsedHeader;
use strsim::normalized_levenshtein;
pub fn validate_commit_msg(
parsed_commit_msg: &ParsedCommitMessage,
parsed_commit_msg_rule: &ParsedCommitMsgRule,
) -> Result<bool, CommitMsgError> {
let header = &parsed_commit_msg.header;
validate_type(header, parsed_commit_msg_rule)?;
validate_scope(header, parsed_commit_msg_rule)?;
validate_subject(header, parsed_commit_msg_rule)?;
validate_body(parsed_commit_msg, parsed_commit_msg_rule)?;
validate_footer(parsed_commit_msg, parsed_commit_msg_rule)?;
Ok(true)
}
fn validate_type(header: &ParsedHeader, rule: &ParsedCommitMsgRule) -> Result<(), CommitMsgError> {
if let Some(allowed) = &rule.header.r#type.allowed_types {
if allowed.is_empty() {
return Err(CommitMsgError::Header(EmptyAllowedTypes));
}
if !allowed.contains(&header.r#type) {
let threshold = 0.8;
if let Some((correct, similarity)) =
detect_type_typo(&header.r#type, allowed, threshold)
{
return Err(CommitMsgError::Header(TypeTypo {
wrong: header.r#type.clone(),
correct,
similarity,
allowed_types: allowed.clone(),
}));
}
return Err(CommitMsgError::Header(NotAllowedType {
r#type: header.r#type.clone(),
allowed_types: allowed.clone(),
}));
}
}
Ok(())
}
pub fn detect_type_typo(wrong: &str, allowed: &[String], threshold: f64) -> Option<(String, f64)> {
let mut best = None;
let mut best_score = 0.0;
for valid in allowed {
let score = normalized_levenshtein(wrong, valid);
if score > best_score {
best_score = score;
best = Some(valid.clone());
}
}
if best_score >= threshold {
best.map(|b| (b, best_score))
} else {
None
}
}
fn validate_scope(header: &ParsedHeader, rule: &ParsedCommitMsgRule) -> Result<(), CommitMsgError> {
let Some(scope_cfg) = &rule.header.scope else {
return Ok(()); };
let scope = header.scope.as_ref();
let allowed = scope_cfg.allowed_scopes.as_ref();
if scope_cfg.required == Some(true) {
let Some(scope_value) = scope else {
return Err(CommitMsgError::Header(EmptyScope));
};
let Some(allowed_scopes) = allowed else {
return Err(CommitMsgError::Header(EmptyAllowedScopes));
};
if allowed_scopes.is_empty() {
return Err(CommitMsgError::Header(EmptyAllowedScopes));
}
if !allowed_scopes.contains(scope_value) {
return Err(CommitMsgError::Header(NotAllowedScope {
scope: scope_value.clone(),
allowed_scopes: allowed_scopes.clone(),
}));
}
return Ok(());
}
let Some(scope_value) = scope else {
return Ok(());
};
let Some(allowed_scopes) = allowed else {
return Ok(());
};
if allowed_scopes.is_empty() {
return Err(CommitMsgError::Header(EmptyAllowedScopes));
}
if !allowed_scopes.contains(scope_value) {
return Err(CommitMsgError::Header(NotAllowedScope {
scope: scope_value.clone(),
allowed_scopes: allowed_scopes.clone(),
}));
}
Ok(())
}
fn validate_subject(
header: &ParsedHeader,
rule: &ParsedCommitMsgRule,
) -> Result<(), CommitMsgError> {
let subject_cfg = &rule.header.subject;
let subject = &header.subject;
if subject.is_empty() {
return Err(CommitMsgError::Header(EmptySubject));
}
let expected_spaces = subject_cfg.spaces_after_colon.unwrap_or(1);
if header.spaces_after_colon != expected_spaces {
return Err(CommitMsgError::Header(SpaceAfterColonNotMatch {
expected: expected_spaces,
actual: header.spaces_after_colon,
}));
}
if rule.header.subject.forbid_trailing_period
&& (subject.ends_with('.') || subject.ends_with('。'))
{
return Err(CommitMsgError::Header(SubjectEndsWithPeriod));
}
let subject_len = subject.chars().count();
if subject_len < subject_cfg.min_length || subject_len > subject_cfg.max_length {
return Err(CommitMsgError::Header(InvalidSubjectLength {
min: subject_cfg.min_length,
max: subject_cfg.max_length,
actual: subject_len,
}));
}
Ok(())
}
pub fn validate_body(
parsed: &ParsedCommitMessage,
rule: &ParsedCommitMsgRule,
) -> Result<(), CommitMsgError> {
let Some(body_rule) = &rule.body else {
return Ok(());
};
if body_rule.required && parsed.body.is_none() {
return Err(CommitMsgError::Body(EmptyBody));
}
let Some(body) = parsed.body.as_deref() else {
return Ok(());
};
if parsed.blank_lines_before_body < body_rule.min_blank_lines_before_body {
return Err(CommitMsgError::Body(
BodyError::BlankLinesBeforeBodyNotEnough {
min_line: body_rule.min_blank_lines_before_body,
current_line: parsed.blank_lines_before_body,
},
));
}
if body_rule.forbid_trailing_whitespace {
for (i, line) in body.lines().enumerate() {
if line.ends_with(' ') {
return Err(CommitMsgError::Body(TrailingWhitespace {
line_number: i + 1,
}));
}
}
}
for (i, line) in body.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let len = trimmed.chars().count();
if len < body_rule.min_line_length || len > body_rule.max_line_length {
return Err(CommitMsgError::Body(BodyLineLengthInvalid {
line_number: i + 1,
min: body_rule.min_line_length,
max: body_rule.max_line_length,
actual: len,
}));
}
}
Ok(())
}
pub fn validate_footer(
parsed: &ParsedCommitMessage,
rule: &ParsedCommitMsgRule,
) -> Result<(), CommitMsgError> {
let Some(footer_rule) = &rule.footer else {
return Ok(()); };
let Some(_footer) = parsed.footer.as_deref() else {
return Ok(());
};
if parsed.blank_lines_before_footer < footer_rule.min_blank_lines_before_footer {
return Err(CommitMsgError::Footer(
FooterError::BlankLinesBeforeFooterNotEnough {
min_line: footer_rule.min_blank_lines_before_footer,
current_line: parsed.blank_lines_before_footer,
},
));
}
if !footer_rule.start_key_words.is_empty() {
let footer_text = parsed.footer.as_deref().ok_or(FooterError::MissingFooter)?;
let first_line = footer_text.lines().next().unwrap_or("").trim();
let (keyword, _) = match first_line.split_once(':') {
Some(v) => v,
None => {
return Err(CommitMsgError::Footer(FooterStartKeywordInvalid {
allowed: footer_rule.start_key_words.clone(),
actual: first_line.to_string(),
}));
}
};
let keyword = keyword.trim();
#[allow(clippy::expect_used)]
let spell_cfg = footer_rule
.start_key_words_spellcheck
.as_ref()
.expect("start_key_words_spellcheck must exist when start_key_words is non-empty");
let threshold = spell_cfg.threshold;
let best_match = footer_rule
.start_key_words
.iter()
.map(|k| (k, strsim::normalized_levenshtein(keyword, k)))
.max_by(|a, b| a.1.total_cmp(&b.1));
if let Some((correct, similarity)) = best_match {
if similarity < threshold {
return Err(CommitMsgError::Footer(FooterStartKeywordInvalid {
actual: keyword.to_string(),
allowed: footer_rule.start_key_words.clone(),
}));
}
if similarity < 1.0 {
return Err(CommitMsgError::Footer(FooterKeywordTypoError {
wrong: keyword.to_string(),
correct: correct.clone(),
similarity,
threshold,
}));
}
}
}
if let Some(footer) = &parsed.footer {
for (i, line) in footer.lines().enumerate() {
let width = line.chars().count();
if width < footer_rule.min_line_length || width > footer_rule.max_line_length {
return Err(CommitMsgError::Footer(FooterLineLengthInvalid {
line_number: i + 1,
min: footer_rule.min_line_length,
max: footer_rule.max_line_length,
actual: width,
}));
}
}
}
if footer_rule.forbid_trailing_whitespace
&& let Some(footer) = &parsed.footer
{
for (i, line) in footer.lines().enumerate() {
if line.ends_with(' ') {
return Err(CommitMsgError::Footer(FooterTrailingWhitespace {
line_number: i + 1,
}));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::constant::COMMIT_MSG_RULE_TEMPLATE;
use crate::parser::commit_msg::parse_commit_msg;
#[test]
fn test_validate_commit_msg() {
let commit_msg = r#"feat: add new feature"#;
let parsed_commit_msg = parse_commit_msg(commit_msg);
let parsed_commit_msg_rule =
config::commit_msg_rule::parse_commit_msg_rule(COMMIT_MSG_RULE_TEMPLATE);
let is_valid = validate_commit_msg(
parsed_commit_msg.as_ref().unwrap(),
&parsed_commit_msg_rule.unwrap(),
);
println!("is_valid: {:?}", is_valid);
assert!(is_valid.is_ok());
}
#[test]
fn test_type_typo() {
let commit_msg = r#"feats: add new feature"#;
let parsed_commit_msg = parse_commit_msg(commit_msg);
let parsed_commit_msg_rule =
config::commit_msg_rule::parse_commit_msg_rule(COMMIT_MSG_RULE_TEMPLATE);
let is_valid = validate_commit_msg(
parsed_commit_msg.as_ref().unwrap(),
&parsed_commit_msg_rule.unwrap(),
);
assert!(is_valid.as_ref().is_err());
match is_valid.as_ref().unwrap_err() {
CommitMsgError::Header(TypeTypo {
wrong,
correct,
similarity,
allowed_types: _,
}) => {
assert_eq!(*correct, "feat".to_string());
assert!(*similarity > 0.7);
assert_eq!(*wrong, "feats".to_string());
}
_ => {
panic!("unexpected error: {:?}", is_valid);
}
}
}
#[test]
fn test_valid_body_and_footer() {
let msg = "\
feat: add new API
This is body line 1
This is body line 2
BREAKING CHANGE: API changed
";
let parsed = parse_commit_msg(msg);
println!("{:#?}", parsed);
}
}