use unicode_normalization::UnicodeNormalization;
use crate::{config::CommitConfig, types::ConventionalCommit, validation::is_past_tense_verb};
pub fn normalize_unicode(text: &str) -> String {
let pre_normalized = text
.replace('≠', "!=") .replace('½', "1/2")
.replace('¼', "1/4")
.replace('¾', "3/4")
.replace('⅓', "1/3")
.replace('⅔', "2/3")
.replace('⅕', "1/5")
.replace('⅖', "2/5")
.replace('⅗', "3/5")
.replace('⅘', "4/5")
.replace('⅙', "1/6")
.replace('⅚', "5/6")
.replace('⅛', "1/8")
.replace('⅜', "3/8")
.replace('⅝', "5/8")
.replace('⅞', "7/8")
.replace('⁰', "^0")
.replace('¹', "^1")
.replace('²', "^2")
.replace('³', "^3")
.replace('⁴', "^4")
.replace('⁵', "^5")
.replace('⁶', "^6")
.replace('⁷', "^7")
.replace('⁸', "^8")
.replace('⁹', "^9")
.replace('₀', "_0")
.replace('₁', "_1")
.replace('₂', "_2")
.replace('₃', "_3")
.replace('₄', "_4")
.replace('₅', "_5")
.replace('₆', "_6")
.replace('₇', "_7")
.replace('₈', "_8")
.replace('₉', "_9");
let normalized: String = pre_normalized.nfkd().collect();
normalized
.replace(['\u{2018}', '\u{2019}'], "'") .replace(['\u{201C}', '\u{201D}'], "\"") .replace('\u{201A}', "'") .replace(['\u{201E}', '\u{00AB}', '\u{00BB}'], "\"") .replace(['\u{2039}', '\u{203A}'], "'") .replace(['\u{2010}', '\u{2011}', '\u{2012}'], "-") .replace(['\u{2013}', '\u{2014}', '\u{2015}'], "--") .replace('\u{2212}', "-") .replace('\u{2192}', "->") .replace('←', "<-") .replace('↔', "<->") .replace('⇒', "=>") .replace('⇐', "<=") .replace('⇔', "<=>") .replace('↑', "^") .replace('↓', "v") .replace('\u{2264}', "<=") .replace('≥', ">=") .replace('≈', "~=") .replace('≡', "==") .replace('\u{00D7}', "x") .replace('÷', "/") .replace(['\u{2026}', '⋯', '⋮'], "...") .replace(['•', '◦', '▪', '▫', '◆', '◇'], "-") .replace(['✓', '✔'], "v") .replace(['✗', '✘'], "x") .replace('λ', "lambda")
.replace('α', "alpha")
.replace('β', "beta")
.replace('γ', "gamma")
.replace('δ', "delta")
.replace('ε', "epsilon")
.replace('θ', "theta")
.replace('μ', "mu")
.replace('π', "pi")
.replace('σ', "sigma")
.replace('Σ', "Sigma")
.replace('Δ', "Delta")
.replace('Π', "Pi")
.replace(
[
'\u{00A0}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}', '\u{2005}',
'\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', '\u{202F}', '\u{205F}',
'\u{3000}',
],
" ",
) .replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") }
const fn estimate_tokens(text: &str) -> usize {
text.len().div_ceil(4) }
pub fn cap_details(details: &mut Vec<String>, max_tokens: usize) {
if details.is_empty() {
return;
}
let total_tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
if total_tokens <= max_tokens {
return; }
let mut scored: Vec<(usize, i32, usize, &String)> = details
.iter()
.enumerate()
.map(|(idx, detail)| {
let lower = detail.to_lowercase();
let mut score = 0;
if lower.contains("security")
|| lower.contains("vulnerability")
|| lower.contains("exploit")
|| lower.contains("critical")
|| (lower.contains("fix") && lower.contains("crash"))
{
score += 100;
}
if lower.contains("breaking") || lower.contains("incompatible") {
score += 90;
}
if lower.contains("performance")
|| lower.contains("faster")
|| lower.contains("optimization")
{
score += 80;
}
if lower.contains("fix") || lower.contains("bug") {
score += 70;
}
if lower.contains("api") || lower.contains("interface") || lower.contains("public") {
score += 50;
}
if lower.contains("user") || lower.contains("client") {
score += 40;
}
if lower.contains("deprecated") || lower.contains("removed") {
score += 35;
}
score += (detail.len() / 20).min(10) as i32;
let tokens = estimate_tokens(detail);
(idx, score, tokens, detail)
})
.collect();
scored.sort_by_key(|item| std::cmp::Reverse(item.1));
let mut budget_remaining = max_tokens;
let mut keep_indices: Vec<usize> = Vec::new();
for (idx, _score, tokens, _detail) in scored {
if tokens <= budget_remaining {
keep_indices.push(idx);
budget_remaining -= tokens;
}
}
keep_indices.sort_unstable();
let kept: Vec<String> = keep_indices
.iter()
.filter_map(|&idx| details.get(idx).cloned())
.collect();
*details = kept;
}
pub fn normalize_summary_verb(summary: &mut String, commit_type: &str) {
use crate::validation::{present_to_past, split_verb_token, verb_stem};
if summary.trim().is_empty() {
return;
}
let mut parts_iter = summary.split_whitespace();
let first_word = match parts_iter.next() {
Some(word) => word.to_string(),
None => return,
};
let rest = parts_iter.collect::<Vec<_>>().join(" ");
let first_word_lower = first_word.to_lowercase();
if is_past_tense_verb(&first_word_lower) {
if commit_type == "refactor" && first_word_lower == "refactored" {
*summary = if rest.is_empty() {
"restructured".to_string()
} else {
format!("restructured {rest}")
};
}
return;
}
let Some((stem_raw, suffix)) = split_verb_token(&first_word) else {
return;
};
let stem = stem_raw.to_ascii_lowercase();
if verb_stem(&first_word).is_none() {
return;
}
let safe_suffix = if suffix.is_empty() || suffix.starts_with('-') || suffix.starts_with('/') {
suffix
} else {
return;
};
if stem == "re" && safe_suffix.starts_with('-') {
let after_dash = &safe_suffix[1..]; let next_n = after_dash
.bytes()
.take_while(|&b| b.is_ascii_alphabetic())
.count();
if next_n > 0 {
let inner = after_dash[..next_n].to_ascii_lowercase();
let tail = &after_dash[next_n..];
let inner_past = present_to_past(&inner)
.or_else(|| inner.strip_suffix('s').and_then(|s| present_to_past(s)))
.or_else(|| inner.strip_suffix("es").and_then(|s| present_to_past(s)))
.or_else(|| {
inner
.strip_suffix("ies")
.and_then(|s| present_to_past(&format!("{s}y")))
})
.map(|p| {
if commit_type == "refactor" && p == "refactored" {
"restructured"
} else {
p
}
});
if let Some(past) = inner_past {
*summary = if rest.is_empty() {
format!("re-{past}{tail}")
} else {
format!("re-{past}{tail} {rest}")
};
}
}
return;
}
let past = present_to_past(&stem)
.or_else(|| {
stem.strip_suffix('s').and_then(|s| present_to_past(s))
})
.or_else(|| {
stem.strip_suffix("es").and_then(|s| present_to_past(s))
})
.or_else(|| {
stem
.strip_suffix("ies")
.and_then(|s| present_to_past(&format!("{s}y")))
})
.map(|p| {
if commit_type == "refactor" && p == "refactored" {
"restructured"
} else {
p
}
});
if let Some(past) = past {
*summary = if rest.is_empty() {
format!("{past}{safe_suffix}")
} else {
format!("{past}{safe_suffix} {rest}")
};
}
}
pub fn post_process_commit_message(msg: &mut ConventionalCommit, config: &CommitConfig) {
let mut summary_str = normalize_unicode(msg.summary.as_str());
msg.body = msg.body.iter().map(|s| normalize_unicode(s)).collect();
msg.footers = msg.footers.iter().map(|s| normalize_unicode(s)).collect();
summary_str = summary_str
.replace(['\r', '\n'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.trim_end_matches('.')
.trim_end_matches(';')
.trim_end_matches(':')
.to_string();
let is_first_token_all_caps = |s: &str| -> bool {
s.split_whitespace().next().is_some_and(|token| {
token
.chars()
.all(|c| !c.is_alphabetic() || c.is_uppercase())
})
};
if !is_first_token_all_caps(&summary_str)
&& let Some(first_char) = summary_str.chars().next()
&& first_char.is_uppercase()
{
let rest = &summary_str[first_char.len_utf8()..];
summary_str = format!("{}{}", first_char.to_lowercase(), rest);
}
normalize_summary_verb(&mut summary_str, msg.commit_type.as_str());
summary_str = summary_str.trim().to_string();
if !is_first_token_all_caps(&summary_str)
&& let Some(first_char) = summary_str.chars().next()
&& first_char.is_uppercase()
{
let rest = &summary_str[first_char.len_utf8()..];
summary_str = format!("{}{}", first_char.to_lowercase(), rest);
}
summary_str = summary_str.trim_end_matches('.').to_string();
msg.summary = crate::types::CommitSummary::new_unchecked(summary_str, 128)
.expect("post-processed summary should be valid");
for item in &mut msg.body {
let mut cleaned = item
.replace(['\r', '\n'], " ")
.trim()
.trim_start_matches('\u{2022}')
.trim_start_matches('-')
.trim_start_matches('*')
.trim_start_matches('+')
.trim()
.to_string();
cleaned = cleaned
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.trim_end_matches('.')
.trim_end_matches(';')
.trim_end_matches(',')
.to_string();
if cleaned.is_empty() {
*item = cleaned;
continue;
}
if let Some(first_char) = cleaned.chars().next()
&& first_char.is_lowercase()
{
let rest = &cleaned[first_char.len_utf8()..];
cleaned = format!("{}{}", first_char.to_uppercase(), rest);
}
if !cleaned.ends_with('.') {
cleaned.push('.');
}
*item = cleaned;
}
msg.body.retain(|item| !item.trim().is_empty());
cap_details(&mut msg.body, config.max_detail_tokens);
}
pub fn format_commit_message(msg: &ConventionalCommit) -> String {
let scope_part = msg
.scope
.as_ref()
.map(|s| format!("({s})"))
.unwrap_or_default();
let first_line = format!("{}{}: {}", msg.commit_type, scope_part, msg.summary);
let body_formatted = if msg.body.is_empty() {
String::new()
} else {
msg.body
.iter()
.map(|item| format!("- {item}"))
.collect::<Vec<_>>()
.join("\n")
};
let footers_formatted = if msg.footers.is_empty() {
String::new()
} else {
msg.footers.join("\n")
};
let mut result = first_line;
if !body_formatted.is_empty() {
result.push_str("\n\n");
result.push_str(&body_formatted);
}
if !footers_formatted.is_empty() {
result.push_str("\n\n");
result.push_str(&footers_formatted);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CommitSummary, CommitType, ConventionalCommit, Scope};
#[test]
fn test_normalize_unicode_smart_quotes() {
assert_eq!(normalize_unicode("\u{2018}smart quotes\u{2019}"), "'smart quotes'");
assert_eq!(normalize_unicode("\u{201C}double quotes\u{201D}"), "\"double quotes\"");
assert_eq!(normalize_unicode("\u{201A}low quote\u{2019}"), "'low quote'");
assert_eq!(normalize_unicode("\u{201E}low double\u{201D}"), "\"low double\"");
}
#[test]
fn test_normalize_unicode_dashes() {
assert_eq!(normalize_unicode("en\u{2013}dash"), "en--dash");
assert_eq!(normalize_unicode("em\u{2014}dash"), "em--dash");
assert_eq!(normalize_unicode("fig\u{2012}dash"), "fig-dash");
assert_eq!(normalize_unicode("minus\u{2212}sign"), "minus-sign");
}
#[test]
fn test_normalize_unicode_arrows() {
assert_eq!(normalize_unicode("arrow\u{2192}right"), "arrow->right");
assert_eq!(normalize_unicode("arrow\u{2190}left"), "arrow<-left");
assert_eq!(normalize_unicode("arrow\u{2194}both"), "arrow<->both");
assert_eq!(normalize_unicode("double\u{21D2}arrow"), "double=>arrow");
assert_eq!(normalize_unicode("up\u{2191}arrow"), "up^arrow");
}
#[test]
fn test_normalize_unicode_math() {
assert_eq!(normalize_unicode("a\u{00D7}b"), "axb");
assert_eq!(normalize_unicode("a\u{00F7}b"), "a/b");
assert_eq!(normalize_unicode("x\u{2264}y"), "x<=y");
assert_eq!(normalize_unicode("x\u{2265}y"), "x>=y");
assert_eq!(normalize_unicode("x\u{2260}y"), "x!=y");
assert_eq!(normalize_unicode("x\u{2248}y"), "x~=y");
}
#[test]
fn test_normalize_unicode_greek() {
assert_eq!(normalize_unicode("\u{03BB} function"), "lambda function");
assert_eq!(normalize_unicode("\u{03B1} beta \u{03B3}"), "alpha beta gamma");
assert_eq!(normalize_unicode("\u{03BC} service"), "mu service");
assert_eq!(normalize_unicode("\u{03A3} total"), "Sigma total");
}
#[test]
fn test_normalize_unicode_fractions() {
assert_eq!(normalize_unicode("\u{00BD} cup"), "1/2 cup");
assert_eq!(normalize_unicode("\u{00BE} done"), "3/4 done");
assert_eq!(normalize_unicode("\u{2153} left"), "1/3 left");
}
#[test]
fn test_normalize_unicode_superscripts() {
assert_eq!(normalize_unicode("x\u{00B2}"), "x^2");
assert_eq!(normalize_unicode("10\u{00B3}"), "10^3");
}
#[test]
fn test_normalize_unicode_multiple_replacements() {
let input =
"\u{2018}smart\u{2019}\u{2192}straight \u{201C}quotes\u{201D}\u{00D7}math\u{2264}ops";
let expected = "'smart'->straight \"quotes\"xmath<=ops";
assert_eq!(normalize_unicode(input), expected);
}
#[test]
fn test_normalize_unicode_ellipsis() {
assert_eq!(normalize_unicode("wait\u{2026}"), "wait...");
assert_eq!(normalize_unicode("more\u{22EF}dots"), "more...dots");
}
#[test]
fn test_normalize_unicode_bullets() {
assert_eq!(normalize_unicode("\u{2022}item"), "-item");
assert_eq!(normalize_unicode("\u{25E6}item"), "-item");
}
#[test]
fn test_normalize_unicode_check_marks() {
assert_eq!(normalize_unicode("\u{2713}done"), "vdone");
assert_eq!(normalize_unicode("\u{2717}failed"), "xfailed");
}
#[test]
fn test_normalize_summary_verb_present_to_past() {
let mut s = "add new feature".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "added new feature");
let mut s = "fix bug".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fixed bug");
let mut s = "update docs".to_string();
normalize_summary_verb(&mut s, "docs");
assert_eq!(s, "updated docs");
}
#[test]
fn test_normalize_summary_verb_already_past() {
let mut s = "added feature".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "added feature");
let mut s = "fixed bug".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fixed bug");
}
#[test]
fn test_normalize_summary_verb_third_person() {
let mut s = "adds feature".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "added feature");
let mut s = "fixes bug".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fixed bug");
}
#[test]
fn test_normalize_summary_verb_non_verb_start() {
let mut s = "123 files changed".to_string();
normalize_summary_verb(&mut s, "chore");
assert_eq!(s, "123 files changed");
}
#[test]
fn test_normalize_summary_verb_refactor_special_case() {
let mut s = "refactored code".to_string();
normalize_summary_verb(&mut s, "refactor");
assert_eq!(s, "restructured code");
}
#[test]
fn test_normalize_summary_verb_refactor_present() {
let mut s = "refactor code".to_string();
normalize_summary_verb(&mut s, "refactor");
assert_eq!(s, "restructured code");
let mut s = "refactor logic".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "refactored logic");
}
#[test]
fn test_normalize_summary_verb_empty() {
let mut s = String::new();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "");
}
#[test]
fn test_normalize_summary_verb_single_word() {
let mut s = "add".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "added");
}
#[test]
fn test_normalize_summary_verb_harden_to_hardened() {
let mut s = "harden stealth scripts against detection".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "hardened stealth scripts against detection");
}
#[test]
fn test_normalize_summary_verb_bind_to_bound() {
let mut s = "bind native methods to local constants".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "bound native methods to local constants");
}
#[test]
fn test_normalize_summary_verb_third_person_ies() {
let mut s = "simplifies the config loading".to_string();
normalize_summary_verb(&mut s, "refactor");
assert_eq!(s, "simplified the config loading");
}
#[test]
fn test_normalize_summary_verb_third_person_es() {
let mut s = "fixes race condition".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fixed race condition");
}
#[test]
fn test_normalize_summary_verb_suffix_reattach_dash() {
let mut s = "isolate-subagent from main flow".to_string();
normalize_summary_verb(&mut s, "refactor");
assert_eq!(s, "isolated-subagent from main flow");
}
#[test]
fn test_normalize_summary_verb_skip_type_prefix_leak() {
let mut s = "fix(tui): rendering bug".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fix(tui): rendering bug");
}
#[test]
fn test_normalize_summary_verb_skip_acronym() {
let mut s = "API response handling".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "API response handling");
}
#[test]
fn test_normalize_summary_verb_skip_numeric() {
let mut s = "403 error handling".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "403 error handling");
}
#[test]
fn test_normalize_summary_verb_already_past_hardened() {
let mut s = "hardened stealth scripts".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "hardened stealth scripts");
}
#[test]
fn test_normalize_summary_verb_already_past_bound() {
let mut s = "bound native methods".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "bound native methods");
}
#[test]
fn test_normalize_summary_verb_preserves_existing_third_person() {
let mut s = "adds feature".to_string();
normalize_summary_verb(&mut s, "feat");
assert_eq!(s, "added feature");
let mut s = "fixes bug".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "fixed bug");
let mut s = "updates docs".to_string();
normalize_summary_verb(&mut s, "docs");
assert_eq!(s, "updated docs");
}
#[test]
fn test_normalize_summary_verb_re_prefix_enable() {
let mut s = "re-enable formatting checks".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "re-enabled formatting checks");
}
#[test]
fn test_normalize_summary_verb_re_prefix_run() {
let mut s = "re-run the test suite".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "re-ran the test suite");
}
#[test]
fn test_normalize_summary_verb_re_prefix_with_tail() {
let mut s = "re-format-checking pipeline".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "re-formatted-checking pipeline");
}
#[test]
fn test_normalize_summary_verb_re_prefix_already_past() {
let mut s = "re-enabled linting".to_string();
normalize_summary_verb(&mut s, "fix");
assert_eq!(s, "re-enabled linting");
}
#[test]
fn test_cap_details_under_budget() {
let mut details = vec!["first".to_string(), "second".to_string(), "third".to_string()];
let tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
cap_details(&mut details, tokens + 100);
assert_eq!(details.len(), 3);
}
#[test]
fn test_cap_details_at_budget() {
let mut details = vec![
"one".to_string(),
"two".to_string(),
"three".to_string(),
"four".to_string(),
"five".to_string(),
"six".to_string(),
];
let tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
cap_details(&mut details, tokens);
assert_eq!(details.len(), 6);
}
#[test]
fn test_cap_details_security_priority() {
let mut details = vec![
"normal change".to_string(),
"security vulnerability fixed".to_string(),
"another change".to_string(),
"third change".to_string(),
"fourth change".to_string(),
"fifth change".to_string(),
"sixth change".to_string(),
];
cap_details(&mut details, 60);
assert!(details.iter().any(|d| d.contains("security")));
}
#[test]
fn test_cap_details_performance_priority() {
let mut details = vec![
"normal change".to_string(),
"performance optimization added".to_string(),
"another change".to_string(),
"third change".to_string(),
"fourth change".to_string(),
"fifth change".to_string(),
];
cap_details(&mut details, 40);
assert!(details.iter().any(|d| d.contains("performance")));
}
#[test]
fn test_cap_details_api_priority() {
let mut details = vec![
"normal change".to_string(),
"API interface updated".to_string(),
"internal change".to_string(),
"another internal change".to_string(),
"yet another change".to_string(),
];
cap_details(&mut details, 50);
assert!(details.iter().any(|d| d.contains("API")));
}
#[test]
fn test_cap_details_preserves_order() {
let mut details = vec![
"first".to_string(),
"critical security fix".to_string(),
"third".to_string(),
"performance improvement".to_string(),
"fifth".to_string(),
];
cap_details(&mut details, 50);
let security_idx = details.iter().position(|d| d.contains("security"));
let perf_idx = details.iter().position(|d| d.contains("performance"));
assert!(security_idx.unwrap() < perf_idx.unwrap());
}
#[test]
fn test_cap_details_empty_list() {
let mut details: Vec<String> = vec![];
cap_details(&mut details, 100);
assert_eq!(details.len(), 0);
}
#[test]
fn test_cap_details_breaking_priority() {
let mut details = vec![
"normal change".to_string(),
"breaking change introduced".to_string(),
"another change".to_string(),
"third change".to_string(),
"fourth change".to_string(),
];
cap_details(&mut details, 50);
assert!(details.iter().any(|d| d.contains("breaking")));
}
#[test]
fn test_cap_details_budget_prefers_short_high_priority() {
let mut details = vec![
"security fix".to_string(), "bug fix".to_string(), "API change".to_string(), "performance gain".to_string(), "breaking change".to_string(), "user feature".to_string(), "This is a very long internal refactoring detail that adds no user value".to_string(),
"Another extremely long low priority change description here".to_string(),
];
cap_details(&mut details, 30);
assert!(details.iter().any(|d| d.contains("security")));
assert!(details.iter().any(|d| d.contains("breaking")));
assert!(!details.iter().any(|d| d.contains("very long internal")));
}
#[test]
fn test_cap_details_budget_allows_variable_count() {
let short_details = vec![
"fix A".to_string(),
"fix B".to_string(),
"fix C".to_string(),
"fix D".to_string(),
"fix E".to_string(),
"fix F".to_string(),
];
let long_details = vec![
"Fixed a critical security vulnerability in authentication".to_string(),
"Implemented comprehensive performance optimization".to_string(),
"Added extensive API documentation and examples".to_string(),
];
let mut short = short_details;
let mut long = long_details;
cap_details(&mut short, 50); cap_details(&mut long, 50);
assert!(short.len() >= 5); assert!(long.len() <= 3); }
#[test]
fn test_format_commit_message_type_summary_only() {
let commit = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: None,
summary: CommitSummary::new_unchecked("added new feature", 128).unwrap(),
body: vec![],
footers: vec![],
};
assert_eq!(format_commit_message(&commit), "feat: added new feature");
}
#[test]
fn test_format_commit_message_with_scope() {
let commit = ConventionalCommit {
commit_type: CommitType::new("fix").unwrap(),
scope: Some(Scope::new("api").unwrap()),
summary: CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
body: vec![],
footers: vec![],
};
assert_eq!(format_commit_message(&commit), "fix(api): fixed bug");
}
#[test]
fn test_format_commit_message_with_body() {
let commit = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: None,
summary: CommitSummary::new_unchecked("added feature", 128).unwrap(),
body: vec!["First detail.".to_string(), "Second detail.".to_string()],
footers: vec![],
};
let expected = "feat: added feature\n\n- First detail.\n- Second detail.";
assert_eq!(format_commit_message(&commit), expected);
}
#[test]
fn test_format_commit_message_with_footers() {
let commit = ConventionalCommit {
commit_type: CommitType::new("fix").unwrap(),
scope: None,
summary: CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
body: vec![],
footers: vec!["Closes: #123".to_string(), "Fixes: #456".to_string()],
};
let expected = "fix: fixed bug\n\nCloses: #123\nFixes: #456";
assert_eq!(format_commit_message(&commit), expected);
}
#[test]
fn test_format_commit_message_full() {
let commit = ConventionalCommit {
commit_type: CommitType::new("feat").unwrap(),
scope: Some(Scope::new("auth").unwrap()),
summary: CommitSummary::new_unchecked("added oauth support", 128).unwrap(),
body: vec![
"Implemented OAuth2 flow.".to_string(),
"Added token refresh.".to_string(),
],
footers: vec!["Closes: #789".to_string()],
};
let expected = "feat(auth): added oauth support\n\n- Implemented OAuth2 flow.\n- Added \
token refresh.\n\nCloses: #789";
assert_eq!(format_commit_message(&commit), expected);
}
#[test]
fn test_format_commit_message_nested_scope() {
let commit = ConventionalCommit {
commit_type: CommitType::new("refactor").unwrap(),
scope: Some(Scope::new("api/client").unwrap()),
summary: CommitSummary::new_unchecked("restructured code", 128).unwrap(),
body: vec![],
footers: vec![],
};
assert_eq!(format_commit_message(&commit), "refactor(api/client): restructured code");
}
}