llm_git/
validation.rs

1use crate::{
2   config::CommitConfig,
3   error::{CommitGenError, Result},
4   types::ConventionalCommit,
5};
6
7/// Check if word is past-tense verb using morphology + common irregulars
8pub fn is_past_tense_verb(word: &str) -> bool {
9   // Regular past tense: ends with -ed
10   if word.ends_with("ed") {
11      // Exclude common false positives (words that end in -ed but aren't verbs)
12      const BLOCKLIST: &[&str] = &["hundred", "thousand", "red", "bed", "wed", "shed"];
13      return !BLOCKLIST.contains(&word);
14   }
15
16   // Words ending in single 'd' preceded by vowel (configured, exposed, etc.)
17   // Must be at least 4 chars and not end in common non-verb patterns
18   if word.len() >= 4 && word.ends_with('d') {
19      let before_d = &word[word.len() - 2..word.len() - 1];
20      // Check if letter before 'd' is vowel (covers: configured, exposed, etc.)
21      if "aeiou".contains(before_d) {
22         const D_BLOCKLIST: &[&str] = &[
23            "and", "bad", "bid", "god", "had", "kid", "lad", "mad", "mid", "mud", "nod", "odd",
24            "old", "pad", "raid", "said", "sad", "should", "would", "could",
25         ];
26         return !D_BLOCKLIST.contains(&word);
27      }
28   }
29
30   // Common irregular past-tense verbs
31   const IRREGULAR: &[&str] = &[
32      "made",
33      "built",
34      "ran",
35      "wrote",
36      "took",
37      "gave",
38      "found",
39      "kept",
40      "left",
41      "felt",
42      "meant",
43      "sent",
44      "spent",
45      "lost",
46      "held",
47      "told",
48      "sold",
49      "stood",
50      "understood",
51      "became",
52      "began",
53      "brought",
54      "bought",
55      "caught",
56      "taught",
57      "thought",
58      "fought",
59      "sought",
60      "chose",
61      "came",
62      "did",
63      "got",
64      "had",
65      "knew",
66      "met",
67      "put",
68      "read",
69      "saw",
70      "said",
71      "set",
72      "sat",
73      "cut",
74      "let",
75      "hit",
76      "hurt",
77      "shut",
78      "split",
79      "spread",
80      "bet",
81      "cast",
82      "cost",
83      "quit",
84   ];
85
86   IRREGULAR.contains(&word)
87}
88
89/// Validate conventional commit message
90pub fn validate_commit_message(msg: &ConventionalCommit, config: &CommitConfig) -> Result<()> {
91   // Validate commit type
92   let valid_types = [
93      "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert",
94   ];
95   if !valid_types.contains(&msg.commit_type.as_str()) {
96      return Err(CommitGenError::InvalidCommitType(format!(
97         "Invalid commit type: '{}'. Must be one of: {}",
98         msg.commit_type,
99         valid_types.join(", ")
100      )));
101   }
102
103   // Validate scope (if present) - Scope type already validates format
104   // This is just a double-check, Scope::new() already enforces rules
105   if let Some(ref scope) = msg.scope
106      && scope.is_empty()
107   {
108      return Err(CommitGenError::InvalidScope(
109         "Scope cannot be empty string (omit if not applicable)".to_string(),
110      ));
111   }
112
113   // Check summary not empty
114   if msg.summary.as_str().trim().is_empty() {
115      return Err(CommitGenError::ValidationError("Summary cannot be empty".to_string()));
116   }
117
118   // Check summary does NOT end with period (conventional commits don't use
119   // periods)
120   if msg.summary.as_str().trim_end().ends_with('.') {
121      return Err(CommitGenError::ValidationError(
122         "Summary must NOT end with a period (conventional commits style)".to_string(),
123      ));
124   }
125
126   // Check first line length: type(scope): summary
127   let scope_part = msg
128      .scope
129      .as_ref()
130      .map(|s| format!("({s})"))
131      .unwrap_or_default();
132   let first_line_len = msg.commit_type.len() + scope_part.len() + 2 + msg.summary.len();
133
134   // Hard limit check (absolute maximum) - REJECT
135   if first_line_len > config.summary_hard_limit {
136      return Err(CommitGenError::SummaryTooLong {
137         len: first_line_len,
138         max: config.summary_hard_limit,
139      });
140   }
141
142   // Soft limit warning (triggers retry in main.rs) - WARN but pass
143   if first_line_len > config.summary_soft_limit {
144      eprintln!(
145         "⚠ Summary exceeds soft limit: {} > {} chars (retry recommended)",
146         first_line_len, config.summary_soft_limit
147      );
148   }
149
150   // Guideline warning (72-96 range) - INFO
151   if first_line_len > config.summary_guideline && first_line_len <= config.summary_soft_limit {
152      eprintln!(
153         "ℹ Summary exceeds guideline: {} > {} chars (still acceptable)",
154         first_line_len, config.summary_guideline
155      );
156   }
157
158   // Check summary starts with lowercase after type:
159   if let Some(first_char) = msg.summary.as_str().trim().chars().next()
160      && first_char.is_uppercase()
161   {
162      eprintln!("Warning: Summary '{}' should start lowercase after type:", msg.summary);
163   }
164
165   // Check first word is past-tense verb (morphology-based)
166   let first_word = msg.summary.as_str().split_whitespace().next().unwrap_or("");
167
168   if first_word.is_empty() {
169      return Err(CommitGenError::ValidationError(
170         "Summary must contain at least one word".to_string(),
171      ));
172   }
173
174   let first_word_lower = first_word.to_lowercase();
175   if !is_past_tense_verb(&first_word_lower) {
176      return Err(CommitGenError::ValidationError(format!(
177         "Summary must start with a past-tense verb (ending in -ed/-d or irregular). Got \
178          '{first_word}'"
179      )));
180   }
181
182   // Check for type-word repetition
183   let type_word = msg.commit_type.as_str();
184   if first_word_lower == type_word {
185      return Err(CommitGenError::ValidationError(format!(
186         "Summary repeats commit type '{type_word}': first word is '{first_word}'"
187      )));
188   }
189
190   // Check for filler words (removed "improved"/"enhanced" as they're valid
191   // past-tense verbs)
192   const FILLER_WORDS: &[&str] = &["comprehensive", "better", "various", "several"];
193   for filler in FILLER_WORDS {
194      if msg.summary.as_str().to_lowercase().contains(filler) {
195         eprintln!("Warning: Summary contains filler word '{}': {}", filler, msg.summary);
196      }
197   }
198
199   // Check for meta-phrases that add no information
200   const META_PHRASES: &[&str] = &[
201      "this commit",
202      "this change",
203      "updated code",
204      "updated the",
205      "modified code",
206      "changed code",
207      "improved code",
208      "modified the",
209      "changed the",
210   ];
211   for phrase in META_PHRASES {
212      if msg.summary.as_str().to_lowercase().contains(phrase) {
213         eprintln!(
214            "Warning: Summary contains meta-phrase '{phrase}' - be more specific about what \
215             changed"
216         );
217      }
218   }
219
220   // Final length check after all potential mutations
221   let final_scope_part = msg
222      .scope
223      .as_ref()
224      .map(|s| format!("({s})"))
225      .unwrap_or_default();
226   let final_first_line_len =
227      msg.commit_type.len() + final_scope_part.len() + 2 + msg.summary.len();
228
229   if final_first_line_len > config.summary_hard_limit {
230      return Err(CommitGenError::SummaryTooLong {
231         len: final_first_line_len,
232         max: config.summary_hard_limit,
233      });
234   }
235
236   // Validate body items
237   for item in &msg.body {
238      let first_word = item.split_whitespace().next().unwrap_or("");
239      let present_tense = [
240         "adds",
241         "fixes",
242         "updates",
243         "removes",
244         "changes",
245         "creates",
246         "refactors",
247         "implements",
248         "migrates",
249         "renames",
250         "moves",
251         "replaces",
252         "improves",
253         "merges",
254         "splits",
255         "extracts",
256         "restructures",
257         "reorganizes",
258         "consolidates",
259      ];
260      if present_tense
261         .iter()
262         .any(|&word| first_word.to_lowercase() == word)
263      {
264         eprintln!("Warning: Body item uses present tense: '{item}'");
265      }
266      if !item.trim_end().ends_with('.') {
267         eprintln!("Warning: Body item missing period: '{item}'");
268      }
269   }
270
271   Ok(())
272}
273
274/// Check type-scope consistency (warn if mismatched)
275pub fn check_type_scope_consistency(msg: &ConventionalCommit, stat: &str) {
276   let commit_type = msg.commit_type.as_str();
277
278   // Check for docs type
279   if commit_type == "docs" {
280      let has_docs = stat.lines().any(|line| {
281         let path = line.split('|').next().unwrap_or("").trim();
282         std::path::Path::new(&path)
283            .extension()
284            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
285            || path.to_lowercase().contains("/docs/")
286            || path.to_lowercase().contains("readme")
287      });
288      if !has_docs {
289         eprintln!("Warning: Commit type 'docs' but no documentation files (.md) changed");
290      }
291   }
292
293   // Check for test type
294   if commit_type == "test" {
295      let has_test = stat.lines().any(|line| {
296         let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
297         path.contains("/test") || path.contains("_test.") || path.contains(".test.")
298      });
299      if !has_test {
300         eprintln!("Warning: Commit type 'test' but no test files changed");
301      }
302   }
303
304   // Check for style type (should be mostly whitespace/formatting)
305   if commit_type == "style" {
306      let has_code = stat.lines().any(|line| {
307         let path = line.split('|').next().unwrap_or("").trim();
308         let path_obj = std::path::Path::new(&path);
309         path_obj.extension().is_some_and(|ext| {
310            ext.eq_ignore_ascii_case("rs")
311               || ext.eq_ignore_ascii_case("js")
312               || ext.eq_ignore_ascii_case("py")
313               || ext.eq_ignore_ascii_case("go")
314         })
315      });
316      if has_code {
317         eprintln!("Warning: Commit type 'style' but code files changed (verify no logic changes)");
318      }
319   }
320
321   // Check for ci type
322   if commit_type == "ci" {
323      let has_ci = stat.lines().any(|line| {
324         let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
325         path.contains(".github/workflows")
326            || path.contains(".gitlab-ci")
327            || path.contains("jenkinsfile")
328      });
329      if !has_ci {
330         eprintln!("Warning: Commit type 'ci' but no CI configuration files changed");
331      }
332   }
333
334   // Check for build type
335   if commit_type == "build" {
336      let has_build = stat.lines().any(|line| {
337         let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
338         path.contains("cargo.toml")
339            || path.contains("package.json")
340            || path.contains("makefile")
341            || path.contains("build.")
342      });
343      if !has_build {
344         eprintln!(
345            "Warning: Commit type 'build' but no build files (Cargo.toml, package.json) changed"
346         );
347      }
348   }
349
350   // Check for refactor with new files (might actually be feat)
351   if commit_type == "refactor" {
352      let has_new_files = stat
353         .lines()
354         .any(|line| line.trim().starts_with("create mode") || line.contains("new file"));
355      if has_new_files {
356         eprintln!(
357            "Warning: Commit type 'refactor' but new files were created - verify no new \
358             capabilities added (might be 'feat')"
359         );
360      }
361   }
362
363   // Check for perf type without performance evidence
364   if commit_type == "perf" {
365      let has_perf_files = stat.lines().any(|line| {
366         let path = line.split('|').next().unwrap_or("").trim().to_lowercase();
367         path.contains("bench") || path.contains("perf") || path.contains("profile")
368      });
369
370      // Check if details mention performance
371      let details_text = msg.body.join(" ").to_lowercase();
372      let has_perf_details = details_text.contains("faster")
373         || details_text.contains("optimization")
374         || details_text.contains("performance")
375         || details_text.contains("optimized");
376
377      if !has_perf_files && !has_perf_details {
378         eprintln!(
379            "Warning: Commit type 'perf' but no performance-related files or optimization \
380             keywords found"
381         );
382      }
383   }
384}
385
386#[cfg(test)]
387mod tests {
388   use super::*;
389   use crate::types::{CommitSummary, CommitType, ConventionalCommit, Scope};
390
391   fn create_commit(
392      type_str: &str,
393      scope: Option<&str>,
394      summary: &str,
395      body: Vec<&str>,
396   ) -> ConventionalCommit {
397      ConventionalCommit {
398         commit_type: CommitType::new(type_str).unwrap(),
399         scope:       scope.map(|s| Scope::new(s).unwrap()),
400         summary:     CommitSummary::new_unchecked(summary, 128).unwrap(),
401         body:        body.into_iter().map(|s| s.to_string()).collect(),
402         footers:     vec![],
403      }
404   }
405
406   #[test]
407   fn test_validate_valid_commit() {
408      let config = CommitConfig::default();
409      let msg = create_commit("feat", Some("api"), "added new endpoint", vec![]);
410      assert!(validate_commit_message(&msg, &config).is_ok());
411   }
412
413   #[test]
414   fn test_validate_valid_commit_no_scope() {
415      let config = CommitConfig::default();
416      let msg = create_commit("fix", None, "corrected race condition", vec![]);
417      assert!(validate_commit_message(&msg, &config).is_ok());
418   }
419
420   #[test]
421   fn test_validate_invalid_type() {
422      let _config = CommitConfig::default();
423      let result = CommitType::new("invalid");
424      assert!(result.is_err());
425      assert!(matches!(result.unwrap_err(), CommitGenError::InvalidCommitType(_)));
426   }
427
428   #[test]
429   fn test_validate_summary_ends_with_period() {
430      let config = CommitConfig::default();
431      let msg = create_commit("feat", Some("api"), "added endpoint.", vec![]);
432      let result = validate_commit_message(&msg, &config);
433      assert!(result.is_err());
434      assert!(
435         result
436            .unwrap_err()
437            .to_string()
438            .contains("must NOT end with a period")
439      );
440   }
441
442   #[test]
443   fn test_validate_summary_too_long() {
444      // CommitSummary::new() enforces 128 char hard limit on summary alone
445      let long_summary = "a".repeat(129);
446      let result = CommitSummary::new(&long_summary, 128);
447      assert!(result.is_err());
448      assert!(matches!(result.unwrap_err(), CommitGenError::SummaryTooLong { .. }));
449   }
450
451   #[test]
452   fn test_validate_summary_empty() {
453      let result = CommitSummary::new("", 128);
454      assert!(result.is_err());
455      assert!(matches!(result.unwrap_err(), CommitGenError::ValidationError(_)));
456   }
457
458   #[test]
459   fn test_validate_summary_empty_whitespace() {
460      let result = CommitSummary::new("   ", 128);
461      assert!(result.is_err());
462      assert!(matches!(result.unwrap_err(), CommitGenError::ValidationError(_)));
463   }
464
465   #[test]
466   fn test_validate_wrong_verb() {
467      let config = CommitConfig::default();
468      let result = CommitSummary::new_unchecked("adding new feature", 128);
469      assert!(result.is_ok());
470      let msg = ConventionalCommit {
471         commit_type: CommitType::new("feat").unwrap(),
472         scope:       None,
473         summary:     result.unwrap(),
474         body:        vec![],
475         footers:     vec![],
476      };
477      let result = validate_commit_message(&msg, &config);
478      assert!(result.is_err());
479      assert!(
480         result
481            .unwrap_err()
482            .to_string()
483            .contains("must start with a past-tense verb")
484      );
485   }
486
487   #[test]
488   fn test_validate_present_tense_verb() {
489      let config = CommitConfig::default();
490      let result = CommitSummary::new_unchecked("adds new feature", 128);
491      assert!(result.is_ok());
492      let msg = ConventionalCommit {
493         commit_type: CommitType::new("feat").unwrap(),
494         scope:       None,
495         summary:     result.unwrap(),
496         body:        vec![],
497         footers:     vec![],
498      };
499      let result = validate_commit_message(&msg, &config);
500      assert!(result.is_err());
501      assert!(
502         result
503            .unwrap_err()
504            .to_string()
505            .contains("must start with a past-tense verb")
506      );
507   }
508
509   #[test]
510   fn test_validate_no_type_verb_overlap() {
511      // This test verifies that using a related verb doesn't trigger false positives
512      // "documented" is valid for "docs" type since they're not exact matches
513      let config = CommitConfig::default();
514      let msg = create_commit("docs", Some("api"), "documented new api", vec![]);
515      assert!(validate_commit_message(&msg, &config).is_ok());
516
517      // "tested" is valid for "test" type
518      let msg = create_commit("test", Some("api"), "added unit tests", vec![]);
519      assert!(validate_commit_message(&msg, &config).is_ok());
520   }
521
522   #[test]
523   fn test_validate_morphology_based_past_tense() {
524      let config = CommitConfig::default();
525      // Test regular -ed endings
526      let regular_verbs = ["added", "configured", "exposed", "formatted", "clarified"];
527      for verb in regular_verbs {
528         let summary = format!("{verb} something");
529         let msg = create_commit("feat", None, &summary, vec![]);
530         assert!(
531            validate_commit_message(&msg, &config).is_ok(),
532            "Regular verb '{verb}' should be accepted"
533         );
534      }
535
536      // Test irregular verbs
537      let irregular_verbs = ["made", "built", "ran", "wrote", "split"];
538      for verb in irregular_verbs {
539         let summary = format!("{verb} something");
540         let msg = create_commit("feat", None, &summary, vec![]);
541         assert!(
542            validate_commit_message(&msg, &config).is_ok(),
543            "Irregular verb '{verb}' should be accepted"
544         );
545      }
546
547      // Test false positives (should be rejected)
548      let non_verbs = ["hundred", "red", "bed"];
549      for word in non_verbs {
550         let summary = format!("{word} something");
551         let msg = ConventionalCommit {
552            commit_type: CommitType::new("feat").unwrap(),
553            scope:       None,
554            summary:     CommitSummary::new_unchecked(&summary, 128).unwrap(),
555            body:        vec![],
556            footers:     vec![],
557         };
558         assert!(
559            validate_commit_message(&msg, &config).is_err(),
560            "Non-verb '{word}' should be rejected"
561         );
562      }
563   }
564
565   #[test]
566   fn test_validate_scope_empty_string() {
567      let result = Scope::new("");
568      assert!(result.is_err());
569      assert!(matches!(result.unwrap_err(), CommitGenError::InvalidScope(_)));
570   }
571
572   #[test]
573   fn test_validate_scope_invalid_chars() {
574      let result = Scope::new("API/New");
575      assert!(result.is_err());
576      assert!(matches!(result.unwrap_err(), CommitGenError::InvalidScope(_)));
577   }
578
579   #[test]
580   fn test_validate_scope_too_many_segments() {
581      let result = Scope::new("core/api/http");
582      assert!(result.is_err());
583      assert!(result.unwrap_err().to_string().contains("max 2 allowed"));
584   }
585
586   #[test]
587   fn test_validate_scope_valid_single() {
588      let result = Scope::new("api");
589      assert!(result.is_ok());
590   }
591
592   #[test]
593   fn test_validate_scope_valid_two_segments() {
594      let result = Scope::new("core/api");
595      assert!(result.is_ok());
596   }
597
598   #[test]
599   fn test_validate_scope_with_dash_underscore() {
600      let result = Scope::new("core_api/http-client");
601      assert!(result.is_ok());
602   }
603
604   #[test]
605   fn test_validate_total_length_at_guideline() {
606      let config = CommitConfig::default();
607      // type(scope): summary = exactly 72 chars (guideline)
608      // "feat(scope): " = 13 chars, summary = 59 chars, starts with valid verb
609      let summary = format!("added {}", "x".repeat(53));
610      let msg = create_commit("feat", Some("scope"), &summary, vec![]);
611      // Should pass (with info message about being at guideline)
612      assert!(validate_commit_message(&msg, &config).is_ok());
613   }
614
615   #[test]
616   fn test_validate_total_length_at_soft_limit() {
617      let config = CommitConfig::default();
618      // type(scope): summary = exactly 96 chars (soft limit)
619      // "feat(scope): " = 13 chars, summary = 83 chars
620      let summary = format!("added {}", "x".repeat(77));
621      let msg = create_commit("feat", Some("scope"), &summary, vec![]);
622      // Should pass (with warning about soft limit)
623      assert!(validate_commit_message(&msg, &config).is_ok());
624   }
625
626   #[test]
627   fn test_validate_total_length_at_hard_limit() {
628      let config = CommitConfig::default();
629      // type(scope): summary = exactly 128 chars (hard limit)
630      // "feat(scope): " = 13 chars, summary = 115 chars
631      let summary = format!("added {}", "x".repeat(109));
632      let msg = create_commit("feat", Some("scope"), &summary, vec![]);
633      // Should pass (at hard limit)
634      assert!(validate_commit_message(&msg, &config).is_ok());
635   }
636
637   #[test]
638   fn test_validate_total_length_over_hard_limit() {
639      let config = CommitConfig::default();
640      // type(scope): summary > 128 chars (exceeds hard limit)
641      // "feat(scope): " = 13 chars, summary = 116 chars (total 129)
642      let summary = "a".repeat(116);
643      let msg = ConventionalCommit {
644         commit_type: CommitType::new("feat").unwrap(),
645         scope:       Some(Scope::new("scope").unwrap()),
646         summary:     CommitSummary::new_unchecked(&summary, 128).unwrap(),
647         body:        vec![],
648         footers:     vec![],
649      };
650      let result = validate_commit_message(&msg, &config);
651      assert!(result.is_err());
652      assert!(matches!(result.unwrap_err(), CommitGenError::SummaryTooLong { .. }));
653   }
654
655   #[test]
656   fn test_check_type_scope_docs_with_md() {
657      let msg = create_commit("docs", Some("readme"), "updated installation guide", vec![]);
658      let stat = " README.md | 10 +++++++---\n 1 file changed, 7 insertions(+), 3 deletions(-)";
659      // Should not print warning
660      check_type_scope_consistency(&msg, stat);
661   }
662
663   #[test]
664   fn test_check_type_scope_docs_without_md() {
665      let msg = create_commit("docs", None, "updated documentation", vec![]);
666      let stat = " src/main.rs | 10 +++++++---\n 1 file changed, 7 insertions(+), 3 deletions(-)";
667      // Should print warning (but we can't test stderr easily)
668      check_type_scope_consistency(&msg, stat);
669   }
670
671   #[test]
672   fn test_check_type_scope_test_with_test_files() {
673      let msg = create_commit("test", Some("api"), "added integration tests", vec![]);
674      let stat = " tests/integration_test.rs | 50 ++++++++++++++++++++++++++++++++\n";
675      check_type_scope_consistency(&msg, stat);
676   }
677
678   #[test]
679   fn test_check_type_scope_test_without_test_files() {
680      let msg = create_commit("test", None, "added tests", vec![]);
681      let stat = " src/lib.rs | 10 +++++++---\n";
682      check_type_scope_consistency(&msg, stat);
683   }
684
685   #[test]
686   fn test_check_type_scope_refactor_new_files() {
687      let msg = create_commit("refactor", Some("core"), "restructured modules", vec![]);
688      let stat = " create mode 100644 src/new_module.rs\n src/lib.rs | 10 +++++++---\n";
689      check_type_scope_consistency(&msg, stat);
690   }
691
692   #[test]
693   fn test_check_type_scope_ci_with_workflow() {
694      let msg = create_commit("ci", None, "updated github actions", vec![]);
695      let stat = " .github/workflows/ci.yml | 20 ++++++++++++++++++++\n";
696      check_type_scope_consistency(&msg, stat);
697   }
698
699   #[test]
700   fn test_check_type_scope_build_with_cargo() {
701      let msg = create_commit("build", Some("deps"), "updated dependencies", vec![]);
702      let stat = " Cargo.toml | 5 +++--\n Cargo.lock | 150 +++++++++++++++++++\n";
703      check_type_scope_consistency(&msg, stat);
704   }
705
706   #[test]
707   fn test_check_type_scope_perf_with_details() {
708      let msg = create_commit("perf", Some("core"), "optimized batch processing", vec![
709         "reduced allocations by 50% for faster throughput.",
710      ]);
711      let stat = " src/core.rs | 30 +++++++++++++-----------------\n";
712      check_type_scope_consistency(&msg, stat);
713   }
714
715   #[test]
716   fn test_check_type_scope_perf_without_evidence() {
717      let msg = create_commit("perf", None, "changed algorithm", vec![]);
718      let stat = " src/lib.rs | 10 +++++++---\n";
719      check_type_scope_consistency(&msg, stat);
720   }
721
722   #[test]
723   fn test_validate_body_present_tense_warning() {
724      let config = CommitConfig::default();
725      let msg = create_commit("feat", None, "added new feature", vec![
726         "adds support for TLS.",
727         "updates configuration.",
728      ]);
729      // Should succeed but print warnings (we can't easily test stderr)
730      assert!(validate_commit_message(&msg, &config).is_ok());
731   }
732
733   #[test]
734   fn test_validate_body_missing_period_warning() {
735      let config = CommitConfig::default();
736      let msg = create_commit("feat", None, "added new feature", vec![
737         "added support for TLS",
738         "updated configuration",
739      ]);
740      // Should succeed but print warnings
741      assert!(validate_commit_message(&msg, &config).is_ok());
742   }
743
744   #[test]
745   fn test_commit_type_case_normalization() {
746      assert!(CommitType::new("FEAT").is_ok());
747      assert!(CommitType::new("Feat").is_ok());
748      assert!(CommitType::new("feat").is_ok());
749      assert_eq!(CommitType::new("FEAT").unwrap().as_str(), "feat");
750   }
751
752   #[test]
753   fn test_commit_type_all_valid() {
754      let valid_types = [
755         "feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci",
756         "revert",
757      ];
758      for t in &valid_types {
759         assert!(CommitType::new(*t).is_ok(), "Type '{t}' should be valid");
760      }
761   }
762
763   #[test]
764   fn test_summary_length_boundaries() {
765      // Guideline (72) - should pass
766      let summary_72 = "a".repeat(72);
767      assert!(CommitSummary::new(&summary_72, 128).is_ok());
768
769      // Soft limit (96) - should pass
770      let summary_96 = "a".repeat(96);
771      assert!(CommitSummary::new(&summary_96, 128).is_ok());
772
773      // Hard limit (128) - should pass
774      let summary_128 = "a".repeat(128);
775      assert!(CommitSummary::new(&summary_128, 128).is_ok());
776
777      // Over hard limit (129) - should fail
778      let summary_129 = "a".repeat(129);
779      let result = CommitSummary::new(&summary_129, 128);
780      assert!(result.is_err());
781      match result.unwrap_err() {
782         CommitGenError::SummaryTooLong { len, max } => {
783            assert_eq!(len, 129);
784            assert_eq!(max, 128);
785         },
786         _ => panic!("Expected SummaryTooLong error"),
787      }
788   }
789}