1use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use super::policy::{git_authored, CommitPolicy, FindingLevel};
39use super::CommitMessage;
40use crate::model::{Diagnostic, Severity};
41use crate::release::commits::record_candidates;
42use crate::release::ChangeKind;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "snake_case")]
52pub enum CommitRule {
60 NotConventional,
62 SubjectTooLong,
64 SubjectEmpty,
66 SubjectTrailingPeriod,
68 UnknownScope,
70 FixWithoutTest,
72 UnresolvedReference,
74 BreakingUnexplained,
76}
77
78impl CommitRule {
79 pub fn code(self) -> &'static str {
86 match self {
87 CommitRule::NotConventional => "commit.not_conventional",
88 CommitRule::SubjectTooLong => "commit.subject_too_long",
89 CommitRule::SubjectEmpty => "commit.subject_empty",
90 CommitRule::SubjectTrailingPeriod => "commit.subject_trailing_period",
91 CommitRule::UnknownScope => "commit.unknown_scope",
92 CommitRule::FixWithoutTest => "commit.fix_without_test",
93 CommitRule::UnresolvedReference => "commit.unresolved_reference",
94 CommitRule::BreakingUnexplained => "commit.breaking_unexplained",
95 }
96 }
97
98 pub const ALL: &'static [CommitRule] = &[
102 CommitRule::NotConventional,
103 CommitRule::SubjectEmpty,
104 CommitRule::SubjectTooLong,
105 CommitRule::SubjectTrailingPeriod,
106 CommitRule::UnknownScope,
107 CommitRule::BreakingUnexplained,
108 CommitRule::UnresolvedReference,
109 CommitRule::FixWithoutTest,
110 ];
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(rename_all = "snake_case")]
116pub enum CommitExemption {
122 GitAuthored,
124}
125
126#[derive(Debug, Clone, Default)]
133pub struct CommitSubject {
147 pub text: String,
149 pub scopes: Option<Vec<String>>,
151 pub paths: Option<Vec<String>>,
153 pub records: Vec<String>,
155}
156
157impl CommitSubject {
158 pub fn of(text: &str) -> CommitSubject {
166 CommitSubject {
167 text: text.to_string(),
168 ..CommitSubject::default()
169 }
170 }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
179pub struct CommitVerdict {
187 pub message: CommitMessage,
189 pub passed: bool,
191 #[serde(skip_serializing_if = "Option::is_none")]
193 pub exempt: Option<CommitExemption>,
194 pub findings: Vec<Diagnostic>,
196}
197
198fn is_test_path(path: &str) -> bool {
204 let p = path.to_ascii_lowercase();
205 p.split('/')
206 .any(|seg| seg == "test" || seg == "tests" || seg == "spec")
207 || p.contains("_test.")
208 || p.contains(".test.")
209 || p.contains("_spec.")
210 || p.ends_with("/tests.rs")
211}
212
213pub fn judge(subject: &CommitSubject, policy: &CommitPolicy) -> CommitVerdict {
226 let message = CommitMessage::parse(&subject.text);
227 let head = &message.header;
228
229 if git_authored(&head.subject) || git_authored(subject.text.lines().next().unwrap_or_default())
230 {
231 return CommitVerdict {
232 message,
233 passed: true,
234 exempt: Some(CommitExemption::GitAuthored),
235 findings: Vec::new(),
236 };
237 }
238
239 let mut found: Vec<(CommitRule, Severity, String)> = Vec::new();
240 let mut add = |rule: CommitRule, level: FindingLevel, message: String| {
241 if let Some(severity) = level.severity() {
242 found.push((rule, severity, message));
243 }
244 };
245
246 if !head.is_conventional() {
247 let detail = if head.word.is_empty() {
248 format!(
249 "'{}' is not `type(scope): subject`; the types are {}",
250 head.subject.chars().take(60).collect::<String>(),
251 ChangeKind::WORDS.join(", ")
252 )
253 } else {
254 format!(
255 "'{}' is not a commit type; the types are {}",
256 head.word,
257 ChangeKind::WORDS.join(", ")
258 )
259 };
260 add(CommitRule::NotConventional, FindingLevel::Error, detail);
261 return finish(message, found);
264 }
265
266 if head.subject.trim().is_empty() {
267 add(
268 CommitRule::SubjectEmpty,
269 FindingLevel::Error,
270 "the header says nothing after the colon".into(),
271 );
272 }
273 let width = head.render().chars().count();
274 if width > policy.subject_max_chars {
275 add(
276 CommitRule::SubjectTooLong,
277 FindingLevel::Error,
278 format!(
279 "the subject is {width} characters; the policy allows {}",
280 policy.subject_max_chars
281 ),
282 );
283 }
284 if head.subject.ends_with('.') {
285 add(
286 CommitRule::SubjectTrailingPeriod,
287 FindingLevel::Error,
288 "the subject ends in a full stop; a subject is a title, not a sentence".into(),
289 );
290 }
291 if let (Some(scope), Some(known)) = (head.scope.as_deref(), subject.scopes.as_deref()) {
292 if !known.iter().any(|k| k == scope) {
293 add(
294 CommitRule::UnknownScope,
295 policy.scope_unknown,
296 format!(
297 "'{scope}' is not a scope this repository uses; `majordomus commit scopes` lists them"
298 ),
299 );
300 }
301 }
302 if message.breaking()
303 && message.body.trim().is_empty()
304 && message.trailer("BREAKING CHANGE").is_none()
305 {
306 add(
307 CommitRule::BreakingUnexplained,
308 policy.breaking_unexplained,
309 "the commit is marked breaking and explains nothing; write a body or a BREAKING CHANGE trailer".into(),
310 );
311 }
312 for candidate in record_candidates(&without_code_spans(&subject.text)) {
313 if !subject.records.contains(&candidate) {
314 add(
315 CommitRule::UnresolvedReference,
316 policy.reference_unresolved,
317 format!(
318 "the message names {candidate}, which this repository's layer does not hold"
319 ),
320 );
321 }
322 }
323 if head.kind == ChangeKind::Fix {
324 if let Some(paths) = subject.paths.as_deref() {
325 if !paths.is_empty() && !paths.iter().any(|p| is_test_path(p)) {
326 add(
327 CommitRule::FixWithoutTest,
328 policy.fix_requires_test,
329 "a fix with no test among its files; a bug that can come back is a bug with no regression test".into(),
330 );
331 }
332 }
333 }
334
335 finish(message, found)
336}
337
338fn finish(message: CommitMessage, found: Vec<(CommitRule, Severity, String)>) -> CommitVerdict {
340 let mut ranked: Vec<RankedFinding> = found
341 .into_iter()
342 .map(|(rule, severity, message)| RankedFinding {
343 rule,
344 severity,
345 message,
346 })
347 .collect();
348 crate::order::canonical(&mut ranked);
349 let found: Vec<(CommitRule, Severity, String)> = ranked
350 .into_iter()
351 .map(|f| (f.rule, f.severity, f.message))
352 .collect();
353 let passed = !found.iter().any(|(_, s, _)| *s == Severity::Error);
354 CommitVerdict {
355 message,
356 passed,
357 exempt: None,
358 findings: found
359 .into_iter()
360 .map(|(rule, severity, msg)| Diagnostic {
361 severity,
362 code: rule.code().to_string(),
363 path: None,
364 message: msg,
365 })
366 .collect(),
367 }
368}
369
370struct RankedFinding {
373 rule: CommitRule,
374 severity: Severity,
375 message: String,
376}
377
378impl crate::order::Ordered for RankedFinding {
379 fn order_key(&self) -> crate::order::OrderKey<'_> {
380 let position = CommitRule::ALL
381 .iter()
382 .position(|r| *r == self.rule)
383 .map_or(i64::MAX, |p| p as i64);
384 crate::order::OrderKey::plain(&self.message, &self.message).ranked(position)
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn codes(v: &CommitVerdict) -> Vec<&str> {
393 v.findings.iter().map(|d| d.code.as_str()).collect()
394 }
395
396 #[test]
397 fn a_well_formed_commit_passes_with_nothing_to_say() {
398 let v = judge(
399 &CommitSubject::of("feat(commit): the rule is decided by a command"),
400 &CommitPolicy::default(),
401 );
402 assert!(v.passed, "{:?}", v.findings);
403 assert!(v.findings.is_empty());
404 assert!(v.exempt.is_none());
405 }
406
407 #[test]
408 fn a_subject_that_is_not_conventional_is_one_finding_and_not_five() {
409 let v = judge(&CommitSubject::of("update stuff"), &CommitPolicy::default());
412 assert!(!v.passed);
413 assert_eq!(codes(&v), vec!["commit.not_conventional"]);
414 }
415
416 #[test]
417 fn git_composes_subjects_that_no_convention_applies_to() {
418 for s in [
419 "Merge pull request #249 from korczis/fix/pipelines",
420 "Revert \"feat(x): a thing\"",
421 "fixup! feat(x): a thing",
422 ] {
423 let v = judge(&CommitSubject::of(s), &CommitPolicy::default());
424 assert!(v.passed, "{s}");
425 assert_eq!(v.exempt, Some(CommitExemption::GitAuthored), "{s}");
426 assert!(v.findings.is_empty(), "{s}");
427 }
428 }
429
430 #[test]
431 fn a_wide_subject_is_measured_whole_and_not_only_after_the_colon() {
432 let long = "x".repeat(80);
433 let v = judge(
434 &CommitSubject::of(&format!("feat(a): {long}")),
435 &CommitPolicy::default(),
436 );
437 assert_eq!(codes(&v), vec!["commit.subject_too_long"]);
438 assert!(!v.passed);
439 assert!(v.findings[0].message.contains("89 characters"));
441 }
442
443 #[test]
444 fn a_scope_outside_the_vocabulary_is_a_warning_and_still_passes() {
445 let subject = CommitSubject {
446 text: "feat(unheard-of): a new subsystem".into(),
447 scopes: Some(vec!["commit".into(), "http".into()]),
448 ..CommitSubject::default()
449 };
450 let v = judge(&subject, &CommitPolicy::default());
451 assert_eq!(codes(&v), vec!["commit.unknown_scope"]);
452 assert!(v.passed);
454 assert_eq!(v.findings[0].severity, Severity::Warning);
455 }
456
457 #[test]
458 fn a_scope_in_the_vocabulary_says_nothing() {
459 let subject = CommitSubject {
460 text: "feat(commit): a thing".into(),
461 scopes: Some(vec!["commit".into()]),
462 ..CommitSubject::default()
463 };
464 assert!(judge(&subject, &CommitPolicy::default())
465 .findings
466 .is_empty());
467 }
468
469 #[test]
470 fn a_reference_the_layer_does_not_hold_fails_and_one_it_holds_does_not() {
471 let held = CommitSubject {
472 text: "fix(plan): a thing under I1305".into(),
473 records: vec!["I1305".into()],
474 ..CommitSubject::default()
475 };
476 assert!(judge(&held, &CommitPolicy::default()).findings.is_empty());
477
478 let invented = CommitSubject {
479 text: "fix(plan): a thing under I9999".into(),
480 records: vec!["I1305".into()],
481 ..CommitSubject::default()
482 };
483 let v = judge(&invented, &CommitPolicy::default());
484 assert_eq!(codes(&v), vec!["commit.unresolved_reference"]);
485 assert!(!v.passed);
486 }
487
488 #[test]
489 fn a_fix_is_asked_for_a_test_only_when_its_files_are_known() {
490 let with_test = CommitSubject {
491 text: "fix(commit): a thing".into(),
492 paths: Some(vec![
493 "src/commit/verdict.rs".into(),
494 "test/cases/1.sh".into(),
495 ]),
496 ..CommitSubject::default()
497 };
498 assert!(judge(&with_test, &CommitPolicy::default())
499 .findings
500 .is_empty());
501
502 let without = CommitSubject {
503 text: "fix(commit): a thing".into(),
504 paths: Some(vec!["src/commit/verdict.rs".into()]),
505 ..CommitSubject::default()
506 };
507 let v = judge(&without, &CommitPolicy::default());
508 assert_eq!(codes(&v), vec!["commit.fix_without_test"]);
509 assert!(
510 v.passed,
511 "a warning by default, so that a gate over history does not rewrite it"
512 );
513
514 let unknown = CommitSubject::of("fix(commit): a thing");
516 assert!(judge(&unknown, &CommitPolicy::default())
517 .findings
518 .is_empty());
519 }
520
521 #[test]
522 fn a_fix_that_only_touches_tests_is_covered_by_them() {
523 let only_tests = CommitSubject {
524 text: "fix(commit): a thing".into(),
525 paths: Some(vec!["apps/majordomus-cli/src/commit/tests.rs".into()]),
526 ..CommitSubject::default()
527 };
528 assert!(judge(&only_tests, &CommitPolicy::default())
529 .findings
530 .is_empty());
531 }
532
533 #[test]
534 fn a_breaking_change_must_say_why() {
535 let bare = judge(
536 &CommitSubject::of("feat(api)!: the route moved"),
537 &CommitPolicy::default(),
538 );
539 assert_eq!(codes(&bare), vec!["commit.breaking_unexplained"]);
540 assert!(!bare.passed);
541
542 let explained = judge(
543 &CommitSubject::of("feat(api)!: the route moved\n\nIt is now under /api/v2."),
544 &CommitPolicy::default(),
545 );
546 assert!(explained.findings.is_empty());
547
548 let trailer = judge(
549 &CommitSubject::of(
550 "feat(api): the route moved\n\nBREAKING CHANGE: it is under /api/v2",
551 ),
552 &CommitPolicy::default(),
553 );
554 assert!(trailer.findings.is_empty());
555 }
556
557 #[test]
558 fn a_level_of_off_reports_nothing_at_all() {
559 let policy = CommitPolicy {
560 reference_unresolved: FindingLevel::Off,
561 ..CommitPolicy::default()
562 };
563 let invented = CommitSubject {
564 text: "fix(plan): a thing under I9999".into(),
565 ..CommitSubject::default()
566 };
567 let v = judge(&invented, &policy);
568 assert!(v.findings.is_empty());
569 assert!(v.passed);
570 }
571
572 #[test]
573 fn findings_are_ordered_by_where_their_evidence_is() {
574 let subject = CommitSubject {
575 text: format!("fix(nope)!: {}.", "x".repeat(80)),
576 scopes: Some(vec!["commit".into()]),
577 ..CommitSubject::default()
578 };
579 let v = judge(&subject, &CommitPolicy::default());
580 assert_eq!(
581 codes(&v),
582 vec![
583 "commit.subject_too_long",
584 "commit.subject_trailing_period",
585 "commit.unknown_scope",
586 "commit.breaking_unexplained",
587 ]
588 );
589 }
590
591 #[test]
592 fn a_test_path_is_recognised_by_the_shapes_this_repository_uses() {
593 for p in [
594 "test/cases/01_init.sh",
595 "tests/integration.rs",
596 "apps/majordomus-cli/src/commit/tests.rs",
597 "lib/foo_test.exs",
598 "site/a.test.js",
599 ] {
600 assert!(is_test_path(p), "{p}");
601 }
602 for p in ["src/commit/verdict.rs", "docs/COMMIT.md", "latest.rs"] {
603 assert!(!is_test_path(p), "{p}");
604 }
605 }
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
610pub struct JudgedCommit {
623 pub commit: String,
625 pub subject: String,
627 pub passed: bool,
629 #[serde(skip_serializing_if = "Option::is_none")]
631 pub exempt: Option<CommitExemption>,
632 pub findings: Vec<Diagnostic>,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
638pub struct HistoryReport {
655 pub range: String,
657 pub total: usize,
659 pub exempt: usize,
661 pub failing: usize,
663 pub commits: Vec<JudgedCommit>,
667}
668
669pub fn without_code_spans(text: &str) -> String {
680 let mut out = String::with_capacity(text.len());
681 let mut inside = false;
682 for ch in text.chars() {
683 if ch == '`' {
684 inside = !inside;
685 out.push(' ');
686 } else if inside && ch != '\n' {
687 out.push(' ');
688 } else {
689 out.push(ch);
690 }
691 }
692 out
693}