1use crate::{
2 config::CommitConfig,
3 error::{CommitGenError, Result},
4 git::git_command,
5 style::{self, icons},
6 types::ConventionalCommit,
7};
8
9const CODE_EXTENSIONS: &[&str] = &[
11 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
13 "java", "kt", "kts", "scala", "groovy", "clj", "cljs", "cs", "fs", "vb", "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", "py", "pyx", "pxd", "pyi", "rb", "rake", "gemspec", "php", "go", "swift", "m", "mm", "lua", "sh", "bash", "zsh", "fish", "pl", "pm", "hs", "lhs", "ml", "mli", "elm", "ex", "exs", "erl", "hrl", "lisp", "cl", "el", "scm", "rkt", "jl", "r", "dart", "cr", "d", "f", "f90", "f95", "f03", "f08", "ada", "adb", "ads", "cob", "cbl", "asm", "s", "sql", "plsql", "pro", "re", "rei", "nix", "tf", "hcl", "sol", "move", "cairo",
42];
43
44fn is_code_extension(ext: &str) -> bool {
46 CODE_EXTENSIONS.iter().any(|&e| e.eq_ignore_ascii_case(ext))
47}
48
49fn 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
69fn normalize_name(name: &str) -> String {
71 name.to_lowercase().replace(['-', '_'], "")
72}
73
74pub fn is_past_tense_verb(word: &str) -> bool {
76 if word.ends_with("ed") {
78 const BLOCKLIST: &[&str] = &["hundred", "thousand", "red", "bed", "wed", "shed"];
80 return !BLOCKLIST.contains(&word);
81 }
82
83 if word.len() >= 4 && word.ends_with('d') {
86 let before_d = &word[word.len() - 2..word.len() - 1];
87 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 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
156pub fn validate_commit_message(msg: &ConventionalCommit, config: &CommitConfig) -> Result<()> {
158 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 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 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 if msg.summary.as_str().trim().is_empty() {
198 return Err(CommitGenError::ValidationError("Summary cannot be empty".to_string()));
199 }
200
201 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 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 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 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 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 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 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 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 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 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 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
355pub fn check_type_scope_consistency(msg: &ConventionalCommit, stat: &str) {
357 let commit_type = msg.commit_type.as_str();
358
359 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let summary = format!("added {}", "x".repeat(53));
692 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
693 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 let summary = format!("added {}", "x".repeat(77));
703 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
704 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 let summary = format!("added {}", "x".repeat(109));
714 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
715 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 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 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 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 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 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 let summary_72 = "a".repeat(72);
849 assert!(CommitSummary::new(&summary_72, 128).is_ok());
850
851 let summary_96 = "a".repeat(96);
853 assert!(CommitSummary::new(&summary_96, 128).is_ok());
854
855 let summary_128 = "a".repeat(128);
857 assert!(CommitSummary::new(&summary_128, 128).is_ok());
858
859 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}