llm_git/
validation.rs

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