1use unicode_normalization::UnicodeNormalization;
3
4use crate::{config::CommitConfig, types::ConventionalCommit, validation::is_past_tense_verb};
5
6pub fn normalize_unicode(text: &str) -> String {
9 let pre_normalized = text
12 .replace('≠', "!=") .replace('½', "1/2")
16 .replace('¼', "1/4")
17 .replace('¾', "3/4")
18 .replace('⅓', "1/3")
19 .replace('⅔', "2/3")
20 .replace('⅕', "1/5")
21 .replace('⅖', "2/5")
22 .replace('⅗', "3/5")
23 .replace('⅘', "4/5")
24 .replace('⅙', "1/6")
25 .replace('⅚', "5/6")
26 .replace('⅛', "1/8")
27 .replace('⅜', "3/8")
28 .replace('⅝', "5/8")
29 .replace('⅞', "7/8")
30 .replace('⁰', "^0")
32 .replace('¹', "^1")
33 .replace('²', "^2")
34 .replace('³', "^3")
35 .replace('⁴', "^4")
36 .replace('⁵', "^5")
37 .replace('⁶', "^6")
38 .replace('⁷', "^7")
39 .replace('⁸', "^8")
40 .replace('⁹', "^9")
41 .replace('₀', "_0")
43 .replace('₁', "_1")
44 .replace('₂', "_2")
45 .replace('₃', "_3")
46 .replace('₄', "_4")
47 .replace('₅', "_5")
48 .replace('₆', "_6")
49 .replace('₇', "_7")
50 .replace('₈', "_8")
51 .replace('₉', "_9");
52
53 let normalized: String = pre_normalized.nfkd().collect();
55
56 normalized
57 .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")
92 .replace('α', "alpha")
93 .replace('β', "beta")
94 .replace('γ', "gamma")
95 .replace('δ', "delta")
96 .replace('ε', "epsilon")
97 .replace('θ', "theta")
98 .replace('μ', "mu")
99 .replace('π', "pi")
100 .replace('σ', "sigma")
101 .replace('Σ', "Sigma")
102 .replace('Δ', "Delta")
103 .replace('Π', "Pi")
104 .replace(
106 [
107 '\u{00A0}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}', '\u{2005}',
108 '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', '\u{202F}', '\u{205F}',
109 '\u{3000}',
110 ],
111 " ",
112 ) .replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") }
116
117const fn estimate_tokens(text: &str) -> usize {
119 text.len().div_ceil(4) }
121
122pub fn cap_details(details: &mut Vec<String>, max_tokens: usize) {
125 if details.is_empty() {
126 return;
127 }
128
129 let total_tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
131
132 if total_tokens <= max_tokens {
133 return; }
135
136 let mut scored: Vec<(usize, i32, usize, &String)> = details
138 .iter()
139 .enumerate()
140 .map(|(idx, detail)| {
141 let lower = detail.to_lowercase();
142 let mut score = 0;
143
144 if lower.contains("security")
146 || lower.contains("vulnerability")
147 || lower.contains("exploit")
148 || lower.contains("critical")
149 || (lower.contains("fix") && lower.contains("crash"))
150 {
151 score += 100;
152 }
153 if lower.contains("breaking") || lower.contains("incompatible") {
154 score += 90;
155 }
156 if lower.contains("performance")
157 || lower.contains("faster")
158 || lower.contains("optimization")
159 {
160 score += 80;
161 }
162 if lower.contains("fix") || lower.contains("bug") {
163 score += 70;
164 }
165
166 if lower.contains("api") || lower.contains("interface") || lower.contains("public") {
168 score += 50;
169 }
170 if lower.contains("user") || lower.contains("client") {
171 score += 40;
172 }
173 if lower.contains("deprecated") || lower.contains("removed") {
174 score += 35;
175 }
176
177 score += (detail.len() / 20).min(10) as i32;
179
180 let tokens = estimate_tokens(detail);
181 (idx, score, tokens, detail)
182 })
183 .collect();
184
185 scored.sort_by(|a, b| b.1.cmp(&a.1));
187
188 let mut budget_remaining = max_tokens;
190 let mut keep_indices: Vec<usize> = Vec::new();
191
192 for (idx, _score, tokens, _detail) in scored {
193 if tokens <= budget_remaining {
194 keep_indices.push(idx);
195 budget_remaining -= tokens;
196 }
197 }
198
199 keep_indices.sort_unstable(); let kept: Vec<String> = keep_indices
203 .iter()
204 .filter_map(|&idx| details.get(idx).cloned())
205 .collect();
206 *details = kept;
207}
208
209pub fn normalize_summary_verb(summary: &mut String, commit_type: &str) {
212 if summary.trim().is_empty() {
213 return;
214 }
215
216 let mut parts_iter = summary.split_whitespace();
217 let first_word = match parts_iter.next() {
218 Some(word) => word.to_string(),
219 None => return,
220 };
221 let rest = parts_iter.collect::<Vec<_>>().join(" ");
222 let first_word_lower = first_word.to_lowercase();
223
224 if is_past_tense_verb(&first_word_lower) {
226 if commit_type == "refactor" && first_word_lower == "refactored" {
228 *summary = if rest.is_empty() {
229 "restructured".to_string()
230 } else {
231 format!("restructured {rest}")
232 };
233 }
234 return;
235 }
236
237 let converted = match first_word_lower.as_str() {
239 "add" | "adds" => Some("added"),
240 "fix" | "fixes" => Some("fixed"),
241 "update" | "updates" => Some("updated"),
242 "refactor" | "refactors" => Some(if commit_type == "refactor" {
243 "restructured"
244 } else {
245 "refactored"
246 }),
247 "remove" | "removes" => Some("removed"),
248 "replace" | "replaces" => Some("replaced"),
249 "improve" | "improves" => Some("improved"),
250 "implement" | "implements" => Some("implemented"),
251 "migrate" | "migrates" => Some("migrated"),
252 "rename" | "renames" => Some("renamed"),
253 "move" | "moves" => Some("moved"),
254 "merge" | "merges" => Some("merged"),
255 "split" | "splits" => Some("split"),
256 "extract" | "extracts" => Some("extracted"),
257 "restructure" | "restructures" => Some("restructured"),
258 "reorganize" | "reorganizes" => Some("reorganized"),
259 "consolidate" | "consolidates" => Some("consolidated"),
260 "simplify" | "simplifies" => Some("simplified"),
261 "optimize" | "optimizes" => Some("optimized"),
262 "document" | "documents" => Some("documented"),
263 "test" | "tests" => Some("tested"),
264 "change" | "changes" => Some("changed"),
265 "introduce" | "introduces" => Some("introduced"),
266 "deprecate" | "deprecates" => Some("deprecated"),
267 "delete" | "deletes" => Some("deleted"),
268 "correct" | "corrects" => Some("corrected"),
269 "enhance" | "enhances" => Some("enhanced"),
270 "revert" | "reverts" => Some("reverted"),
271 _ => None,
272 };
273
274 if let Some(past) = converted {
275 *summary = if rest.is_empty() {
276 past.to_string()
277 } else {
278 format!("{past} {rest}")
279 };
280 }
281}
282
283pub fn post_process_commit_message(msg: &mut ConventionalCommit, config: &CommitConfig) {
285 let mut summary_str = normalize_unicode(msg.summary.as_str());
290
291 msg.body = msg.body.iter().map(|s| normalize_unicode(s)).collect();
293 msg.footers = msg.footers.iter().map(|s| normalize_unicode(s)).collect();
294
295 summary_str = summary_str
297 .replace(['\r', '\n'], " ")
298 .split_whitespace()
299 .collect::<Vec<_>>()
300 .join(" ")
301 .trim()
302 .trim_end_matches('.')
303 .trim_end_matches(';')
304 .trim_end_matches(':')
305 .to_string();
306
307 let is_first_token_all_caps = |s: &str| -> bool {
309 s.split_whitespace().next().is_some_and(|token| {
310 token
311 .chars()
312 .all(|c| !c.is_alphabetic() || c.is_uppercase())
313 })
314 };
315
316 if !is_first_token_all_caps(&summary_str)
318 && let Some(first_char) = summary_str.chars().next()
319 && first_char.is_uppercase()
320 {
321 let rest = &summary_str[first_char.len_utf8()..];
322 summary_str = format!("{}{}", first_char.to_lowercase(), rest);
323 }
324
325 normalize_summary_verb(&mut summary_str, msg.commit_type.as_str());
328 summary_str = summary_str.trim().to_string();
329
330 if !is_first_token_all_caps(&summary_str)
332 && let Some(first_char) = summary_str.chars().next()
333 && first_char.is_uppercase()
334 {
335 let rest = &summary_str[first_char.len_utf8()..];
336 summary_str = format!("{}{}", first_char.to_lowercase(), rest);
337 }
338
339 summary_str = summary_str.trim_end_matches('.').to_string();
342
343 msg.summary = crate::types::CommitSummary::new_unchecked(summary_str, 128)
346 .expect("post-processed summary should be valid");
347
348 for item in &mut msg.body {
350 let mut cleaned = item
351 .replace(['\r', '\n'], " ")
352 .trim()
353 .trim_start_matches('\u{2022}')
354 .trim_start_matches('-')
355 .trim_start_matches('*')
356 .trim_start_matches('+')
357 .trim()
358 .to_string();
359
360 cleaned = cleaned
361 .split_whitespace()
362 .collect::<Vec<_>>()
363 .join(" ")
364 .trim()
365 .trim_end_matches('.')
366 .trim_end_matches(';')
367 .trim_end_matches(',')
368 .to_string();
369
370 if cleaned.is_empty() {
371 *item = cleaned;
372 continue;
373 }
374
375 if let Some(first_char) = cleaned.chars().next()
377 && first_char.is_lowercase()
378 {
379 let rest = &cleaned[first_char.len_utf8()..];
380 cleaned = format!("{}{}", first_char.to_uppercase(), rest);
381 }
382
383 if !cleaned.ends_with('.') {
384 cleaned.push('.');
385 }
386
387 *item = cleaned;
388 }
389
390 msg.body.retain(|item| !item.trim().is_empty());
392
393 cap_details(&mut msg.body, config.max_detail_tokens);
395}
396
397pub fn format_commit_message(msg: &ConventionalCommit) -> String {
399 let scope_part = msg
401 .scope
402 .as_ref()
403 .map(|s| format!("({s})"))
404 .unwrap_or_default();
405 let first_line = format!("{}{}: {}", msg.commit_type, scope_part, msg.summary);
406
407 let body_formatted = if msg.body.is_empty() {
409 String::new()
410 } else {
411 msg.body
412 .iter()
413 .map(|item| format!("- {item}"))
414 .collect::<Vec<_>>()
415 .join("\n")
416 };
417
418 let footers_formatted = if msg.footers.is_empty() {
420 String::new()
421 } else {
422 msg.footers.join("\n")
423 };
424
425 let mut result = first_line;
427 if !body_formatted.is_empty() {
428 result.push_str("\n\n");
429 result.push_str(&body_formatted);
430 }
431 if !footers_formatted.is_empty() {
432 result.push_str("\n\n");
433 result.push_str(&footers_formatted);
434 }
435 result
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::types::{CommitSummary, CommitType, ConventionalCommit, Scope};
442
443 #[test]
445 fn test_normalize_unicode_smart_quotes() {
446 assert_eq!(normalize_unicode("\u{2018}smart quotes\u{2019}"), "'smart quotes'");
447 assert_eq!(normalize_unicode("\u{201C}double quotes\u{201D}"), "\"double quotes\"");
448 assert_eq!(normalize_unicode("\u{201A}low quote\u{2019}"), "'low quote'");
449 assert_eq!(normalize_unicode("\u{201E}low double\u{201D}"), "\"low double\"");
450 }
451
452 #[test]
453 fn test_normalize_unicode_dashes() {
454 assert_eq!(normalize_unicode("en\u{2013}dash"), "en--dash");
455 assert_eq!(normalize_unicode("em\u{2014}dash"), "em--dash");
456 assert_eq!(normalize_unicode("fig\u{2012}dash"), "fig-dash");
457 assert_eq!(normalize_unicode("minus\u{2212}sign"), "minus-sign");
458 }
459
460 #[test]
461 fn test_normalize_unicode_arrows() {
462 assert_eq!(normalize_unicode("arrow\u{2192}right"), "arrow->right");
463 assert_eq!(normalize_unicode("arrow\u{2190}left"), "arrow<-left");
464 assert_eq!(normalize_unicode("arrow\u{2194}both"), "arrow<->both");
465 assert_eq!(normalize_unicode("double\u{21D2}arrow"), "double=>arrow");
466 assert_eq!(normalize_unicode("up\u{2191}arrow"), "up^arrow");
467 }
468
469 #[test]
470 fn test_normalize_unicode_math() {
471 assert_eq!(normalize_unicode("a\u{00D7}b"), "axb");
472 assert_eq!(normalize_unicode("a\u{00F7}b"), "a/b");
473 assert_eq!(normalize_unicode("x\u{2264}y"), "x<=y");
474 assert_eq!(normalize_unicode("x\u{2265}y"), "x>=y");
475 assert_eq!(normalize_unicode("x\u{2260}y"), "x!=y");
476 assert_eq!(normalize_unicode("x\u{2248}y"), "x~=y");
477 }
478
479 #[test]
480 fn test_normalize_unicode_greek() {
481 assert_eq!(normalize_unicode("\u{03BB} function"), "lambda function");
482 assert_eq!(normalize_unicode("\u{03B1} beta \u{03B3}"), "alpha beta gamma");
483 assert_eq!(normalize_unicode("\u{03BC} service"), "mu service");
484 assert_eq!(normalize_unicode("\u{03A3} total"), "Sigma total");
485 }
486
487 #[test]
488 fn test_normalize_unicode_fractions() {
489 assert_eq!(normalize_unicode("\u{00BD} cup"), "1/2 cup");
490 assert_eq!(normalize_unicode("\u{00BE} done"), "3/4 done");
491 assert_eq!(normalize_unicode("\u{2153} left"), "1/3 left");
492 }
493
494 #[test]
495 fn test_normalize_unicode_superscripts() {
496 assert_eq!(normalize_unicode("x\u{00B2}"), "x^2");
497 assert_eq!(normalize_unicode("10\u{00B3}"), "10^3");
498 }
499
500 #[test]
501 fn test_normalize_unicode_multiple_replacements() {
502 let input =
503 "\u{2018}smart\u{2019}\u{2192}straight \u{201C}quotes\u{201D}\u{00D7}math\u{2264}ops";
504 let expected = "'smart'->straight \"quotes\"xmath<=ops";
505 assert_eq!(normalize_unicode(input), expected);
506 }
507
508 #[test]
509 fn test_normalize_unicode_ellipsis() {
510 assert_eq!(normalize_unicode("wait\u{2026}"), "wait...");
511 assert_eq!(normalize_unicode("more\u{22EF}dots"), "more...dots");
512 }
513
514 #[test]
515 fn test_normalize_unicode_bullets() {
516 assert_eq!(normalize_unicode("\u{2022}item"), "-item");
517 assert_eq!(normalize_unicode("\u{25E6}item"), "-item");
518 }
519
520 #[test]
521 fn test_normalize_unicode_check_marks() {
522 assert_eq!(normalize_unicode("\u{2713}done"), "vdone");
523 assert_eq!(normalize_unicode("\u{2717}failed"), "xfailed");
524 }
525
526 #[test]
528 fn test_normalize_summary_verb_present_to_past() {
529 let mut s = "add new feature".to_string();
530 normalize_summary_verb(&mut s, "feat");
531 assert_eq!(s, "added new feature");
532
533 let mut s = "fix bug".to_string();
534 normalize_summary_verb(&mut s, "fix");
535 assert_eq!(s, "fixed bug");
536
537 let mut s = "update docs".to_string();
538 normalize_summary_verb(&mut s, "docs");
539 assert_eq!(s, "updated docs");
540 }
541
542 #[test]
543 fn test_normalize_summary_verb_already_past() {
544 let mut s = "added feature".to_string();
545 normalize_summary_verb(&mut s, "feat");
546 assert_eq!(s, "added feature");
547
548 let mut s = "fixed bug".to_string();
549 normalize_summary_verb(&mut s, "fix");
550 assert_eq!(s, "fixed bug");
551 }
552
553 #[test]
554 fn test_normalize_summary_verb_third_person() {
555 let mut s = "adds feature".to_string();
556 normalize_summary_verb(&mut s, "feat");
557 assert_eq!(s, "added feature");
558
559 let mut s = "fixes bug".to_string();
560 normalize_summary_verb(&mut s, "fix");
561 assert_eq!(s, "fixed bug");
562 }
563
564 #[test]
565 fn test_normalize_summary_verb_non_verb_start() {
566 let mut s = "123 files changed".to_string();
567 normalize_summary_verb(&mut s, "chore");
568 assert_eq!(s, "123 files changed");
569 }
570
571 #[test]
572 fn test_normalize_summary_verb_refactor_special_case() {
573 let mut s = "refactored code".to_string();
574 normalize_summary_verb(&mut s, "refactor");
575 assert_eq!(s, "restructured code");
576 }
577
578 #[test]
579 fn test_normalize_summary_verb_refactor_present() {
580 let mut s = "refactor code".to_string();
581 normalize_summary_verb(&mut s, "refactor");
582 assert_eq!(s, "restructured code");
583
584 let mut s = "refactor logic".to_string();
585 normalize_summary_verb(&mut s, "feat");
586 assert_eq!(s, "refactored logic");
587 }
588
589 #[test]
590 fn test_normalize_summary_verb_empty() {
591 let mut s = String::new();
592 normalize_summary_verb(&mut s, "feat");
593 assert_eq!(s, "");
594 }
595
596 #[test]
597 fn test_normalize_summary_verb_single_word() {
598 let mut s = "add".to_string();
599 normalize_summary_verb(&mut s, "feat");
600 assert_eq!(s, "added");
601 }
602
603 #[test]
605 fn test_cap_details_under_budget() {
606 let mut details = vec!["first".to_string(), "second".to_string(), "third".to_string()];
607 let tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
608 cap_details(&mut details, tokens + 100);
609 assert_eq!(details.len(), 3);
610 }
611
612 #[test]
613 fn test_cap_details_at_budget() {
614 let mut details = vec![
615 "one".to_string(),
616 "two".to_string(),
617 "three".to_string(),
618 "four".to_string(),
619 "five".to_string(),
620 "six".to_string(),
621 ];
622 let tokens: usize = details.iter().map(|d| estimate_tokens(d)).sum();
623 cap_details(&mut details, tokens);
624 assert_eq!(details.len(), 6);
625 }
626
627 #[test]
628 fn test_cap_details_security_priority() {
629 let mut details = vec![
630 "normal change".to_string(),
631 "security vulnerability fixed".to_string(),
632 "another change".to_string(),
633 "third change".to_string(),
634 "fourth change".to_string(),
635 "fifth change".to_string(),
636 "sixth change".to_string(),
637 ];
638 cap_details(&mut details, 60);
640 assert!(details.iter().any(|d| d.contains("security")));
641 }
642
643 #[test]
644 fn test_cap_details_performance_priority() {
645 let mut details = vec![
646 "normal change".to_string(),
647 "performance optimization added".to_string(),
648 "another change".to_string(),
649 "third change".to_string(),
650 "fourth change".to_string(),
651 "fifth change".to_string(),
652 ];
653 cap_details(&mut details, 40);
655 assert!(details.iter().any(|d| d.contains("performance")));
656 }
657
658 #[test]
659 fn test_cap_details_api_priority() {
660 let mut details = vec![
661 "normal change".to_string(),
662 "API interface updated".to_string(),
663 "internal change".to_string(),
664 "another internal change".to_string(),
665 "yet another change".to_string(),
666 ];
667 cap_details(&mut details, 50);
669 assert!(details.iter().any(|d| d.contains("API")));
670 }
671
672 #[test]
673 fn test_cap_details_preserves_order() {
674 let mut details = vec![
675 "first".to_string(),
676 "critical security fix".to_string(),
677 "third".to_string(),
678 "performance improvement".to_string(),
679 "fifth".to_string(),
680 ];
681 cap_details(&mut details, 50);
683 let security_idx = details.iter().position(|d| d.contains("security"));
685 let perf_idx = details.iter().position(|d| d.contains("performance"));
686 assert!(security_idx.unwrap() < perf_idx.unwrap());
687 }
688
689 #[test]
690 fn test_cap_details_empty_list() {
691 let mut details: Vec<String> = vec![];
692 cap_details(&mut details, 100);
693 assert_eq!(details.len(), 0);
694 }
695
696 #[test]
697 fn test_cap_details_breaking_priority() {
698 let mut details = vec![
699 "normal change".to_string(),
700 "breaking change introduced".to_string(),
701 "another change".to_string(),
702 "third change".to_string(),
703 "fourth change".to_string(),
704 ];
705 cap_details(&mut details, 50);
707 assert!(details.iter().any(|d| d.contains("breaking")));
708 }
709
710 #[test]
711 fn test_cap_details_budget_prefers_short_high_priority() {
712 let mut details = vec![
714 "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(), ];
723 cap_details(&mut details, 30);
725 assert!(details.iter().any(|d| d.contains("security")));
727 assert!(details.iter().any(|d| d.contains("breaking")));
728 assert!(!details.iter().any(|d| d.contains("very long internal")));
730 }
731
732 #[test]
733 fn test_cap_details_budget_allows_variable_count() {
734 let short_details = vec![
736 "fix A".to_string(),
737 "fix B".to_string(),
738 "fix C".to_string(),
739 "fix D".to_string(),
740 "fix E".to_string(),
741 "fix F".to_string(),
742 ];
743 let long_details = vec![
744 "Fixed a critical security vulnerability in authentication".to_string(),
745 "Implemented comprehensive performance optimization".to_string(),
746 "Added extensive API documentation and examples".to_string(),
747 ];
748
749 let mut short = short_details;
750 let mut long = long_details;
751
752 cap_details(&mut short, 50); cap_details(&mut long, 50); assert!(short.len() >= 5); assert!(long.len() <= 3); }
758
759 #[test]
761 fn test_format_commit_message_type_summary_only() {
762 let commit = ConventionalCommit {
763 commit_type: CommitType::new("feat").unwrap(),
764 scope: None,
765 summary: CommitSummary::new_unchecked("added new feature", 128).unwrap(),
766 body: vec![],
767 footers: vec![],
768 };
769 assert_eq!(format_commit_message(&commit), "feat: added new feature");
770 }
771
772 #[test]
773 fn test_format_commit_message_with_scope() {
774 let commit = ConventionalCommit {
775 commit_type: CommitType::new("fix").unwrap(),
776 scope: Some(Scope::new("api").unwrap()),
777 summary: CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
778 body: vec![],
779 footers: vec![],
780 };
781 assert_eq!(format_commit_message(&commit), "fix(api): fixed bug");
782 }
783
784 #[test]
785 fn test_format_commit_message_with_body() {
786 let commit = ConventionalCommit {
787 commit_type: CommitType::new("feat").unwrap(),
788 scope: None,
789 summary: CommitSummary::new_unchecked("added feature", 128).unwrap(),
790 body: vec!["First detail.".to_string(), "Second detail.".to_string()],
791 footers: vec![],
792 };
793 let expected = "feat: added feature\n\n- First detail.\n- Second detail.";
794 assert_eq!(format_commit_message(&commit), expected);
795 }
796
797 #[test]
798 fn test_format_commit_message_with_footers() {
799 let commit = ConventionalCommit {
800 commit_type: CommitType::new("fix").unwrap(),
801 scope: None,
802 summary: CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
803 body: vec![],
804 footers: vec!["Closes: #123".to_string(), "Fixes: #456".to_string()],
805 };
806 let expected = "fix: fixed bug\n\nCloses: #123\nFixes: #456";
807 assert_eq!(format_commit_message(&commit), expected);
808 }
809
810 #[test]
811 fn test_format_commit_message_full() {
812 let commit = ConventionalCommit {
813 commit_type: CommitType::new("feat").unwrap(),
814 scope: Some(Scope::new("auth").unwrap()),
815 summary: CommitSummary::new_unchecked("added oauth support", 128).unwrap(),
816 body: vec![
817 "Implemented OAuth2 flow.".to_string(),
818 "Added token refresh.".to_string(),
819 ],
820 footers: vec!["Closes: #789".to_string()],
821 };
822 let expected = "feat(auth): added oauth support\n\n- Implemented OAuth2 flow.\n- Added \
823 token refresh.\n\nCloses: #789";
824 assert_eq!(format_commit_message(&commit), expected);
825 }
826
827 #[test]
828 fn test_format_commit_message_nested_scope() {
829 let commit = ConventionalCommit {
830 commit_type: CommitType::new("refactor").unwrap(),
831 scope: Some(Scope::new("api/client").unwrap()),
832 summary: CommitSummary::new_unchecked("restructured code", 128).unwrap(),
833 body: vec![],
834 footers: vec![],
835 };
836 assert_eq!(format_commit_message(&commit), "refactor(api/client): restructured code");
837 }
838}