1use std::process::Command;
2
3use crate::{
4 config::CommitConfig,
5 error::{CommitGenError, Result},
6 style::{self, icons},
7 types::ConventionalCommit,
8};
9
10const CODE_EXTENSIONS: &[&str] = &[
12 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
14 "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",
43];
44
45fn is_code_extension(ext: &str) -> bool {
47 CODE_EXTENSIONS.iter().any(|&e| e.eq_ignore_ascii_case(ext))
48}
49
50fn 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
70fn normalize_name(name: &str) -> String {
72 name.to_lowercase().replace(['-', '_'], "")
73}
74
75pub fn is_past_tense_verb(word: &str) -> bool {
77 if word.ends_with("ed") {
79 const BLOCKLIST: &[&str] = &["hundred", "thousand", "red", "bed", "wed", "shed"];
81 return !BLOCKLIST.contains(&word);
82 }
83
84 if word.len() >= 4 && word.ends_with('d') {
87 let before_d = &word[word.len() - 2..word.len() - 1];
88 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 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
157pub fn validate_commit_message(msg: &ConventionalCommit, config: &CommitConfig) -> Result<()> {
159 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 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 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 if msg.summary.as_str().trim().is_empty() {
197 return Err(CommitGenError::ValidationError("Summary cannot be empty".to_string()));
198 }
199
200 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 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 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 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 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 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 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 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 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 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 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
354pub fn check_type_scope_consistency(msg: &ConventionalCommit, stat: &str) {
356 let commit_type = msg.commit_type.as_str();
357
358 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let summary = format!("added {}", "x".repeat(53));
691 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
692 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 let summary = format!("added {}", "x".repeat(77));
702 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
703 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 let summary = format!("added {}", "x".repeat(109));
713 let msg = create_commit("feat", Some("scope"), &summary, vec![]);
714 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 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 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 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 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 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 let summary_72 = "a".repeat(72);
848 assert!(CommitSummary::new(&summary_72, 128).is_ok());
849
850 let summary_96 = "a".repeat(96);
852 assert!(CommitSummary::new(&summary_96, 128).is_ok());
853
854 let summary_128 = "a".repeat(128);
856 assert!(CommitSummary::new(&summary_128, 128).is_ok());
857
858 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}