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