majordomus_cli/commit/policy.rs
1//! What this repository holds a commit to, as data.
2//!
3//! The type vocabulary is deliberately *not* here: [`crate::release::ChangeKind`] already
4//! is the list of words a commit's type may be, the changelog already renders from it, and
5//! a second copy under a `types:` key would be exactly the duplication this subsystem
6//! exists to remove. What is here is only what a repository can reasonably decide
7//! differently — a subject width, and how loudly to report three things that are findings
8//! in one project and noise in another.
9//!
10//! The block is read from `.ai/repo/policy.yaml`, the canonical policy this repository
11//! already has, and every field has the default this repository's own history satisfies, so
12//! a policy that says nothing about commits still yields the same verdict.
13//!
14//! ```
15//! use majordomus_cli::commit::CommitPolicy;
16//! let p = CommitPolicy::default();
17//! assert_eq!(p.subject_max_chars, 72);
18//! ```
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23use crate::model::Severity;
24
25/// How loudly one judgement is reported, including not at all.
26///
27/// `Off` is a value and not an absence: a repository that has decided a check does not
28/// apply to it has made a decision, and a decision is worth being able to read back.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
30#[serde(rename_all = "lowercase")]
31/// ```
32/// use majordomus_cli::commit::FindingLevel;
33/// // a repository that has turned a check off has made a decision, and it reads back
34/// let off: FindingLevel = serde_json::from_str("\"off\"").expect("a level");
35/// assert_eq!(off, FindingLevel::Off);
36/// assert_eq!(FindingLevel::default(), FindingLevel::Warning);
37/// ```
38pub enum FindingLevel {
39 /// Never reported.
40 Off,
41 /// Reported; changes no verdict.
42 Info,
43 /// Reported; the verdict is still a pass.
44 #[default]
45 Warning,
46 /// Reported; the verdict fails.
47 Error,
48}
49
50impl FindingLevel {
51 /// The severity a finding at this level carries, or `None` when it is not reported.
52 /// ```
53 /// use majordomus_cli::commit::FindingLevel;
54 /// use majordomus_cli::Severity;
55 /// assert_eq!(FindingLevel::Off.severity(), None);
56 /// assert_eq!(FindingLevel::Error.severity(), Some(Severity::Error));
57 /// ```
58 pub fn severity(self) -> Option<Severity> {
59 match self {
60 FindingLevel::Off => None,
61 FindingLevel::Info => Some(Severity::Info),
62 FindingLevel::Warning => Some(Severity::Warning),
63 FindingLevel::Error => Some(Severity::Error),
64 }
65 }
66}
67
68/// `commit:` in the repository policy: the subject width, and how loudly to report the four
69/// judgements a repository can reasonably decide differently about. The type vocabulary is
70/// deliberately absent — it is [`crate::release::ChangeKind`], which the changelog already
71/// renders from, and a second copy of those words here is the duplication this subsystem
72/// exists to remove.
73///
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
75/// ```
76/// use majordomus_cli::commit::{CommitPolicy, FindingLevel};
77/// // a policy that says nothing is the default this repository's history already satisfies
78/// let quiet: CommitPolicy = serde_json::from_str("{}").expect("an empty block");
79/// assert_eq!(quiet, CommitPolicy::default());
80/// assert_eq!(quiet.subject_max_chars, 72);
81/// // and one that says something keeps the defaults it did not mention
82/// let narrow: CommitPolicy =
83/// serde_json::from_str(r#"{"subject_max_chars": 50}"#).expect("a block");
84/// assert_eq!(narrow.subject_max_chars, 50);
85/// assert_eq!(narrow.reference_unresolved, FindingLevel::Error);
86/// ```
87pub struct CommitPolicy {
88 /// The widest a subject line may be. 72 is what a terminal and every git viewer show
89 /// without wrapping, and what this repository's history already keeps to.
90 #[serde(default = "subject_max_chars")]
91 pub subject_max_chars: usize,
92 /// A scope outside the vocabulary the repository's own history and layout yield.
93 /// A warning by default: the vocabulary is inferred, and inference must not refuse the
94 /// first commit of a new subsystem.
95 #[serde(default)]
96 pub scope_unknown: FindingLevel,
97 /// A `fix` whose staged files include no test. The strongest available evidence that a
98 /// bug fix carries a regression test, and the cheapest place to ask for it.
99 #[serde(default)]
100 pub fix_requires_test: FindingLevel,
101 /// A record id in the message — `I1305`, `M000` — that the layer does not hold. An
102 /// error by default: a reference to a record that does not exist cannot be told from a
103 /// good one until somebody follows it, which is usually months later.
104 #[serde(default = "error")]
105 pub reference_unresolved: FindingLevel,
106 /// A commit that declares itself breaking and says nothing else. An error by default:
107 /// the `!` is the only mark a release reads, and a release note derived from a breaking
108 /// change with no explanation is a release note that says a thing broke.
109 #[serde(default = "error")]
110 pub breaking_unexplained: FindingLevel,
111}
112
113fn subject_max_chars() -> usize {
114 72
115}
116
117fn error() -> FindingLevel {
118 FindingLevel::Error
119}
120
121impl Default for CommitPolicy {
122 fn default() -> Self {
123 CommitPolicy {
124 subject_max_chars: subject_max_chars(),
125 scope_unknown: FindingLevel::Warning,
126 fix_requires_test: FindingLevel::Warning,
127 reference_unresolved: FindingLevel::Error,
128 breaking_unexplained: FindingLevel::Error,
129 }
130 }
131}
132
133/// CommitSubject prefixes git itself writes, which no commit convention applies to.
134///
135/// These are not a repository's choice and are not in the policy: `git merge`, `git revert`
136/// and `git commit --fixup` compose these subjects, a person did not, and a validator that
137/// refused them would be refusing git. The list is short and closed because git's is.
138pub const GIT_AUTHORED_PREFIXES: &[&str] = &[
139 "Merge ",
140 "Revert \"",
141 "Revert: ",
142 "fixup! ",
143 "squash! ",
144 "amend! ",
145];
146
147/// Whether git composed this subject rather than a person.
148///
149/// ```
150/// use majordomus_cli::commit::policy::git_authored;
151/// assert!(git_authored("Merge pull request #249 from korczis/fix"));
152/// assert!(git_authored("Revert \"feat(x): a thing\""));
153/// assert!(!git_authored("feat(x): a thing"));
154/// ```
155pub fn git_authored(subject: &str) -> bool {
156 GIT_AUTHORED_PREFIXES.iter().any(|p| subject.starts_with(p))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn a_policy_that_says_nothing_is_the_default() {
165 let p: CommitPolicy = serde_json::from_str("{}").expect("an empty block");
166 assert_eq!(p, CommitPolicy::default());
167 }
168
169 #[test]
170 fn a_policy_that_says_something_is_read() {
171 let p: CommitPolicy =
172 serde_json::from_str(r#"{"subject_max_chars": 50, "fix_requires_test": "error"}"#)
173 .expect("a block");
174 assert_eq!(p.subject_max_chars, 50);
175 assert_eq!(p.fix_requires_test, FindingLevel::Error);
176 // and what it did not say keeps the default
177 assert_eq!(p.reference_unresolved, FindingLevel::Error);
178 }
179
180 #[test]
181 fn off_reports_nothing_and_every_other_level_has_a_severity() {
182 assert_eq!(FindingLevel::Off.severity(), None);
183 assert_eq!(FindingLevel::Info.severity(), Some(Severity::Info));
184 assert_eq!(FindingLevel::Warning.severity(), Some(Severity::Warning));
185 assert_eq!(FindingLevel::Error.severity(), Some(Severity::Error));
186 }
187
188 #[test]
189 fn the_prefixes_git_writes_are_recognised_and_nothing_else_is() {
190 for s in [
191 "Merge branch 'master' into feature/x",
192 "Merge pull request #1 from a/b",
193 "Revert \"feat(x): a thing\"",
194 "fixup! feat(x): a thing",
195 "squash! feat(x): a thing",
196 ] {
197 assert!(git_authored(s), "{s}");
198 }
199 for s in [
200 "feat(x): a thing",
201 "Merged the two branches by hand",
202 "revert(x): undo the thing",
203 ] {
204 assert!(!git_authored(s), "{s}");
205 }
206 }
207}