llm_git/
normalization.rs

1/// Normalization utilities for commit messages
2use unicode_normalization::UnicodeNormalization;
3
4use crate::{config::CommitConfig, types::ConventionalCommit, validation::is_past_tense_verb};
5
6/// Normalize Unicode characters to ASCII (remove AI-style formatting)
7/// Normalize Unicode characters to ASCII (remove AI-style formatting)
8pub fn normalize_unicode(text: &str) -> String {
9   // Pre-NFKD replacements for chars that decompose badly
10   // (≠ → = + combining, ½ → 1⁄2, ² → 2)
11   let pre_normalized = text
12      // Math symbols that decompose badly
13      .replace('≠', "!=") // not equal to (decomposes to = + \u{338})
14      // Fractions (NFKD decomposes ½ to 1⁄2 with fraction slash, not regular /)
15      .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      // Superscripts (NFKD decomposes ² to just "2", losing the superscript meaning)
31      .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      // Subscripts
42      .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   // Apply NFKD normalization for canonical decomposition
54   let normalized: String = pre_normalized.nfkd().collect();
55
56   normalized
57      // Smart quotes to straight quotes
58      .replace(['\u{2018}', '\u{2019}'], "'") // ' right single quote / apostrophe
59      .replace(['\u{201C}', '\u{201D}'], "\"") // " right double quote
60      .replace('\u{201A}', "'") // ‚ single low-9 quote
61      .replace(['\u{201E}', '\u{00AB}', '\u{00BB}'], "\"") // » right-pointing double angle quote
62      .replace(['\u{2039}', '\u{203A}'], "'") // › single right-pointing angle quote
63      // Dashes and hyphens
64      .replace(['\u{2010}', '\u{2011}', '\u{2012}'], "-") // ‒ figure dash
65      .replace(['\u{2013}', '\u{2014}', '\u{2015}'], "--") // ― horizontal bar
66      .replace('\u{2212}', "-") // − minus sign
67      // Arrows
68      .replace('\u{2192}', "->") // rightwards arrow
69      .replace('←', "<-") // leftwards arrow
70      .replace('↔', "<->") // left right arrow
71      .replace('⇒', "=>") // rightwards double arrow
72      .replace('⇐', "<=") // leftwards double arrow
73      .replace('⇔', "<=>") // left right double arrow
74      .replace('↑', "^") // upwards arrow
75      .replace('↓', "v") // downwards arrow
76      // Math symbols
77      .replace('\u{2264}', "<=") // less than or equal to
78      .replace('≥', ">=") // greater than or equal to
79      .replace('≈', "~=") // approximately equal to
80      .replace('≡', "==") // identical to
81      .replace('\u{00D7}', "x") // multiplication sign
82      .replace('÷', "/") // division sign
83      // Ellipsis
84      .replace(['\u{2026}', '⋯', '⋮'], "...") // vertical ellipsis
85      // Bullet points (convert to hyphens for consistency)
86      .replace(['•', '◦', '▪', '▫', '◆', '◇'], "-") // white diamond
87      // Check marks
88      .replace(['✓', '✔'], "v") // heavy check mark
89      .replace(['✗', '✘'], "x") // heavy ballot x
90      // Greek letters (common in programming)
91      .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      // Special spaces to regular space
105      .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      ) // ideographic space
113      // Zero-width characters (remove)
114      .replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") // zero-width no-break space (BOM)
115}
116
117/// Cap detail points to top 3-4 by priority (keyword scoring + length)
118pub fn cap_details(details: &mut Vec<String>, max_count: usize) {
119   if details.len() <= max_count {
120      return;
121   }
122
123   // Score by priority keywords and length
124   let mut scored: Vec<(usize, i32, &String)> = details
125      .iter()
126      .enumerate()
127      .map(|(idx, detail)| {
128         let lower = detail.to_lowercase();
129         let mut score = 0;
130
131         // High priority keywords (security, crashes, critical bugs)
132         if lower.contains("security")
133            || lower.contains("vulnerability")
134            || lower.contains("exploit")
135            || lower.contains("critical")
136            || (lower.contains("fix") && lower.contains("crash"))
137         {
138            score += 100;
139         }
140         if lower.contains("breaking") || lower.contains("incompatible") {
141            score += 90;
142         }
143         if lower.contains("performance")
144            || lower.contains("faster")
145            || lower.contains("optimization")
146         {
147            score += 80;
148         }
149         if lower.contains("fix") || lower.contains("bug") {
150            score += 70;
151         }
152
153         // Medium priority keywords
154         if lower.contains("api") || lower.contains("interface") || lower.contains("public") {
155            score += 50;
156         }
157         if lower.contains("user") || lower.contains("client") {
158            score += 40;
159         }
160         if lower.contains("deprecated") || lower.contains("removed") {
161            score += 35;
162         }
163
164         // Add length component (capped contribution to avoid favoring verbosity)
165         score += (detail.len() / 20).min(10) as i32;
166
167         (idx, score, detail)
168      })
169      .collect();
170
171   // Sort by score descending
172   scored.sort_by(|a, b| b.1.cmp(&a.1));
173
174   // Keep top max_count indices
175   let mut keep_indices: Vec<usize> = scored
176      .iter()
177      .take(max_count)
178      .map(|(idx, ..)| *idx)
179      .collect();
180   keep_indices.sort_unstable(); // Preserve original order
181
182   // Filter details
183   let kept: Vec<String> = keep_indices
184      .iter()
185      .filter_map(|&idx| details.get(idx).cloned())
186      .collect();
187   *details = kept;
188}
189
190/// Convert present-tense verbs to past-tense and handle type-specific
191/// replacements
192pub fn normalize_summary_verb(summary: &mut String, commit_type: &str) {
193   if summary.trim().is_empty() {
194      return;
195   }
196
197   let mut parts_iter = summary.split_whitespace();
198   let first_word = match parts_iter.next() {
199      Some(word) => word.to_string(),
200      None => return,
201   };
202   let rest = parts_iter.collect::<Vec<_>>().join(" ");
203   let first_word_lower = first_word.to_lowercase();
204
205   // Check if already past tense
206   if is_past_tense_verb(&first_word_lower) {
207      // Special case: refactor type shouldn't use "refactored"
208      if commit_type == "refactor" && first_word_lower == "refactored" {
209         *summary = if rest.is_empty() {
210            "restructured".to_string()
211         } else {
212            format!("restructured {rest}")
213         };
214      }
215      return;
216   }
217
218   // Convert present tense to past tense
219   let converted = match first_word_lower.as_str() {
220      "add" | "adds" => Some("added"),
221      "fix" | "fixes" => Some("fixed"),
222      "update" | "updates" => Some("updated"),
223      "refactor" | "refactors" => Some(if commit_type == "refactor" {
224         "restructured"
225      } else {
226         "refactored"
227      }),
228      "remove" | "removes" => Some("removed"),
229      "replace" | "replaces" => Some("replaced"),
230      "improve" | "improves" => Some("improved"),
231      "implement" | "implements" => Some("implemented"),
232      "migrate" | "migrates" => Some("migrated"),
233      "rename" | "renames" => Some("renamed"),
234      "move" | "moves" => Some("moved"),
235      "merge" | "merges" => Some("merged"),
236      "split" | "splits" => Some("split"),
237      "extract" | "extracts" => Some("extracted"),
238      "restructure" | "restructures" => Some("restructured"),
239      "reorganize" | "reorganizes" => Some("reorganized"),
240      "consolidate" | "consolidates" => Some("consolidated"),
241      "simplify" | "simplifies" => Some("simplified"),
242      "optimize" | "optimizes" => Some("optimized"),
243      "document" | "documents" => Some("documented"),
244      "test" | "tests" => Some("tested"),
245      "change" | "changes" => Some("changed"),
246      "introduce" | "introduces" => Some("introduced"),
247      "deprecate" | "deprecates" => Some("deprecated"),
248      "delete" | "deletes" => Some("deleted"),
249      "correct" | "corrects" => Some("corrected"),
250      "enhance" | "enhances" => Some("enhanced"),
251      "revert" | "reverts" => Some("reverted"),
252      _ => None,
253   };
254
255   if let Some(past) = converted {
256      *summary = if rest.is_empty() {
257         past.to_string()
258      } else {
259         format!("{past} {rest}")
260      };
261   }
262}
263
264/// Post-process conventional commit message to fix common issues
265pub fn post_process_commit_message(msg: &mut ConventionalCommit, _config: &CommitConfig) {
266   // CommitType and Scope are already normalized to lowercase in their
267   // constructors No need to re-normalize them here
268
269   // Extract summary string for mutations, will reconstruct at end
270   let mut summary_str = normalize_unicode(msg.summary.as_str());
271
272   // Normalize body and footers
273   msg.body = msg.body.iter().map(|s| normalize_unicode(s)).collect();
274   msg.footers = msg.footers.iter().map(|s| normalize_unicode(s)).collect();
275
276   // Normalize summary formatting: single line, trimmed, enforce trailing period
277   summary_str = summary_str
278      .replace(['\r', '\n'], " ")
279      .split_whitespace()
280      .collect::<Vec<_>>()
281      .join(" ")
282      .trim()
283      .trim_end_matches('.')
284      .trim_end_matches(';')
285      .trim_end_matches(':')
286      .to_string();
287
288   // Helper: check if first token is all caps (acronym/initialism)
289   let is_first_token_all_caps = |s: &str| -> bool {
290      s.split_whitespace().next().is_some_and(|token| {
291         token
292            .chars()
293            .all(|c| !c.is_alphabetic() || c.is_uppercase())
294      })
295   };
296
297   // Ensure summary starts with lowercase (unless first token is all caps)
298   if !is_first_token_all_caps(&summary_str)
299      && let Some(first_char) = summary_str.chars().next()
300      && first_char.is_uppercase()
301   {
302      let rest = &summary_str[first_char.len_utf8()..];
303      summary_str = format!("{}{}", first_char.to_lowercase(), rest);
304   }
305
306   // Normalize verb tense (present \u{2192} past, handle type-specific
307   // replacements)
308   normalize_summary_verb(&mut summary_str, msg.commit_type.as_str());
309   summary_str = summary_str.trim().to_string();
310
311   // Ensure lowercase after normalization (unless first token is all caps)
312   if !is_first_token_all_caps(&summary_str)
313      && let Some(first_char) = summary_str.chars().next()
314      && first_char.is_uppercase()
315   {
316      let rest = &summary_str[first_char.len_utf8()..];
317      summary_str = format!("{}{}", first_char.to_lowercase(), rest);
318   }
319
320   // No truncation - validation handles length checks
321   // Remove any trailing period (conventional commits don't use periods)
322   summary_str = summary_str.trim_end_matches('.').to_string();
323
324   // Reconstruct CommitSummary (bypassing warnings since post-processing
325   // normalizes)
326   msg.summary = crate::types::CommitSummary::new_unchecked(summary_str, 128)
327      .expect("post-processed summary should be valid");
328
329   // Clean and enforce punctuation for body items
330   for item in &mut msg.body {
331      let mut cleaned = item
332         .replace(['\r', '\n'], " ")
333         .trim()
334         .trim_start_matches('\u{2022}')
335         .trim_start_matches('-')
336         .trim_start_matches('*')
337         .trim_start_matches('+')
338         .trim()
339         .to_string();
340
341      cleaned = cleaned
342         .split_whitespace()
343         .collect::<Vec<_>>()
344         .join(" ")
345         .trim()
346         .trim_end_matches('.')
347         .trim_end_matches(';')
348         .trim_end_matches(',')
349         .to_string();
350
351      if cleaned.is_empty() {
352         *item = cleaned;
353         continue;
354      }
355
356      // Capitalize first letter
357      if let Some(first_char) = cleaned.chars().next()
358         && first_char.is_lowercase()
359      {
360         let rest = &cleaned[first_char.len_utf8()..];
361         cleaned = format!("{}{}", first_char.to_uppercase(), rest);
362      }
363
364      if !cleaned.ends_with('.') {
365         cleaned.push('.');
366      }
367
368      *item = cleaned;
369   }
370
371   // Remove empty body items
372   msg.body.retain(|item| !item.trim().is_empty());
373
374   // Cap details to top 4 by priority
375   cap_details(&mut msg.body, 4);
376}
377
378/// Format `ConventionalCommit` as a single string for display and commit
379pub fn format_commit_message(msg: &ConventionalCommit) -> String {
380   // Build first line: type(scope): summary
381   let scope_part = msg
382      .scope
383      .as_ref()
384      .map(|s| format!("({s})"))
385      .unwrap_or_default();
386   let first_line = format!("{}{}: {}", msg.commit_type, scope_part, msg.summary);
387
388   // Build body with - bullets
389   let body_formatted = if msg.body.is_empty() {
390      String::new()
391   } else {
392      msg.body
393         .iter()
394         .map(|item| format!("- {item}"))
395         .collect::<Vec<_>>()
396         .join("\n")
397   };
398
399   // Build footers
400   let footers_formatted = if msg.footers.is_empty() {
401      String::new()
402   } else {
403      msg.footers.join("\n")
404   };
405
406   // Combine parts
407   let mut result = first_line;
408   if !body_formatted.is_empty() {
409      result.push_str("\n\n");
410      result.push_str(&body_formatted);
411   }
412   if !footers_formatted.is_empty() {
413      result.push_str("\n\n");
414      result.push_str(&footers_formatted);
415   }
416   result
417}
418
419#[cfg(test)]
420mod tests {
421   use super::*;
422   use crate::types::{CommitSummary, CommitType, ConventionalCommit, Scope};
423
424   // normalize_unicode tests
425   #[test]
426   fn test_normalize_unicode_smart_quotes() {
427      assert_eq!(normalize_unicode("\u{2018}smart quotes\u{2019}"), "'smart quotes'");
428      assert_eq!(normalize_unicode("\u{201C}double quotes\u{201D}"), "\"double quotes\"");
429      assert_eq!(normalize_unicode("\u{201A}low quote\u{2019}"), "'low quote'");
430      assert_eq!(normalize_unicode("\u{201E}low double\u{201D}"), "\"low double\"");
431   }
432
433   #[test]
434   fn test_normalize_unicode_dashes() {
435      assert_eq!(normalize_unicode("en\u{2013}dash"), "en--dash");
436      assert_eq!(normalize_unicode("em\u{2014}dash"), "em--dash");
437      assert_eq!(normalize_unicode("fig\u{2012}dash"), "fig-dash");
438      assert_eq!(normalize_unicode("minus\u{2212}sign"), "minus-sign");
439   }
440
441   #[test]
442   fn test_normalize_unicode_arrows() {
443      assert_eq!(normalize_unicode("arrow\u{2192}right"), "arrow->right");
444      assert_eq!(normalize_unicode("arrow\u{2190}left"), "arrow<-left");
445      assert_eq!(normalize_unicode("arrow\u{2194}both"), "arrow<->both");
446      assert_eq!(normalize_unicode("double\u{21D2}arrow"), "double=>arrow");
447      assert_eq!(normalize_unicode("up\u{2191}arrow"), "up^arrow");
448   }
449
450   #[test]
451   fn test_normalize_unicode_math() {
452      assert_eq!(normalize_unicode("a\u{00D7}b"), "axb");
453      assert_eq!(normalize_unicode("a\u{00F7}b"), "a/b");
454      assert_eq!(normalize_unicode("x\u{2264}y"), "x<=y");
455      assert_eq!(normalize_unicode("x\u{2265}y"), "x>=y");
456      assert_eq!(normalize_unicode("x\u{2260}y"), "x!=y");
457      assert_eq!(normalize_unicode("x\u{2248}y"), "x~=y");
458   }
459
460   #[test]
461   fn test_normalize_unicode_greek() {
462      assert_eq!(normalize_unicode("\u{03BB} function"), "lambda function");
463      assert_eq!(normalize_unicode("\u{03B1} beta \u{03B3}"), "alpha beta gamma");
464      assert_eq!(normalize_unicode("\u{03BC} service"), "mu service");
465      assert_eq!(normalize_unicode("\u{03A3} total"), "Sigma total");
466   }
467
468   #[test]
469   fn test_normalize_unicode_fractions() {
470      assert_eq!(normalize_unicode("\u{00BD} cup"), "1/2 cup");
471      assert_eq!(normalize_unicode("\u{00BE} done"), "3/4 done");
472      assert_eq!(normalize_unicode("\u{2153} left"), "1/3 left");
473   }
474
475   #[test]
476   fn test_normalize_unicode_superscripts() {
477      assert_eq!(normalize_unicode("x\u{00B2}"), "x^2");
478      assert_eq!(normalize_unicode("10\u{00B3}"), "10^3");
479   }
480
481   #[test]
482   fn test_normalize_unicode_multiple_replacements() {
483      let input =
484         "\u{2018}smart\u{2019}\u{2192}straight \u{201C}quotes\u{201D}\u{00D7}math\u{2264}ops";
485      let expected = "'smart'->straight \"quotes\"xmath<=ops";
486      assert_eq!(normalize_unicode(input), expected);
487   }
488
489   #[test]
490   fn test_normalize_unicode_ellipsis() {
491      assert_eq!(normalize_unicode("wait\u{2026}"), "wait...");
492      assert_eq!(normalize_unicode("more\u{22EF}dots"), "more...dots");
493   }
494
495   #[test]
496   fn test_normalize_unicode_bullets() {
497      assert_eq!(normalize_unicode("\u{2022}item"), "-item");
498      assert_eq!(normalize_unicode("\u{25E6}item"), "-item");
499   }
500
501   #[test]
502   fn test_normalize_unicode_check_marks() {
503      assert_eq!(normalize_unicode("\u{2713}done"), "vdone");
504      assert_eq!(normalize_unicode("\u{2717}failed"), "xfailed");
505   }
506
507   // normalize_summary_verb tests
508   #[test]
509   fn test_normalize_summary_verb_present_to_past() {
510      let mut s = "add new feature".to_string();
511      normalize_summary_verb(&mut s, "feat");
512      assert_eq!(s, "added new feature");
513
514      let mut s = "fix bug".to_string();
515      normalize_summary_verb(&mut s, "fix");
516      assert_eq!(s, "fixed bug");
517
518      let mut s = "update docs".to_string();
519      normalize_summary_verb(&mut s, "docs");
520      assert_eq!(s, "updated docs");
521   }
522
523   #[test]
524   fn test_normalize_summary_verb_already_past() {
525      let mut s = "added feature".to_string();
526      normalize_summary_verb(&mut s, "feat");
527      assert_eq!(s, "added feature");
528
529      let mut s = "fixed bug".to_string();
530      normalize_summary_verb(&mut s, "fix");
531      assert_eq!(s, "fixed bug");
532   }
533
534   #[test]
535   fn test_normalize_summary_verb_third_person() {
536      let mut s = "adds feature".to_string();
537      normalize_summary_verb(&mut s, "feat");
538      assert_eq!(s, "added feature");
539
540      let mut s = "fixes bug".to_string();
541      normalize_summary_verb(&mut s, "fix");
542      assert_eq!(s, "fixed bug");
543   }
544
545   #[test]
546   fn test_normalize_summary_verb_non_verb_start() {
547      let mut s = "123 files changed".to_string();
548      normalize_summary_verb(&mut s, "chore");
549      assert_eq!(s, "123 files changed");
550   }
551
552   #[test]
553   fn test_normalize_summary_verb_refactor_special_case() {
554      let mut s = "refactored code".to_string();
555      normalize_summary_verb(&mut s, "refactor");
556      assert_eq!(s, "restructured code");
557   }
558
559   #[test]
560   fn test_normalize_summary_verb_refactor_present() {
561      let mut s = "refactor code".to_string();
562      normalize_summary_verb(&mut s, "refactor");
563      assert_eq!(s, "restructured code");
564
565      let mut s = "refactor logic".to_string();
566      normalize_summary_verb(&mut s, "feat");
567      assert_eq!(s, "refactored logic");
568   }
569
570   #[test]
571   fn test_normalize_summary_verb_empty() {
572      let mut s = String::new();
573      normalize_summary_verb(&mut s, "feat");
574      assert_eq!(s, "");
575   }
576
577   #[test]
578   fn test_normalize_summary_verb_single_word() {
579      let mut s = "add".to_string();
580      normalize_summary_verb(&mut s, "feat");
581      assert_eq!(s, "added");
582   }
583
584   // cap_details tests
585   #[test]
586   fn test_cap_details_below_max() {
587      let mut details = vec!["first".to_string(), "second".to_string(), "third".to_string()];
588      cap_details(&mut details, 6);
589      assert_eq!(details.len(), 3);
590   }
591
592   #[test]
593   fn test_cap_details_at_max() {
594      let mut details = vec![
595         "one".to_string(),
596         "two".to_string(),
597         "three".to_string(),
598         "four".to_string(),
599         "five".to_string(),
600         "six".to_string(),
601      ];
602      cap_details(&mut details, 6);
603      assert_eq!(details.len(), 6);
604   }
605
606   #[test]
607   fn test_cap_details_security_priority() {
608      let mut details = vec![
609         "normal change".to_string(),
610         "security vulnerability fixed".to_string(),
611         "another change".to_string(),
612         "third change".to_string(),
613         "fourth change".to_string(),
614         "fifth change".to_string(),
615         "sixth change".to_string(),
616      ];
617      cap_details(&mut details, 4);
618      assert!(details.iter().any(|d| d.contains("security")));
619   }
620
621   #[test]
622   fn test_cap_details_performance_priority() {
623      let mut details = vec![
624         "normal change".to_string(),
625         "performance optimization added".to_string(),
626         "another change".to_string(),
627         "third change".to_string(),
628         "fourth change".to_string(),
629         "fifth change".to_string(),
630      ];
631      cap_details(&mut details, 3);
632      assert!(details.iter().any(|d| d.contains("performance")));
633   }
634
635   #[test]
636   fn test_cap_details_api_priority() {
637      let mut details = vec![
638         "normal change".to_string(),
639         "API interface updated".to_string(),
640         "internal change".to_string(),
641         "another internal change".to_string(),
642         "yet another change".to_string(),
643      ];
644      cap_details(&mut details, 3);
645      assert!(details.iter().any(|d| d.contains("API")));
646   }
647
648   #[test]
649   fn test_cap_details_preserves_order() {
650      let mut details = vec![
651         "first".to_string(),
652         "critical security fix".to_string(),
653         "third".to_string(),
654         "performance improvement".to_string(),
655         "fifth".to_string(),
656      ];
657      cap_details(&mut details, 3);
658      // Should preserve relative order of kept items
659      let security_idx = details.iter().position(|d| d.contains("security"));
660      let perf_idx = details.iter().position(|d| d.contains("performance"));
661      assert!(security_idx.unwrap() < perf_idx.unwrap());
662   }
663
664   #[test]
665   fn test_cap_details_empty_list() {
666      let mut details: Vec<String> = vec![];
667      cap_details(&mut details, 4);
668      assert_eq!(details.len(), 0);
669   }
670
671   #[test]
672   fn test_cap_details_breaking_priority() {
673      let mut details = vec![
674         "normal change".to_string(),
675         "breaking change introduced".to_string(),
676         "another change".to_string(),
677         "third change".to_string(),
678         "fourth change".to_string(),
679      ];
680      cap_details(&mut details, 3);
681      assert!(details.iter().any(|d| d.contains("breaking")));
682   }
683
684   // format_commit_message tests
685   #[test]
686   fn test_format_commit_message_type_summary_only() {
687      let commit = ConventionalCommit {
688         commit_type: CommitType::new("feat").unwrap(),
689         scope:       None,
690         summary:     CommitSummary::new_unchecked("added new feature", 128).unwrap(),
691         body:        vec![],
692         footers:     vec![],
693      };
694      assert_eq!(format_commit_message(&commit), "feat: added new feature");
695   }
696
697   #[test]
698   fn test_format_commit_message_with_scope() {
699      let commit = ConventionalCommit {
700         commit_type: CommitType::new("fix").unwrap(),
701         scope:       Some(Scope::new("api").unwrap()),
702         summary:     CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
703         body:        vec![],
704         footers:     vec![],
705      };
706      assert_eq!(format_commit_message(&commit), "fix(api): fixed bug");
707   }
708
709   #[test]
710   fn test_format_commit_message_with_body() {
711      let commit = ConventionalCommit {
712         commit_type: CommitType::new("feat").unwrap(),
713         scope:       None,
714         summary:     CommitSummary::new_unchecked("added feature", 128).unwrap(),
715         body:        vec!["First detail.".to_string(), "Second detail.".to_string()],
716         footers:     vec![],
717      };
718      let expected = "feat: added feature\n\n- First detail.\n- Second detail.";
719      assert_eq!(format_commit_message(&commit), expected);
720   }
721
722   #[test]
723   fn test_format_commit_message_with_footers() {
724      let commit = ConventionalCommit {
725         commit_type: CommitType::new("fix").unwrap(),
726         scope:       None,
727         summary:     CommitSummary::new_unchecked("fixed bug", 128).unwrap(),
728         body:        vec![],
729         footers:     vec!["Closes: #123".to_string(), "Fixes: #456".to_string()],
730      };
731      let expected = "fix: fixed bug\n\nCloses: #123\nFixes: #456";
732      assert_eq!(format_commit_message(&commit), expected);
733   }
734
735   #[test]
736   fn test_format_commit_message_full() {
737      let commit = ConventionalCommit {
738         commit_type: CommitType::new("feat").unwrap(),
739         scope:       Some(Scope::new("auth").unwrap()),
740         summary:     CommitSummary::new_unchecked("added oauth support", 128).unwrap(),
741         body:        vec![
742            "Implemented OAuth2 flow.".to_string(),
743            "Added token refresh.".to_string(),
744         ],
745         footers:     vec!["Closes: #789".to_string()],
746      };
747      let expected = "feat(auth): added oauth support\n\n- Implemented OAuth2 flow.\n- Added \
748                      token refresh.\n\nCloses: #789";
749      assert_eq!(format_commit_message(&commit), expected);
750   }
751
752   #[test]
753   fn test_format_commit_message_nested_scope() {
754      let commit = ConventionalCommit {
755         commit_type: CommitType::new("refactor").unwrap(),
756         scope:       Some(Scope::new("api/client").unwrap()),
757         summary:     CommitSummary::new_unchecked("restructured code", 128).unwrap(),
758         body:        vec![],
759         footers:     vec![],
760      };
761      assert_eq!(format_commit_message(&commit), "refactor(api/client): restructured code");
762   }
763}