1use std::sync::LazyLock;
12
13use regex::Regex;
14
15use crate::data::check::{CommitIssue, 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
270#[cfg(test)]
271#[allow(clippy::unwrap_used, clippy::expect_used)]
272mod tests {
273 use super::*;
274
275 fn scope(name: &str) -> ScopeDefinition {
276 ScopeDefinition {
277 name: name.to_string(),
278 description: String::new(),
279 examples: vec![],
280 file_patterns: vec![],
281 }
282 }
283
284 fn scopes(names: &[&str]) -> Vec<ScopeDefinition> {
285 names.iter().map(|n| scope(n)).collect()
286 }
287
288 fn issue_rules<'a>(rule: &str, issues: &'a [CommitIssue]) -> Vec<&'a CommitIssue> {
289 issues.iter().filter(|i| i.rule == rule).collect()
290 }
291
292 #[test]
295 fn parse_subject_simple() {
296 let p = parse_subject("feat(cli): add twiddle contextual options").unwrap();
297 assert_eq!(p.commit_type, "feat");
298 assert_eq!(p.scope, Some("cli"));
299 assert_eq!(p.description, "add twiddle contextual options");
300 assert!(!p.breaking);
301 }
302
303 #[test]
304 fn parse_subject_scope_less() {
305 let p = parse_subject("docs: clarify dry_run helper scope").unwrap();
306 assert_eq!(p.commit_type, "docs");
307 assert_eq!(p.scope, None);
308 }
309
310 #[test]
311 fn parse_subject_canonical_breaking_change() {
312 let p = parse_subject("feat(cli)!: change commit check output format").unwrap();
315 assert_eq!(p.commit_type, "feat");
316 assert_eq!(p.scope, Some("cli"));
317 assert!(p.breaking);
318 }
319
320 #[test]
321 fn parse_subject_lenient_legacy_breaking_change() {
322 let p = parse_subject("feat!(cli): add thing").unwrap();
323 assert_eq!(p.scope, Some("cli"));
324 assert!(p.breaking);
325 }
326
327 #[test]
328 fn parse_subject_both_bangs_accepted_leniently() {
329 assert!(parse_subject("feat!(cli)!: add thing").is_some());
330 }
331
332 #[test]
333 fn parse_subject_multi_scope() {
334 let p = parse_subject("feat(git,data): integrate branch analysis").unwrap();
335 assert_eq!(p.scope, Some("git,data"));
336 }
337
338 #[test]
339 fn parse_subject_rejects_missing_colon_space() {
340 assert!(parse_subject("feat(cli):no space").is_none());
341 }
342
343 #[test]
344 fn parse_subject_rejects_garbage() {
345 assert!(parse_subject("this is not a conventional commit").is_none());
346 }
347
348 #[test]
351 fn format_valid_passes() {
352 let rules = CommitRules::default();
353 let issues = lint_message("feat(cli): add twiddle contextual options", &rules, &[]);
354 assert!(issue_rules("format", &issues).is_empty());
355 assert!(passes(&issues));
356 }
357
358 #[test]
359 fn format_invalid_flags_error() {
360 let rules = CommitRules::default();
361 let issues = lint_message("not a conventional commit at all", &rules, &[]);
362 let found = issue_rules("format", &issues);
363 assert_eq!(found.len(), 1);
364 assert_eq!(found[0].severity, IssueSeverity::Error);
365 assert_eq!(found[0].section, "Commit Format");
366 }
367
368 #[test]
371 fn known_type_passes() {
372 let rules = CommitRules::default();
373 let issues = lint_message("feat(cli): add thing", &rules, &[]);
374 assert!(issue_rules("unknown-type", &issues).is_empty());
375 }
376
377 #[test]
378 fn unknown_type_flags_error() {
379 let rules = CommitRules::default();
380 let issues = lint_message("feature(cli): add thing", &rules, &[]);
381 let found = issue_rules("unknown-type", &issues);
382 assert_eq!(found.len(), 1);
383 assert_eq!(found[0].severity, IssueSeverity::Error);
384 assert_eq!(found[0].section, "Types");
385 }
386
387 #[test]
390 fn scope_comma_format_valid_passes() {
391 let rules = CommitRules::default();
392 let valid = scopes(&["cli", "claude"]);
393 let issues = lint_message("feat(cli,claude): add thing", &rules, &valid);
394 assert!(issue_rules("scope-comma-format", &issues).is_empty());
395
396 let issues_spaced = lint_message("feat(cli, claude): add thing", &rules, &valid);
397 assert!(issue_rules("scope-comma-format", &issues_spaced).is_empty());
398 }
399
400 #[test]
401 fn scope_comma_two_spaces_after_flags_error() {
402 let rules = CommitRules::default();
403 let valid = scopes(&["a", "b"]);
404 let issues = lint_message("feat(a, b): add thing", &rules, &valid);
405 let found = issue_rules("scope-comma-format", &issues);
406 assert_eq!(found.len(), 1);
407 assert_eq!(found[0].severity, IssueSeverity::Error);
408 }
409
410 #[test]
411 fn scope_comma_space_before_flags_error() {
412 let rules = CommitRules::default();
413 let valid = scopes(&["a", "b"]);
414 let issues = lint_message("feat(a ,b): add thing", &rules, &valid);
415 assert_eq!(issue_rules("scope-comma-format", &issues).len(), 1);
416 }
417
418 #[test]
421 fn valid_scope_passes() {
422 let rules = CommitRules::default();
423 let valid = scopes(&["cli"]);
424 let issues = lint_message("feat(cli): add thing", &rules, &valid);
425 assert!(issue_rules("unknown-scope", &issues).is_empty());
426 }
427
428 #[test]
429 fn undefined_scope_flags_error() {
430 let rules = CommitRules::default();
431 let valid = scopes(&["cli"]);
432 let issues = lint_message("feat(bogus): add thing", &rules, &valid);
433 let found = issue_rules("unknown-scope", &issues);
434 assert_eq!(found.len(), 1);
435 assert_eq!(found[0].severity, IssueSeverity::Error);
436 assert_eq!(found[0].section, "Scopes");
437 }
438
439 #[test]
440 fn empty_valid_scopes_list_accepts_anything() {
441 let rules = CommitRules::default();
443 let issues = lint_message("feat(anything): add thing", &rules, &[]);
444 assert!(issue_rules("unknown-scope", &issues).is_empty());
445 }
446
447 #[test]
450 fn missing_scope_allowed_by_default() {
451 let rules = CommitRules::default();
452 assert!(!rules.require_scope);
453 let issues = lint_message("docs: update issue references", &rules, &[]);
454 assert!(issue_rules("missing-scope", &issues).is_empty());
455 }
456
457 #[test]
458 fn missing_scope_flagged_when_required() {
459 let rules = CommitRules {
460 require_scope: true,
461 ..CommitRules::default()
462 };
463 let issues = lint_message("docs: update issue references", &rules, &[]);
464 let found = issue_rules("missing-scope", &issues);
465 assert_eq!(found.len(), 1);
466 assert_eq!(found[0].severity, IssueSeverity::Error);
467 }
468
469 #[test]
470 fn present_scope_not_flagged_when_required() {
471 let rules = CommitRules {
472 require_scope: true,
473 ..CommitRules::default()
474 };
475 let issues = lint_message("docs(docs): update issue references", &rules, &[]);
476 assert!(issue_rules("missing-scope", &issues).is_empty());
477 }
478
479 #[test]
482 fn lowercase_description_passes() {
483 let rules = CommitRules::default();
484 let issues = lint_message("feat(cli): add thing", &rules, &[]);
485 assert!(issue_rules("lowercase-description", &issues).is_empty());
486 }
487
488 #[test]
489 fn uppercase_description_flags_info() {
490 let rules = CommitRules::default();
491 let issues = lint_message("feat(cli): Add thing", &rules, &[]);
492 let found = issue_rules("lowercase-description", &issues);
493 assert_eq!(found.len(), 1);
494 assert_eq!(found[0].severity, IssueSeverity::Info);
495 assert!(passes(&found_as_issues(&issues, "lowercase-description")));
497 }
498
499 fn found_as_issues(issues: &[CommitIssue], rule: &str) -> Vec<CommitIssue> {
500 issues.iter().filter(|i| i.rule == rule).cloned().collect()
501 }
502
503 #[test]
504 fn no_trailing_period_passes() {
505 let rules = CommitRules::default();
506 let issues = lint_message("feat(cli): add thing", &rules, &[]);
507 assert!(issue_rules("no-trailing-period", &issues).is_empty());
508 }
509
510 #[test]
511 fn trailing_period_flags_info() {
512 let rules = CommitRules::default();
513 let issues = lint_message("feat(cli): add thing.", &rules, &[]);
514 let found = issue_rules("no-trailing-period", &issues);
515 assert_eq!(found.len(), 1);
516 assert_eq!(found[0].severity, IssueSeverity::Info);
517 }
518
519 #[test]
522 fn subject_within_limit_passes() {
523 let rules = CommitRules::default();
524 let issues = lint_message("feat(cli): add thing", &rules, &[]);
525 assert!(issue_rules("subject-length", &issues).is_empty());
526 }
527
528 #[test]
529 fn subject_over_limit_flags_error() {
530 let rules = CommitRules::default();
531 let long_desc = "x".repeat(rules.subject_max_len);
532 let msg = format!("feat(cli): {long_desc}");
533 assert!(msg.chars().count() > rules.subject_max_len);
534 let issues = lint_message(&msg, &rules, &[]);
535 let found = issue_rules("subject-length", &issues);
536 assert_eq!(found.len(), 1);
537 assert_eq!(found[0].severity, IssueSeverity::Error);
538 }
539
540 #[test]
541 fn configured_limit_is_honoured() {
542 let prefix = "feat(cli): ";
545 let desc = "x".repeat(73 - prefix.len());
546 let msg = format!("{prefix}{desc}");
547 assert_eq!(msg.chars().count(), 73);
548
549 let rules_80 = CommitRules {
550 subject_max_len: 80,
551 ..CommitRules::default()
552 };
553 assert!(issue_rules("subject-length", &lint_message(&msg, &rules_80, &[])).is_empty());
554
555 let rules_72 = CommitRules {
556 subject_max_len: 72,
557 ..CommitRules::default()
558 };
559 assert_eq!(
560 issue_rules("subject-length", &lint_message(&msg, &rules_72, &[])).len(),
561 1
562 );
563 }
564
565 #[test]
568 fn blank_line_2_with_body_passes() {
569 let rules = CommitRules::default();
570 let issues = lint_message("feat(cli): add thing\n\nMore detail here.", &rules, &[]);
571 assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
572 }
573
574 #[test]
575 fn subject_only_no_body_passes() {
576 let rules = CommitRules::default();
577 let issues = lint_message("feat(cli): add thing", &rules, &[]);
578 assert!(issue_rules("blank-line-after-subject", &issues).is_empty());
579 }
580
581 #[test]
582 fn non_blank_line_2_flags_error() {
583 let rules = CommitRules::default();
586 let issues = lint_message(
587 "fix(git): handle detached HEAD in branch analysis\nThis line should be blank.",
588 &rules,
589 &[],
590 );
591 let found = issue_rules("blank-line-after-subject", &issues);
592 assert_eq!(found.len(), 1);
593 assert_eq!(found[0].severity, IssueSeverity::Error);
594 assert_eq!(found[0].section, "Commit Format");
595 }
596
597 #[test]
600 fn no_footer_passes() {
601 let rules = CommitRules::default();
602 let issues = lint_message(
603 "feat(cli): add thing\n\nBody text.\n\nCloses #123",
604 &rules,
605 &[],
606 );
607 assert!(issue_rules("forbidden-footer", &issues).is_empty());
608 }
609
610 #[test]
611 fn co_authored_by_footer_flags_warning() {
612 let rules = CommitRules::default();
613 let issues = lint_message(
614 "feat(cli): add thing\n\nBody text.\n\nCo-Authored-By: Claude <noreply@anthropic.com>",
615 &rules,
616 &[],
617 );
618 let found = issue_rules("forbidden-footer", &issues);
619 assert_eq!(found.len(), 1);
620 assert_eq!(found[0].severity, IssueSeverity::Warning);
621 assert_eq!(found[0].section, "Body Guidelines");
622 assert!(passes(&found_as_issues(&issues, "forbidden-footer")));
624 }
625
626 #[test]
627 fn co_authored_by_footer_case_insensitive() {
628 let rules = CommitRules::default();
629 let issues = lint_message(
630 "feat(cli): add thing\n\nco-authored-by: someone",
631 &rules,
632 &[],
633 );
634 assert_eq!(issue_rules("forbidden-footer", &issues).len(), 1);
635 }
636
637 #[test]
640 fn ecosystem_default_scopes_pass_even_when_absent_from_scopes_yaml() {
641 let rules = CommitRules::default();
646 let merged = scopes(&["cli", "git", "lib", "cargo", "core", "test"]);
647 for s in ["lib", "cargo", "core", "test"] {
648 let msg = format!("chore({s}): bump dependency");
649 let issues = lint_message(&msg, &rules, &merged);
650 assert!(
651 issue_rules("unknown-scope", &issues).is_empty(),
652 "ecosystem default scope {s} should be accepted"
653 );
654 }
655 }
656
657 #[test]
660 fn positive_fixtures_from_history_pass_cleanly() {
661 let rules = CommitRules::default();
662 let valid = scopes(&["cli", "claude", "docs"]);
663 for msg in [
664 "feat(cli,claude): add twiddle contextual options",
665 "feat(cli)!: change commit check output format",
666 "docs(docs): add architecture overview document",
667 ] {
668 let issues = lint_message(msg, &rules, &valid);
669 assert!(passes(&issues), "{msg:?} should pass cleanly: {issues:?}");
670 }
671 }
672
673 #[test]
674 fn multi_scope_breaking_change_records_both_checks() {
675 let rules = CommitRules::default();
676 let valid = scopes(&["cli", "claude"]);
677 let issues = lint_message("feat(cli,claude)!: add thing", &rules, &valid);
678 assert!(passes(&issues));
679 }
680}