1use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::data::check::{CommitIssue, CommitSuggestion, IssueSeverity};
16use crate::data::context::{CommitRules, ScopeDefinition};
17
18static SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
24 #[allow(clippy::expect_used)] Regex::new(
26 r"^(?P<type>[a-z]+)(?P<bang1>!)?(?:\((?P<scope>[^)]*)\))?(?P<bang2>!)?: (?P<desc>.*)$",
27 )
28 .expect("SUBJECT_RE is a valid compile-time constant regex")
29});
30
31static BAD_SCOPE_COMMA_RE: LazyLock<Regex> = LazyLock::new(|| {
36 #[allow(clippy::expect_used)] Regex::new(r"\s,|,\s{2,}").expect("BAD_SCOPE_COMMA_RE is a valid compile-time constant regex")
38});
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ParsedSubject<'a> {
43 pub commit_type: &'a str,
45 pub scope: Option<&'a str>,
48 pub description: &'a str,
50 pub breaking: bool,
53}
54
55pub fn parse_subject(first_line: &str) -> Option<ParsedSubject<'_>> {
59 let caps = SUBJECT_RE.captures(first_line)?;
60 let commit_type = caps.name("type")?.as_str();
61 let description = caps.name("desc")?.as_str();
62 let breaking = caps.name("bang1").is_some() || caps.name("bang2").is_some();
63 let scope = caps
64 .name("scope")
65 .map(|m| m.as_str())
66 .filter(|s| !s.is_empty());
67 Some(ParsedSubject {
68 commit_type,
69 scope,
70 description,
71 breaking,
72 })
73}
74
75pub(crate) fn scope_comma_format_ok(scope: &str) -> bool {
78 !BAD_SCOPE_COMMA_RE.is_match(scope)
79}
80
81pub(crate) fn scope_parts_all_valid(scope: &str, valid_scopes: &[ScopeDefinition]) -> bool {
87 valid_scopes.is_empty() || invalid_scope_parts(scope, valid_scopes).is_empty()
88}
89
90fn invalid_scope_parts<'a>(scope: &'a str, valid_scopes: &[ScopeDefinition]) -> Vec<&'a str> {
93 scope
94 .split(',')
95 .map(str::trim)
96 .filter(|part| !valid_scopes.iter().any(|s| s.name == *part))
97 .collect()
98}
99
100fn issue(severity: IssueSeverity, section: &str, rule: &str, explanation: String) -> CommitIssue {
101 CommitIssue {
102 severity,
103 section: section.to_string(),
104 rule: rule.to_string(),
105 explanation,
106 }
107}
108
109fn line_has_forbidden_footer(line: &str, footer: &str) -> bool {
115 let Some(prefix) = line.get(..footer.len()) else {
116 return false;
117 };
118 prefix.eq_ignore_ascii_case(footer) && line[footer.len()..].starts_with(':')
119}
120
121pub fn lint_message(
128 message: &str,
129 rules: &CommitRules,
130 valid_scopes: &[ScopeDefinition],
131) -> Vec<CommitIssue> {
132 let mut issues = Vec::new();
133
134 let first_line = message.lines().next().unwrap_or("");
135
136 let len = first_line.chars().count();
138 if len > rules.subject_max_len {
139 issues.push(issue(
140 IssueSeverity::Error,
141 "Subject Line",
142 "subject-length",
143 format!(
144 "Subject is {len} characters, which exceeds the {}-character limit",
145 rules.subject_max_len
146 ),
147 ));
148 }
149
150 if let Some(second_line) = message.lines().nth(1) {
153 if !second_line.is_empty() {
154 issues.push(issue(
155 IssueSeverity::Error,
156 "Commit Format",
157 "blank-line-after-subject",
158 "Line 2 must be blank when the commit message has a body".to_string(),
159 ));
160 }
161 }
162
163 for line in message.lines() {
166 for footer in &rules.forbidden_footers {
167 if line_has_forbidden_footer(line, footer) {
168 issues.push(issue(
169 IssueSeverity::Warning,
170 "Body Guidelines",
171 "forbidden-footer",
172 format!("'{footer}' footer is not permitted"),
173 ));
174 }
175 }
176 }
177
178 let Some(parsed) = parse_subject(first_line) else {
179 issues.push(issue(
180 IssueSeverity::Error,
181 "Commit Format",
182 "format",
183 "Subject must match '<type>(<scope>): <description>'".to_string(),
184 ));
185 return issues;
186 };
187
188 if !rules.types.iter().any(|t| t == parsed.commit_type) {
189 issues.push(issue(
190 IssueSeverity::Error,
191 "Types",
192 "unknown-type",
193 format!(
194 "'{}' is not one of the accepted types: {}",
195 parsed.commit_type,
196 rules.types.join(", ")
197 ),
198 ));
199 }
200
201 match parsed.scope {
202 Some(scope) => {
203 if !scope_comma_format_ok(scope) {
204 issues.push(issue(
205 IssueSeverity::Error,
206 "Scopes",
207 "scope-comma-format",
208 format!("Scope '{scope}' must separate multiple scopes with ',' or ', ' only"),
209 ));
210 }
211
212 let invalid = invalid_scope_parts(scope, valid_scopes);
213 if !valid_scopes.is_empty() && !invalid.is_empty() {
214 issues.push(issue(
215 IssueSeverity::Error,
216 "Scopes",
217 "unknown-scope",
218 format!(
219 "Scope(s) not in the valid scopes list: {}",
220 invalid.join(", ")
221 ),
222 ));
223 }
224 }
225 None if rules.require_scope => {
226 issues.push(issue(
227 IssueSeverity::Error,
228 "Scopes",
229 "missing-scope",
230 "A scope is required, e.g. 'type(scope): description'".to_string(),
231 ));
232 }
233 None => {}
234 }
235
236 if parsed
237 .description
238 .chars()
239 .next()
240 .is_some_and(char::is_uppercase)
241 {
242 issues.push(issue(
243 IssueSeverity::Info,
244 "Subject Line Style",
245 "lowercase-description",
246 "Description should start with a lowercase letter".to_string(),
247 ));
248 }
249
250 if first_line.trim_end().ends_with('.') {
251 issues.push(issue(
252 IssueSeverity::Info,
253 "Subject Line Style",
254 "no-trailing-period",
255 "Subject should not end with a period".to_string(),
256 ));
257 }
258
259 issues
260}
261
262#[must_use]
266pub fn passes(issues: &[CommitIssue]) -> bool {
267 !issues.iter().any(|i| i.severity == IssueSeverity::Error)
268}
269
270pub fn suggest_scope_fix(
286 message: &str,
287 files: &[&str],
288 valid_scopes: &[ScopeDefinition],
289 issues: &[CommitIssue],
290) -> Option<CommitSuggestion> {
291 if !issues
292 .iter()
293 .any(|i| i.rule == "unknown-scope" || i.rule == "missing-scope")
294 {
295 return None;
296 }
297
298 let first_line = message.lines().next().unwrap_or("");
299 let parsed = parse_subject(first_line)?;
300
301 let corrected = if parsed.scope.is_some() {
302 let refined = crate::git::commit::refine_message_scope(message, files, valid_scopes);
303 if refined == message {
304 return None;
305 }
306 refined
307 } else {
308 let resolved = crate::git::commit::resolve_scope(files, valid_scopes)?;
309 let bang = if parsed.breaking { "!" } else { "" };
315 let new_first_line = format!(
316 "{}({resolved}){bang}: {}",
317 parsed.commit_type, parsed.description
318 );
319 match message.split_once('\n') {
320 Some((_, rest)) => format!("{new_first_line}\n{rest}"),
321 None => new_first_line,
322 }
323 };
324
325 Some(CommitSuggestion {
326 message: corrected,
327 explanation: "Deterministically resolved from the commit's changed files against the \
328 project's scope definitions (no AI)."
329 .to_string(),
330 })
331}
332
333#[cfg(test)]
334#[allow(clippy::unwrap_used, clippy::expect_used)]
335mod tests {
336 use super::*;
337
338 fn scope(name: &str) -> ScopeDefinition {
339 ScopeDefinition {
340 name: name.to_string(),
341 description: String::new(),
342 examples: vec![],
343 file_patterns: vec![],
344 }
345 }
346
347 fn scopes(names: &[&str]) -> Vec<ScopeDefinition> {
348 names.iter().map(|n| scope(n)).collect()
349 }
350
351 fn issue_rules<'a>(rule: &str, issues: &'a [CommitIssue]) -> Vec<&'a CommitIssue> {
352 issues.iter().filter(|i| i.rule == rule).collect()
353 }
354
355 #[test]
358 fn parse_subject_simple() {
359 let p = parse_subject("feat(cli): add twiddle contextual options").unwrap();
360 assert_eq!(p.commit_type, "feat");
361 assert_eq!(p.scope, Some("cli"));
362 assert_eq!(p.description, "add twiddle contextual options");
363 assert!(!p.breaking);
364 }
365
366 #[test]
367 fn parse_subject_scope_less() {
368 let p = parse_subject("docs: clarify dry_run helper scope").unwrap();
369 assert_eq!(p.commit_type, "docs");
370 assert_eq!(p.scope, None);
371 }
372
373 #[test]
374 fn parse_subject_canonical_breaking_change() {
375 let p = parse_subject("feat(cli)!: change commit check output format").unwrap();
378 assert_eq!(p.commit_type, "feat");
379 assert_eq!(p.scope, Some("cli"));
380 assert!(p.breaking);
381 }
382
383 #[test]
384 fn parse_subject_lenient_legacy_breaking_change() {
385 let p = parse_subject("feat!(cli): add thing").unwrap();
386 assert_eq!(p.scope, Some("cli"));
387 assert!(p.breaking);
388 }
389
390 #[test]
391 fn parse_subject_both_bangs_accepted_leniently() {
392 assert!(parse_subject("feat!(cli)!: add thing").is_some());
393 }
394
395 #[test]
396 fn parse_subject_multi_scope() {
397 let p = parse_subject("feat(git,data): integrate branch analysis").unwrap();
398 assert_eq!(p.scope, Some("git,data"));
399 }
400
401 #[test]
402 fn parse_subject_rejects_missing_colon_space() {
403 assert!(parse_subject("feat(cli):no space").is_none());
404 }
405
406 #[test]
407 fn parse_subject_rejects_garbage() {
408 assert!(parse_subject("this is not a conventional commit").is_none());
409 }
410
411 #[test]
414 fn format_valid_passes() {
415 let rules = CommitRules::default();
416 let issues = lint_message("feat(cli): add twiddle contextual options", &rules, &[]);
417 assert!(issue_rules("format", &issues).is_empty());
418 assert!(passes(&issues));
419 }
420
421 #[test]
422 fn format_invalid_flags_error() {
423 let rules = CommitRules::default();
424 let issues = lint_message("not a conventional commit at all", &rules, &[]);
425 let found = issue_rules("format", &issues);
426 assert_eq!(found.len(), 1);
427 assert_eq!(found[0].severity, IssueSeverity::Error);
428 assert_eq!(found[0].section, "Commit Format");
429 }
430
431 #[test]
434 fn known_type_passes() {
435 let rules = CommitRules::default();
436 let issues = lint_message("feat(cli): add thing", &rules, &[]);
437 assert!(issue_rules("unknown-type", &issues).is_empty());
438 }
439
440 #[test]
441 fn unknown_type_flags_error() {
442 let rules = CommitRules::default();
443 let issues = lint_message("feature(cli): add thing", &rules, &[]);
444 let found = issue_rules("unknown-type", &issues);
445 assert_eq!(found.len(), 1);
446 assert_eq!(found[0].severity, IssueSeverity::Error);
447 assert_eq!(found[0].section, "Types");
448 }
449
450 #[test]
453 fn scope_comma_format_valid_passes() {
454 let rules = CommitRules::default();
455 let valid = scopes(&["cli", "claude"]);
456 let issues = lint_message("feat(cli,claude): add thing", &rules, &valid);
457 assert!(issue_rules("scope-comma-format", &issues).is_empty());
458
459 let issues_spaced = lint_message("feat(cli, claude): add thing", &rules, &valid);
460 assert!(issue_rules("scope-comma-format", &issues_spaced).is_empty());
461 }
462
463 #[test]
464 fn scope_comma_two_spaces_after_flags_error() {
465 let rules = CommitRules::default();
466 let valid = scopes(&["a", "b"]);
467 let issues = lint_message("feat(a, b): add thing", &rules, &valid);
468 let found = issue_rules("scope-comma-format", &issues);
469 assert_eq!(found.len(), 1);
470 assert_eq!(found[0].severity, IssueSeverity::Error);
471 }
472
473 #[test]
474 fn scope_comma_space_before_flags_error() {
475 let rules = CommitRules::default();
476 let valid = scopes(&["a", "b"]);
477 let issues = lint_message("feat(a ,b): add thing", &rules, &valid);
478 assert_eq!(issue_rules("scope-comma-format", &issues).len(), 1);
479 }
480
481 #[test]
484 fn valid_scope_passes() {
485 let rules = CommitRules::default();
486 let valid = scopes(&["cli"]);
487 let issues = lint_message("feat(cli): add thing", &rules, &valid);
488 assert!(issue_rules("unknown-scope", &issues).is_empty());
489 }
490
491 #[test]
492 fn undefined_scope_flags_error() {
493 let rules = CommitRules::default();
494 let valid = scopes(&["cli"]);
495 let issues = lint_message("feat(bogus): add thing", &rules, &valid);
496 let found = issue_rules("unknown-scope", &issues);
497 assert_eq!(found.len(), 1);
498 assert_eq!(found[0].severity, IssueSeverity::Error);
499 assert_eq!(found[0].section, "Scopes");
500 }
501
502 #[test]
503 fn empty_valid_scopes_list_accepts_anything() {
504 let rules = CommitRules::default();
506 let issues = lint_message("feat(anything): add thing", &rules, &[]);
507 assert!(issue_rules("unknown-scope", &issues).is_empty());
508 }
509
510 #[test]
513 fn missing_scope_allowed_by_default() {
514 let rules = CommitRules::default();
515 assert!(!rules.require_scope);
516 let issues = lint_message("docs: update issue references", &rules, &[]);
517 assert!(issue_rules("missing-scope", &issues).is_empty());
518 }
519
520 #[test]
521 fn missing_scope_flagged_when_required() {
522 let rules = CommitRules {
523 require_scope: true,
524 ..CommitRules::default()
525 };
526 let issues = lint_message("docs: update issue references", &rules, &[]);
527 let found = issue_rules("missing-scope", &issues);
528 assert_eq!(found.len(), 1);
529 assert_eq!(found[0].severity, IssueSeverity::Error);
530 }
531
532 #[test]
533 fn present_scope_not_flagged_when_required() {
534 let rules = CommitRules {
535 require_scope: true,
536 ..CommitRules::default()
537 };
538 let issues = lint_message("docs(docs): update issue references", &rules, &[]);
539 assert!(issue_rules("missing-scope", &issues).is_empty());
540 }
541
542 #[test]
545 fn lowercase_description_passes() {
546 let rules = CommitRules::default();
547 let issues = lint_message("feat(cli): add thing", &rules, &[]);
548 assert!(issue_rules("lowercase-description", &issues).is_empty());
549 }
550
551 #[test]
552 fn uppercase_description_flags_info() {
553 let rules = CommitRules::default();
554 let issues = lint_message("feat(cli): Add thing", &rules, &[]);
555 let found = issue_rules("lowercase-description", &issues);
556 assert_eq!(found.len(), 1);
557 assert_eq!(found[0].severity, IssueSeverity::Info);
558 assert!(passes(&found_as_issues(&issues, "lowercase-description")));
560 }
561
562 fn found_as_issues(issues: &[CommitIssue], rule: &str) -> Vec<CommitIssue> {
563 issues.iter().filter(|i| i.rule == rule).cloned().collect()
564 }
565
566 #[test]
567 fn no_trailing_period_passes() {
568 let rules = CommitRules::default();
569 let issues = lint_message("feat(cli): add thing", &rules, &[]);
570 assert!(issue_rules("no-trailing-period", &issues).is_empty());
571 }
572
573 #[test]
574 fn trailing_period_flags_info() {
575 let rules = CommitRules::default();
576 let issues = lint_message("feat(cli): add thing.", &rules, &[]);
577 let found = issue_rules("no-trailing-period", &issues);
578 assert_eq!(found.len(), 1);
579 assert_eq!(found[0].severity, IssueSeverity::Info);
580 }
581
582 #[test]
585 fn subject_within_limit_passes() {
586 let rules = CommitRules::default();
587 let issues = lint_message("feat(cli): add thing", &rules, &[]);
588 assert!(issue_rules("subject-length", &issues).is_empty());
589 }
590
591 #[test]
592 fn subject_over_limit_flags_error() {
593 let rules = CommitRules::default();
594 let long_desc = "x".repeat(rules.subject_max_len);
595 let msg = format!("feat(cli): {long_desc}");
596 assert!(msg.chars().count() > rules.subject_max_len);
597 let issues = lint_message(&msg, &rules, &[]);
598 let found = issue_rules("subject-length", &issues);
599 assert_eq!(found.len(), 1);
600 assert_eq!(found[0].severity, IssueSeverity::Error);
601 }
602
603 #[test]
604 fn configured_limit_is_honoured() {
605 let prefix = "feat(cli): ";
608 let desc = "x".repeat(73 - prefix.len());
609 let msg = format!("{prefix}{desc}");
610 assert_eq!(msg.chars().count(), 73);
611
612 let rules_80 = CommitRules {
613 subject_max_len: 80,
614 ..CommitRules::default()
615 };
616 assert!(issue_rules("subject-length", &lint_message(&msg, &rules_80, &[])).is_empty());
617
618 let rules_72 = CommitRules {
619 subject_max_len: 72,
620 ..CommitRules::default()
621 };
622 assert_eq!(
623 issue_rules("subject-length", &lint_message(&msg, &rules_72, &[])).len(),
624 1
625 );
626 }
627
628 #[test]
631 fn blank_line_2_with_body_passes() {
632 let rules = CommitRules::default();
633 let issues = lint_message("feat(cli): add thing\n\nMore detail here.", &rules, &[]);
634 assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
635 }
636
637 #[test]
638 fn subject_only_no_body_passes() {
639 let rules = CommitRules::default();
640 let issues = lint_message("feat(cli): add thing", &rules, &[]);
641 assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
642 }
643
644 #[test]
645 fn non_blank_line_2_flags_error() {
646 let rules = CommitRules::default();
649 let issues = lint_message(
650 "fix(git): handle detached HEAD in branch analysis\nThis line should be blank.",
651 &rules,
652 &[],
653 );
654 let found = issue_rules("blank-line-after-subject", &issues);
655 assert_eq!(found.len(), 1);
656 assert_eq!(found[0].severity, IssueSeverity::Error);
657 assert_eq!(found[0].section, "Commit Format");
658 }
659
660 #[test]
663 fn no_footer_passes() {
664 let rules = CommitRules::default();
665 let issues = lint_message(
666 "feat(cli): add thing\n\nBody text.\n\nCloses #123",
667 &rules,
668 &[],
669 );
670 assert!(issue_rules("forbidden-footer", &issues).is_empty());
671 }
672
673 #[test]
674 fn co_authored_by_footer_flags_warning() {
675 let rules = CommitRules::default();
676 let issues = lint_message(
677 "feat(cli): add thing\n\nBody text.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
678 &rules,
679 &[],
680 );
681 let found = issue_rules("forbidden-footer", &issues);
682 assert_eq!(found.len(), 1);
683 assert_eq!(found[0].severity, IssueSeverity::Warning);
684 assert_eq!(found[0].section, "Body Guidelines");
685 assert!(passes(&found_as_issues(&issues, "forbidden-footer")));
687 }
688
689 #[test]
690 fn co_authored_by_footer_case_insensitive() {
691 let rules = CommitRules::default();
692 let issues = lint_message(
693 "feat(cli): add thing\n\nco-authored-by: someone",
694 &rules,
695 &[],
696 );
697 assert_eq!(issue_rules("forbidden-footer", &issues).len(), 1);
698 }
699
700 #[test]
703 fn ecosystem_default_scopes_pass_even_when_absent_from_scopes_yaml() {
704 let rules = CommitRules::default();
709 let merged = scopes(&["cli", "git", "lib", "cargo", "core", "test"]);
710 for s in ["lib", "cargo", "core", "test"] {
711 let msg = format!("chore({s}): bump dependency");
712 let issues = lint_message(&msg, &rules, &merged);
713 assert!(
714 issue_rules("unknown-scope", &issues).is_empty(),
715 "ecosystem default scope {s} should be accepted"
716 );
717 }
718 }
719
720 #[test]
723 fn positive_fixtures_from_history_pass_cleanly() {
724 let rules = CommitRules::default();
725 let valid = scopes(&["cli", "claude", "docs"]);
726 for msg in [
727 "feat(cli,claude): add twiddle contextual options",
728 "feat(cli)!: change commit check output format",
729 "docs(docs): add architecture overview document",
730 ] {
731 let issues = lint_message(msg, &rules, &valid);
732 assert!(passes(&issues), "{msg:?} should pass cleanly: {issues:?}");
733 }
734 }
735
736 #[test]
737 fn multi_scope_breaking_change_records_both_checks() {
738 let rules = CommitRules::default();
739 let valid = scopes(&["cli", "claude"]);
740 let issues = lint_message("feat(cli,claude)!: add thing", &rules, &valid);
741 assert!(passes(&issues));
742 }
743
744 fn scope_with_patterns(name: &str, patterns: &[&str]) -> ScopeDefinition {
747 ScopeDefinition {
748 name: name.to_string(),
749 description: String::new(),
750 examples: vec![],
751 file_patterns: patterns.iter().map(|p| (*p).to_string()).collect(),
752 }
753 }
754
755 #[test]
756 fn suggest_scope_fix_replaces_unknown_scope() {
757 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml", "Cargo.lock"])];
758 let message = "chore(deps): bump the rust-minor-patch group";
759 let issues = lint_message(message, &CommitRules::default(), &valid);
760 let suggestion =
761 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
762 assert_eq!(
763 suggestion.message,
764 "chore(cargo): bump the rust-minor-patch group"
765 );
766 }
767
768 #[test]
769 fn suggest_scope_fix_inserts_missing_scope() {
770 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
771 let rules = CommitRules {
772 require_scope: true,
773 ..CommitRules::default()
774 };
775 let message = "chore: bump deps";
776 let issues = lint_message(message, &rules, &valid);
777 let suggestion =
778 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
779 assert_eq!(suggestion.message, "chore(cargo): bump deps");
780 }
781
782 #[test]
783 fn suggest_scope_fix_inserts_missing_scope_preserves_breaking_bang() {
784 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
785 let rules = CommitRules {
786 require_scope: true,
787 ..CommitRules::default()
788 };
789 let message = "chore!: bump deps";
790 let issues = lint_message(message, &rules, &valid);
791 let suggestion =
792 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
793 assert_eq!(suggestion.message, "chore(cargo)!: bump deps");
794 }
795
796 #[test]
797 fn suggest_scope_fix_inserts_missing_scope_preserves_body() {
798 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
799 let rules = CommitRules {
800 require_scope: true,
801 ..CommitRules::default()
802 };
803 let message = "chore: bump deps\n\nSome body text.";
804 let issues = lint_message(message, &rules, &valid);
805 let suggestion =
806 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
807 assert_eq!(
808 suggestion.message,
809 "chore(cargo): bump deps\n\nSome body text."
810 );
811 }
812
813 #[test]
814 fn suggest_scope_fix_preserves_body() {
815 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
816 let message = "chore(deps): bump deps\n\nSome body text.";
817 let issues = lint_message(message, &CommitRules::default(), &valid);
818 let suggestion =
819 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
820 assert_eq!(
821 suggestion.message,
822 "chore(cargo): bump deps\n\nSome body text."
823 );
824 }
825
826 #[test]
827 fn suggest_scope_fix_no_issue_no_suggestion() {
828 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
829 let message = "chore(cargo): bump deps";
830 let issues = lint_message(message, &CommitRules::default(), &valid);
831 assert!(issues.is_empty());
832 assert!(suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).is_none());
833 }
834
835 #[test]
836 fn suggest_scope_fix_no_matching_scope_defs_no_suggestion() {
837 let valid = vec![scope_with_patterns("docs", &["docs/**"])];
838 let message = "chore(deps): bump deps";
839 let issues = lint_message(message, &CommitRules::default(), &valid);
840 assert!(suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).is_none());
841 }
842
843 #[test]
844 fn suggest_scope_fix_format_broken_subject_no_suggestion() {
845 let valid = vec![scope_with_patterns("cargo", &["Cargo.toml"])];
846 let message = "not a conventional commit subject";
847 let issues = lint_message(message, &CommitRules::default(), &valid);
848 assert_eq!(issue_rules("format", &issues).len(), 1);
849 assert!(suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).is_none());
850 }
851
852 #[test]
853 fn suggest_scope_fix_tied_scopes_join_with_valid_comma_spacing() {
854 let valid = vec![
855 scope_with_patterns("cargo", &["Cargo.toml"]),
856 scope_with_patterns("lib", &["Cargo.toml"]),
857 ];
858 let message = "chore(deps): bump deps";
859 let issues = lint_message(message, &CommitRules::default(), &valid);
860 let suggestion =
861 suggest_scope_fix(message, &["Cargo.toml"], &valid, &issues).expect("should suggest");
862 assert_eq!(suggestion.message, "chore(cargo, lib): bump deps");
863 let rechecked = lint_message(&suggestion.message, &CommitRules::default(), &valid);
865 assert!(issue_rules("scope-comma-format", &rechecked).is_empty());
866 }
867}