Skip to main content

llm_git/
validation.rs

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