use crate::{
config::CommitConfig,
error::{CommitGenError, Result},
git::git_command,
style::{self, icons},
types::ConventionalCommit,
};
const CODE_EXTENSIONS: &[&str] = &[
"rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
"java", "kt", "kts", "scala", "groovy", "clj", "cljs", "cs", "fs", "vb", "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", "py", "pyx", "pxd", "pyi", "rb", "rake", "gemspec", "php", "go", "swift", "m", "mm", "lua", "sh", "bash", "zsh", "fish", "pl", "pm", "hs", "lhs", "ml", "mli", "elm", "ex", "exs", "erl", "hrl", "lisp", "cl", "el", "scm", "rkt", "jl", "r", "dart", "cr", "d", "f", "f90", "f95", "f03", "f08", "ada", "adb", "ads", "cob", "cbl", "asm", "s", "sql", "plsql", "pro", "re", "rei", "nix", "tf", "hcl", "sol", "move", "cairo",
];
fn is_code_extension(ext: &str) -> bool {
CODE_EXTENSIONS.iter().any(|&e| e.eq_ignore_ascii_case(ext))
}
fn get_repository_name() -> Result<String> {
let output = git_command()
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| CommitGenError::git(e.to_string()))?;
if !output.status.success() {
return Err(CommitGenError::git("Failed to get repository root".to_string()));
}
let path = String::from_utf8_lossy(&output.stdout);
let repo_name = std::path::Path::new(path.trim())
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| CommitGenError::git("Could not extract repository name".to_string()))?;
Ok(repo_name.to_string())
}
fn normalize_name(name: &str) -> String {
name.to_lowercase().replace(['-', '_'], "")
}
pub fn is_past_tense_verb(word: &str) -> bool {
if word.ends_with("ed") {
const BLOCKLIST: &[&str] = &["hundred", "thousand", "red", "bed", "wed", "shed"];
return !BLOCKLIST.contains(&word);
}
if word.len() >= 4 && word.ends_with('d') {
let before_d = &word[word.len() - 2..word.len() - 1];
if "aeiou".contains(before_d) {
const D_BLOCKLIST: &[&str] = &[
"and", "bad", "bid", "god", "had", "kid", "lad", "mad", "mid", "mud", "nod", "odd",
"old", "pad", "raid", "said", "sad", "should", "would", "could",
];
return !D_BLOCKLIST.contains(&word);
}
}
const IRREGULAR: &[&str] = &[
"made",
"built",
"ran",
"wrote",
"took",
"gave",
"found",
"kept",
"left",
"felt",
"meant",
"sent",
"spent",
"lost",
"held",
"told",
"sold",
"stood",
"understood",
"became",
"began",
"brought",
"bought",
"caught",
"taught",
"thought",
"fought",
"sought",
"chose",
"came",
"did",
"got",
"had",
"knew",
"met",
"put",
"read",
"saw",
"said",
"set",
"sat",
"cut",
"let",
"hit",
"hurt",
"shut",
"split",
"spread",
"bet",
"cast",
"cost",
"quit",
];
IRREGULAR.contains(&word)
}
pub fn validate_commit_message(msg: &ConventionalCommit, config: &CommitConfig) -> Result<()> {
let valid_types = [
"feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
"deps", "security", "config", "ux", "release", "hotfix", "infra", "init", "merge", "hack",
"wip",
];
if !valid_types.contains(&msg.commit_type.as_str()) {
return Err(CommitGenError::InvalidCommitType(format!(
"Invalid commit type: '{}'. Must be one of: {}",
msg.commit_type,
valid_types.join(", ")
)));
}
if let Some(scope) = &msg.scope
&& scope.is_empty()
{
return Err(CommitGenError::InvalidScope(
"Scope cannot be empty string (omit if not applicable)".to_string(),
));
}
if let Some(scope) = &msg.scope
&& let Ok(repo_name) = get_repository_name()
{
let normalized_scope = normalize_name(scope.as_str());
let normalized_repo = normalize_name(&repo_name);
if normalized_scope == normalized_repo {
return Err(CommitGenError::InvalidScope(format!(
"Scope '{scope}' is the project name - omit scope for project-wide changes"
)));
}
}
if msg.summary.as_str().trim().is_empty() {
return Err(CommitGenError::ValidationError("Summary cannot be empty".to_string()));
}
if msg.summary.as_str().trim_end().ends_with('.') {
return Err(CommitGenError::ValidationError(
"Summary must NOT end with a period (conventional commits style)".to_string(),
));
}
let scope_part = msg
.scope
.as_ref()
.map(|s| format!("({s})"))
.unwrap_or_default();
let first_line_len = msg.commit_type.len() + scope_part.len() + 2 + msg.summary.len();
if first_line_len > config.summary_hard_limit {
return Err(CommitGenError::SummaryTooLong {
len: first_line_len,
max: config.summary_hard_limit,
});
}
if first_line_len > config.summary_soft_limit {
style::warn(&format!(
"Summary exceeds soft limit: {} > {} chars (retry recommended)",
first_line_len, config.summary_soft_limit
));
}
if first_line_len > config.summary_guideline && first_line_len <= config.summary_soft_limit {
eprintln!(
"{} {}",
style::info(icons::INFO),
style::info(&format!(
"Summary exceeds guideline: {} > {} chars (still acceptable)",
first_line_len, config.summary_guideline
))
);
}
let first_word = msg.summary.as_str().split_whitespace().next().unwrap_or("");
if first_word.is_empty() {
return Err(CommitGenError::ValidationError(
"Summary must contain at least one word".to_string(),
));
}
let first_word_lower = first_word.to_lowercase();
if !is_past_tense_verb(&first_word_lower) {
return Err(CommitGenError::ValidationError(format!(
"Summary must start with a past-tense verb (ending in -ed/-d or irregular). Got \
'{first_word}'"
)));
}
let type_word = msg.commit_type.as_str();
if first_word_lower == type_word {
return Err(CommitGenError::ValidationError(format!(
"Summary repeats commit type '{type_word}': first word is '{first_word}'"
)));
}
const FILLER_WORDS: &[&str] = &["comprehensive", "better", "various", "several"];
for filler in FILLER_WORDS {
if msg.summary.as_str().to_lowercase().contains(filler) {
style::warn(&format!("Summary contains filler word '{}': {}", filler, msg.summary));
}
}
const META_PHRASES: &[&str] = &[
"this commit",
"this change",
"updated code",
"updated the",
"modified code",
"changed code",
"improved code",
"modified the",
"changed the",
];
for phrase in META_PHRASES {
if msg.summary.as_str().to_lowercase().contains(phrase) {
style::warn(&format!(
"Summary contains meta-phrase '{phrase}' - be more specific about what changed"
));
}
}
let final_scope_part = msg
.scope
.as_ref()
.map(|s| format!("({s})"))
.unwrap_or_default();
let final_first_line_len =
msg.commit_type.len() + final_scope_part.len() + 2 + msg.summary.len();
if final_first_line_len > config.summary_hard_limit {
return Err(CommitGenError::SummaryTooLong {
len: final_first_line_len,
max: config.summary_hard_limit,
});
}
for item in &msg.body {
let first_word = item.split_whitespace().next().unwrap_or("");
let present_tense = [
"adds",
"fixes",
"updates",
"removes",
"changes",
"creates",
"refactors",
"implements",
"migrates",
"renames",
"moves",
"replaces",
"improves",
"merges",
"splits",
"extracts",
"restructures",
"reorganizes",
"consolidates",
];
if present_tense
.iter()
.any(|&word| first_word.to_lowercase() == word)
{
style::warn(&format!("Body item uses present tense: '{item}'"));
}
if !item.trim_end().ends_with('.') {
style::warn(&format!("Body item missing period: '{item}'"));
}
}
Ok(())
}
pub fn check_type_scope_consistency(msg: &ConventionalCommit, stat: &str) {
let commit_type = msg.commit_type.as_str();
if commit_type == "docs" {
let has_docs = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim();
let is_doc_file = std::path::Path::new(&path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"md" | "mdx" | "adoc" | "asciidoc" | "rst" | "txt" | "org" | "tex" | "pod"
)
});
is_doc_file
|| path.to_lowercase().contains("/docs/")
|| path.to_lowercase().contains("readme")
});
if !has_docs {
style::warn("Commit type 'docs' but no documentation files changed");
}
}
if commit_type == "test" {
let has_test = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
path.contains("/test") || path.contains("_test.") || path.contains(".test.")
});
if !has_test {
style::warn("Commit type 'test' but no test files changed");
}
}
if commit_type == "style" {
let has_code = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim();
let path_obj = std::path::Path::new(&path);
path_obj
.extension()
.is_some_and(|ext| is_code_extension(ext.to_str().unwrap_or("")))
});
if has_code {
style::warn("Commit type 'style' but code files changed (verify no logic changes)");
}
}
if commit_type == "ci" {
let has_ci = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
path.contains(".github/workflows")
|| path.contains(".gitlab-ci")
|| path.contains("jenkinsfile")
});
if !has_ci {
style::warn("Commit type 'ci' but no CI configuration files changed");
}
}
if commit_type == "build" {
let has_build = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
path.contains("cargo.toml")
|| path.contains("package.json")
|| path.contains("makefile")
|| path.contains("build.")
});
if !has_build {
style::warn("Commit type 'build' but no build files (Cargo.toml, package.json) changed");
}
}
if commit_type == "refactor" {
let has_new_files = stat
.lines()
.any(|line| line.trim().starts_with("create mode") || line.contains("new file"));
if has_new_files {
style::warn(
"Commit type 'refactor' but new files were created - verify no new capabilities added \
(might be 'feat')",
);
}
}
if commit_type == "perf" {
let has_perf_files = stat.lines().any(|line| {
let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
path.contains("bench") || path.contains("perf") || path.contains("profile")
});
let details_text = msg.body.join(" ").to_lowercase();
let has_perf_details = details_text.contains("faster")
|| details_text.contains("optimization")
|| details_text.contains("performance")
|| details_text.contains("optimized");
if !has_perf_files && !has_perf_details {
style::warn(
"Commit type 'perf' but no performance-related files or optimization keywords found",
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommitSummary, CommitType, ConventionalCommit, Scope};
fn create_commit(
type_str: &str,
scope: Option<&str>,
summary: &str,
body: Vec<&str>,
) -> ConventionalCommit {
ConventionalCommit {
commit_type: CommitType::new(type_str).unwrap(),
scope: scope.map(|s| Scope::new(s).unwrap()),
summary: CommitSummary::new_unchecked(summary, 128).unwrap(),
body: body.into_iter().map(|s| s.to_string()).collect(),
footers: vec![],
}
}
#[test]
fn test_validate_valid_commit() {
let config = CommitConfig::default();
let msg = create_commit("feat", Some("api"), "added new endpoint", vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_valid_commit_no_scope() {
let config = CommitConfig::default();
let msg = create_commit("fix", None, "corrected race condition", vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_invalid_type() {
let _config = CommitConfig::default();
let result = CommitType::new("invalid");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::InvalidCommitType(_)));
}
#[test]
fn test_validate_summary_ends_with_period() {
let config = CommitConfig::default();
let msg = create_commit("feat", Some("api"), "added endpoint.", vec![]);
let result = validate_commit_message(&msg, &config);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must NOT end with a period")
);
}
#[test]
fn test_validate_summary_too_long() {
let long_summary = "a".repeat(129);
let result = CommitSummary::new(&long_summary, 128);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::SummaryTooLong { .. }));
}
#[test]
fn test_validate_summary_empty() {
let result = CommitSummary::new("", 128);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::ValidationError(_)));
}
#[test]
fn test_validate_summary_empty_whitespace() {
let result = CommitSummary::new(" ", 128);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::ValidationError(_)));
}
#[test]
fn test_validate_wrong_verb() {
let config = CommitConfig::default();
let result = CommitSummary::new_unchecked("adding new feature", 128);
assert!(result.is_ok());
let msg = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: None,
summary: result.unwrap(),
body: vec![],
footers: vec![],
};
let result = validate_commit_message(&msg, &config);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must start with a past-tense verb")
);
}
#[test]
fn test_validate_present_tense_verb() {
let config = CommitConfig::default();
let result = CommitSummary::new_unchecked("adds new feature", 128);
assert!(result.is_ok());
let msg = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: None,
summary: result.unwrap(),
body: vec![],
footers: vec![],
};
let result = validate_commit_message(&msg, &config);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must start with a past-tense verb")
);
}
#[test]
fn test_validate_no_type_verb_overlap() {
let config = CommitConfig::default();
let msg = create_commit("docs", Some("api"), "documented new api", vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
let msg = create_commit("test", Some("api"), "added unit tests", vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_morphology_based_past_tense() {
let config = CommitConfig::default();
let regular_verbs = ["added", "configured", "exposed", "formatted", "clarified"];
for verb in regular_verbs {
let summary = format!("{verb} something");
let msg = create_commit("feat", None, &summary, vec![]);
assert!(
validate_commit_message(&msg, &config).is_ok(),
"Regular verb '{verb}' should be accepted"
);
}
let irregular_verbs = ["made", "built", "ran", "wrote", "split"];
for verb in irregular_verbs {
let summary = format!("{verb} something");
let msg = create_commit("feat", None, &summary, vec![]);
assert!(
validate_commit_message(&msg, &config).is_ok(),
"Irregular verb '{verb}' should be accepted"
);
}
let non_verbs = ["hundred", "red", "bed"];
for word in non_verbs {
let summary = format!("{word} something");
let msg = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: None,
summary: CommitSummary::new_unchecked(&summary, 128).unwrap(),
body: vec![],
footers: vec![],
};
assert!(
validate_commit_message(&msg, &config).is_err(),
"Non-verb '{word}' should be rejected"
);
}
}
#[test]
fn test_validate_scope_empty_string() {
let result = Scope::new("");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::InvalidScope(_)));
}
#[test]
fn test_validate_scope_invalid_chars() {
let result = Scope::new("API/New");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::InvalidScope(_)));
}
#[test]
fn test_validate_scope_too_many_segments() {
let result = Scope::new("core/api/http");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("max 2 allowed"));
}
#[test]
fn test_validate_scope_valid_single() {
let result = Scope::new("api");
assert!(result.is_ok());
}
#[test]
fn test_validate_scope_valid_two_segments() {
let result = Scope::new("core/api");
assert!(result.is_ok());
}
#[test]
fn test_validate_scope_with_dash_underscore() {
let result = Scope::new("core_api/http-client");
assert!(result.is_ok());
}
#[test]
fn test_validate_total_length_at_guideline() {
let config = CommitConfig::default();
let summary = format!("added {}", "x".repeat(53));
let msg = create_commit("feat", Some("scope"), &summary, vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_total_length_at_soft_limit() {
let config = CommitConfig::default();
let summary = format!("added {}", "x".repeat(77));
let msg = create_commit("feat", Some("scope"), &summary, vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_total_length_at_hard_limit() {
let config = CommitConfig::default();
let summary = format!("added {}", "x".repeat(109));
let msg = create_commit("feat", Some("scope"), &summary, vec![]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_total_length_over_hard_limit() {
let config = CommitConfig::default();
let summary = "a".repeat(116);
let msg = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: Some(Scope::new("scope").unwrap()),
summary: CommitSummary::new_unchecked(&summary, 128).unwrap(),
body: vec![],
footers: vec![],
};
let result = validate_commit_message(&msg, &config);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CommitGenError::SummaryTooLong { .. }));
}
#[test]
fn test_check_type_scope_docs_with_md() {
let msg = create_commit("docs", Some("readme"), "updated installation guide", vec![]);
let stat = " README.md | 10 +++++++---\n 1 file changed, 7 insertions(+), 3 deletions(-)";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_docs_without_md() {
let msg = create_commit("docs", None, "updated documentation", vec![]);
let stat = " src/main.rs | 10 +++++++---\n 1 file changed, 7 insertions(+), 3 deletions(-)";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_test_with_test_files() {
let msg = create_commit("test", Some("api"), "added integration tests", vec![]);
let stat = " tests/integration_test.rs | 50 ++++++++++++++++++++++++++++++++\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_test_without_test_files() {
let msg = create_commit("test", None, "added tests", vec![]);
let stat = " src/lib.rs | 10 +++++++---\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_refactor_new_files() {
let msg = create_commit("refactor", Some("core"), "restructured modules", vec![]);
let stat = " create mode 100644 src/new_module.rs\n src/lib.rs | 10 +++++++---\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_ci_with_workflow() {
let msg = create_commit("ci", None, "updated github actions", vec![]);
let stat = " .github/workflows/ci.yml | 20 ++++++++++++++++++++\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_build_with_cargo() {
let msg = create_commit("build", Some("deps"), "updated dependencies", vec![]);
let stat = " Cargo.toml | 5 +++--\n Cargo.lock | 150 +++++++++++++++++++\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_perf_with_details() {
let msg = create_commit("perf", Some("core"), "optimized batch processing", vec![
"reduced allocations by 50% for faster throughput.",
]);
let stat = " src/core.rs | 30 +++++++++++++-----------------\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_check_type_scope_perf_without_evidence() {
let msg = create_commit("perf", None, "changed algorithm", vec![]);
let stat = " src/lib.rs | 10 +++++++---\n";
check_type_scope_consistency(&msg, stat);
}
#[test]
fn test_validate_body_present_tense_warning() {
let config = CommitConfig::default();
let msg = create_commit("feat", None, "added new feature", vec![
"adds support for TLS.",
"updates configuration.",
]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_validate_body_missing_period_warning() {
let config = CommitConfig::default();
let msg = create_commit("feat", None, "added new feature", vec![
"added support for TLS",
"updated configuration",
]);
assert!(validate_commit_message(&msg, &config).is_ok());
}
#[test]
fn test_commit_type_case_normalization() {
assert!(CommitType::new("FEAT").is_ok());
assert!(CommitType::new("Feat").is_ok());
assert!(CommitType::new("feat").is_ok());
assert_eq!(CommitType::new("FEAT").unwrap().as_str(), "feat");
}
#[test]
fn test_commit_type_all_valid() {
let valid_types = [
"feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
"revert",
];
for t in &valid_types {
assert!(CommitType::new(*t).is_ok(), "Type '{t}' should be valid");
}
}
#[test]
fn test_summary_length_boundaries() {
let summary_72 = "a".repeat(72);
assert!(CommitSummary::new(&summary_72, 128).is_ok());
let summary_96 = "a".repeat(96);
assert!(CommitSummary::new(&summary_96, 128).is_ok());
let summary_128 = "a".repeat(128);
assert!(CommitSummary::new(&summary_128, 128).is_ok());
let summary_129 = "a".repeat(129);
let result = CommitSummary::new(&summary_129, 128);
assert!(result.is_err());
match result.unwrap_err() {
CommitGenError::SummaryTooLong { len, max } => {
assert_eq!(len, 129);
assert_eq!(max, 128);
},
_ => panic!("Expected SummaryTooLong error"),
}
}
}