Skip to main content

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}