Skip to main content

omni_dev/git/
lint.rs

1//! Deterministic, no-AI commit-message lint.
2//!
3//! [`lint_message`] is the pure predicate behind `omni-dev git commit
4//! message lint`. It takes no git handle and does no I/O, so it is
5//! exhaustively unit-testable and shared with
6//! [`crate::git::commit::CommitInfoForAI::run_pre_validation_checks`] /
7//! [`crate::git::commit::refine_message_scope`] via [`parse_subject`] and
8//! the scope-check helpers below — one implementation, multiple consumers
9//! (#1474).
10
11use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::data::check::{CommitIssue, CommitSuggestion, IssueSeverity};
16use crate::data::context::{CommitRules, ScopeDefinition};
17
18/// Matches `type(scope)!: description`, `type(scope): description`,
19/// `type: description`, and the lenient legacy `type!(scope): description`
20/// form (accepted for backward compatibility, though not the documented
21/// form). This is the corrected #1473 pattern from the start: the canonical
22/// breaking-change `!` is placed *after* the closing paren, not before it.
23static SUBJECT_RE: LazyLock<Regex> = LazyLock::new(|| {
24    #[allow(clippy::expect_used)] // compile-time constant pattern
25    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
31/// Matches invalid comma spacing within a (possibly multi-part) scope
32/// segment: any whitespace before a comma, or two-or-more spaces after one.
33/// Mirrors `.omni-dev/commit-guidelines.md`'s "Two or more spaces after a
34/// comma, or any whitespace before a comma, is not permitted."
35static BAD_SCOPE_COMMA_RE: LazyLock<Regex> = LazyLock::new(|| {
36    #[allow(clippy::expect_used)] // compile-time constant pattern
37    Regex::new(r"\s,|,\s{2,}").expect("BAD_SCOPE_COMMA_RE is a valid compile-time constant regex")
38});
39
40/// A parsed conventional-commit subject line.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ParsedSubject<'a> {
43    /// The `type` token, e.g. `feat`.
44    pub commit_type: &'a str,
45    /// The raw `(scope)` contents, un-split on commas, when present and
46    /// non-empty.
47    pub scope: Option<&'a str>,
48    /// Everything after the `: ` separator.
49    pub description: &'a str,
50    /// Whether a breaking-change `!` marker was present, either side of the
51    /// scope parens.
52    pub breaking: bool,
53}
54
55/// Parses a commit subject line (the first line of a commit message) into
56/// its conventional-commit parts. Returns `None` when the subject doesn't
57/// match `type(scope)?!?: description` in any accepted form.
58pub 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
75/// Returns `true` when a multi-scope segment's comma spacing is acceptable
76/// (`,` or `, ` only — no space before a comma, no 2+ spaces after one).
77pub(crate) fn scope_comma_format_ok(scope: &str) -> bool {
78    !BAD_SCOPE_COMMA_RE.is_match(scope)
79}
80
81/// Returns `true` when every comma-separated part of `scope` is a member of
82/// `valid_scopes` (already ecosystem-merged by the caller — see
83/// [`crate::claude::context::load_project_scopes`]). An empty `valid_scopes`
84/// list is treated as "anything goes" (no `scopes.yaml` configured),
85/// matching the pre-existing `run_pre_validation_checks` behaviour.
86pub(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
90/// Returns the comma-separated parts of `scope` that are not present in
91/// `valid_scopes`, trimmed.
92fn 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
109/// Returns `true` when `line` starts with `footer` (case-insensitive)
110/// immediately followed by `:`, e.g. `line = "Co-Authored-By: x"`,
111/// `footer = "Co-Authored-By"`. Byte-slices via `str::get` rather than
112/// direct indexing so a `footer` longer than `line`, or a split that lands
113/// mid-codepoint, returns `false` instead of panicking.
114fn 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
121/// Runs every deterministic rule against `message`.
122///
123/// Returns the issues found (empty = passes cleanly). Pure — no git, no
124/// I/O; `rules` and `valid_scopes` are supplied by the caller (already
125/// resolved/merged, e.g. via [`crate::claude::context::load_project_scopes`]
126/// / [`crate::claude::context::load_commit_rules`]).
127pub 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    // Subject length — independent of whether the subject otherwise parses.
137    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    // Blank line after subject when a body is present — independent of
151    // whether the subject otherwise parses.
152    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    // Forbidden footers — independent of whether the subject otherwise
164    // parses; scanned across every line of the message.
165    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/// Returns `true` when `issues` contains no [`IssueSeverity::Error`] entry —
263/// the same pass/fail split [`crate::data::check::CheckReport::exit_code`]
264/// uses.
265#[must_use]
266pub fn passes(issues: &[CommitIssue]) -> bool {
267    !issues.iter().any(|i| i.severity == IssueSeverity::Error)
268}
269
270/// Deterministically suggests a corrected message for an `unknown-scope` or
271/// `missing-scope` issue.
272///
273/// Resolves a scope from `files` against `valid_scopes` via
274/// [`crate::git::commit::resolve_scope`] — no AI, no network. Returns `None`
275/// when there's nothing to suggest: neither issue is present, the subject
276/// doesn't parse, `resolve_scope` can't resolve anything from `files`, or
277/// the resolved scope doesn't actually change the message.
278///
279/// An existing-but-wrong scope (`unknown-scope`) is *replaced* via
280/// [`crate::git::commit::refine_message_scope`]. A missing scope
281/// (`missing-scope`, only reachable when `rules.require_scope` is set) is
282/// *inserted* between the type (and any breaking-change `!`) and the colon —
283/// `refine_message_scope` is deliberately not reused for this case, since its
284/// contract is "replace an existing scope," never "add one."
285pub 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        // Canonical placement: the breaking-change `!` goes after the scope
310        // parens, matching `SUBJECT_RE`'s documented form (see the module
311        // doc comment on `refine_message_scope`'s sibling regex in this
312        // file) — not before them, which is only accepted leniently on
313        // input.
314        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    // ── parse_subject ────────────────────────────────────────────────
356
357    #[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        // #1473: `!` after the closing paren is the documented form and
376        // must match.
377        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    // ── lint_message: Rule 1 — format ───────────────────────────────
412
413    #[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    // ── lint_message: Rule 2 — types ────────────────────────────────
432
433    #[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    // ── lint_message: Rule 3 — scope comma format ───────────────────
451
452    #[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    // ── lint_message: Rule 4 — scope validity ───────────────────────
482
483    #[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        // No scopes.yaml configured — mirrors run_pre_validation_checks.
505        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    // ── lint_message: Rule 5 — require_scope toggle ─────────────────
511
512    #[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    // ── lint_message: Rules 6/7 — subject line style ────────────────
543
544    #[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        // Info-only issues never fail the commit.
559        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    // ── lint_message: Rule 8 — subject length ───────────────────────
583
584    #[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        // Same message: passes at 80, fails at 72. Built programmatically
606        // (rather than hand-counted) to land reliably between the two.
607        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    // ── lint_message: Rule 9 — blank line after subject ─────────────
629
630    #[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        // The 6413fd73 case: a clean subject, non-blank line 2 folds the
647        // whole first paragraph into git's %s.
648        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    // ── lint_message: Rule 10 — forbidden footers ───────────────────
661
662    #[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        // A warning alone must not fail the commit.
686        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    // ── ecosystem-default scopes (the case most likely to be got wrong) ──
701
702    #[test]
703    fn ecosystem_default_scopes_pass_even_when_absent_from_scopes_yaml() {
704        // Mirrors merge_ecosystem_scopes' Rust defaults: lib, cargo, core,
705        // test are accepted even though a project's scopes.yaml lists
706        // neither — the caller is responsible for merging them in before
707        // calling lint_message (this test simulates that merged list).
708        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    // ── real positive/negative fixtures from the issue ──────────────
721
722    #[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    // ── suggest_scope_fix ────────────────────────────────────────────
745
746    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        // The joined scope must itself pass the comma-format rule.
864        let rechecked = lint_message(&suggestion.message, &CommitRules::default(), &valid);
865        assert!(issue_rules("scope-comma-format", &rechecked).is_empty());
866    }
867}