majordomus_cli/commit/header.rs
1//! The conventional-commit grammar: one parser, one renderer, one place.
2//!
3//! ```text
4//! feat(commit)!: the header is parsed once
5//! ^^^^ ^^^^^^ ^ ^^^^^^^^^^^^^^^^^^^^^^^^
6//! word scope breaking subject
7//! ```
8//!
9//! # Why the parse is total
10//!
11//! `git log` returns what the history holds, and the history holds merges, reverts, and
12//! subjects nobody spelled conventionally. A parser that returned an error for those would
13//! push every caller into deciding what to do with a commit that exists, and the changelog
14//! already made that decision once: an entry it cannot classify is carried whole rather
15//! than dropped, because a changelog that omits what it cannot parse lies about what
16//! happened.
17//!
18//! So [`CommitHeader::parse`] never fails. It answers with what it found, and
19//! [`CommitHeader::is_conventional`] is the separate question a *judge* asks. The changelog reads
20//! the first; [`crate::commit::verdict`] reads the second. Two verdicts, one grammar — which
21//! is the whole reason this module exists rather than a second regular expression in the
22//! gate that checks commits.
23//!
24//! ```
25//! use majordomus_cli::commit::CommitHeader;
26//! use majordomus_cli::release::ChangeKind;
27//!
28//! let h = CommitHeader::parse("feat(commit)!: the header is parsed once");
29//! assert_eq!(h.kind, ChangeKind::Feat);
30//! assert_eq!(h.scope.as_deref(), Some("commit"));
31//! assert!(h.breaking);
32//! assert_eq!(h.subject, "the header is parsed once");
33//! assert!(h.is_conventional());
34//!
35//! // a merge commit is not conventional, and is not damaged by being read
36//! let m = CommitHeader::parse("Merge pull request #249 from korczis/fix/pipelines");
37//! assert!(!m.is_conventional());
38//! assert_eq!(m.subject, "Merge pull request #249 from korczis/fix/pipelines");
39//! ```
40
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44use crate::release::ChangeKind;
45
46/// The header of a commit message: its first line, taken apart.
47///
48/// `word` is the type as the author wrote it and `kind` is what that word names; they
49/// differ exactly when the word is not one of the eleven the convention defines, and
50/// keeping both is what lets a reader see `wip(site): halfway through` as its author typed
51/// it while a judge still reports that `wip` is not a type.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53/// ```
54/// use majordomus_cli::commit::CommitHeader;
55/// use majordomus_cli::release::ChangeKind;
56/// let h = CommitHeader::parse("fix(plan): the fingerprint names what moved");
57/// assert_eq!(h.word, "fix");
58/// assert_eq!(h.kind, ChangeKind::Fix);
59/// assert_eq!(h.scope.as_deref(), Some("plan"));
60/// assert!(!h.breaking);
61/// assert_eq!(h.subject, "the fingerprint names what moved");
62/// ```
63pub struct CommitHeader {
64 /// The type as written: `feat`, `fix`, `wip`, or the empty string when the subject
65 /// carries no `type: ` head at all.
66 pub word: String,
67 /// What [`Self::word`] names; [`ChangeKind::Other`] for a word the convention does not
68 /// define, and for a subject with no head.
69 pub kind: ChangeKind,
70 /// The parenthesised scope, when there is one.
71 pub scope: Option<String>,
72 /// Whether the head carried the `!` that marks a breaking change. The `BREAKING CHANGE:`
73 /// trailer is a property of the body and is read by [`crate::commit::CommitMessage`].
74 pub breaking: bool,
75 /// What the commit says it did. For a header that does not parse this is the whole
76 /// first line, unchanged, and for an unknown type word it keeps the word: nothing that
77 /// the author wrote is dropped on the way through.
78 pub subject: String,
79}
80
81impl CommitHeader {
82 /// Read one subject line. Never fails; see the module documentation for why.
83 /// ```
84 /// use majordomus_cli::commit::CommitHeader;
85 /// // a conventional subject comes apart
86 /// assert_eq!(CommitHeader::parse("docs: one line").subject, "one line");
87 /// // and one that is not is carried whole rather than split at a colon in the sentence
88 /// let m = CommitHeader::parse("Merge branch 'x' of host: into y");
89 /// assert_eq!(m.word, "");
90 /// assert_eq!(m.subject, "Merge branch 'x' of host: into y");
91 /// ```
92 pub fn parse(subject: &str) -> CommitHeader {
93 let line = subject.trim_end_matches(['\r', '\n']);
94 let Some((head, rest)) = line.split_once(": ") else {
95 return CommitHeader {
96 word: String::new(),
97 kind: ChangeKind::Other,
98 scope: None,
99 breaking: false,
100 subject: line.trim().to_string(),
101 };
102 };
103 let head = head.trim();
104 // A head with whitespace in it was never a type word; `Merge branch 'x': into y`
105 // splits on `: ` and must not be read as the type `Merge branch 'x'`.
106 if head.is_empty() || head.contains(char::is_whitespace) {
107 return CommitHeader {
108 word: String::new(),
109 kind: ChangeKind::Other,
110 scope: None,
111 breaking: false,
112 subject: line.trim().to_string(),
113 };
114 }
115 let breaking = head.ends_with('!');
116 let head = head.trim_end_matches('!');
117 let (word, scope) = match head.split_once('(') {
118 Some((w, s)) => (w, s.strip_suffix(')').map(str::to_string)),
119 None => (head, None),
120 };
121 let kind = ChangeKind::parse(word);
122 let subject = if kind == ChangeKind::Other {
123 // The word is what the author said about the change; hiding it says less.
124 format!("{word}: {}", rest.trim())
125 } else {
126 rest.trim().to_string()
127 };
128 CommitHeader {
129 word: word.to_string(),
130 kind,
131 scope,
132 breaking,
133 subject,
134 }
135 }
136
137 /// Whether this header is a conventional commit: a type word the convention defines,
138 /// followed by an optional scope, an optional `!`, `: `, and a subject.
139 ///
140 /// ```
141 /// use majordomus_cli::commit::CommitHeader;
142 /// assert!(CommitHeader::parse("docs: the page the recipes cite").is_conventional());
143 /// assert!(!CommitHeader::parse("update stuff").is_conventional());
144 /// assert!(!CommitHeader::parse("wip(site): halfway").is_conventional());
145 /// ```
146 pub fn is_conventional(&self) -> bool {
147 self.kind != ChangeKind::Other && !self.subject.is_empty()
148 }
149
150 /// Render a header back to the line it came from.
151 ///
152 /// The round trip is exact for every conventional header, which is the property that
153 /// lets a planner build a header, a validator judge the rendered line, and both be
154 /// talking about the same commit.
155 ///
156 /// ```
157 /// use majordomus_cli::commit::CommitHeader;
158 /// for line in ["feat(commit)!: derived", "fix: one thing", "perf(http): fewer reads"] {
159 /// assert_eq!(CommitHeader::parse(line).render(), line);
160 /// }
161 /// ```
162 pub fn render(&self) -> String {
163 if self.word.is_empty() {
164 return self.subject.clone();
165 }
166 let scope = match &self.scope {
167 Some(s) => format!("({s})"),
168 None => String::new(),
169 };
170 let bang = if self.breaking { "!" } else { "" };
171 format!("{}{scope}{bang}: {}", self.word, self.subject)
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn the_parts_of_a_conventional_header_are_separated() {
181 let h = CommitHeader::parse("feat(commands): one canonical command graph");
182 assert_eq!(h.kind, ChangeKind::Feat);
183 assert_eq!(h.word, "feat");
184 assert_eq!(h.scope.as_deref(), Some("commands"));
185 assert_eq!(h.subject, "one canonical command graph");
186 assert!(!h.breaking);
187 assert!(h.is_conventional());
188 }
189
190 #[test]
191 fn a_scope_is_optional_and_a_bang_is_read() {
192 let h = CommitHeader::parse("docs: the document the env recipes cite");
193 assert_eq!(h.kind, ChangeKind::Docs);
194 assert_eq!(h.scope, None);
195 assert!(CommitHeader::parse("feat(api)!: the route moved").breaking);
196 assert!(CommitHeader::parse("feat!: the route moved").breaking);
197 assert_eq!(CommitHeader::parse("feat!: the route moved").word, "feat");
198 }
199
200 #[test]
201 fn a_subject_that_is_not_conventional_is_kept_whole() {
202 // The property the changelog depends on: an entry it cannot classify is carried,
203 // not dropped, and is not split at a colon that was part of the sentence.
204 for line in [
205 "Merge pull request #117 from korczis/int",
206 "Revert: the thing that broke",
207 "update stuff",
208 ] {
209 let h = CommitHeader::parse(line);
210 assert_eq!(h.kind, ChangeKind::Other, "{line}");
211 assert_eq!(h.subject, line, "{line}");
212 assert!(!h.is_conventional(), "{line}");
213 }
214 }
215
216 #[test]
217 fn an_unknown_type_word_keeps_the_word_and_its_scope() {
218 let h = CommitHeader::parse("wip(site): halfway through");
219 assert_eq!(h.kind, ChangeKind::Other);
220 assert_eq!(h.word, "wip");
221 assert_eq!(h.scope.as_deref(), Some("site"));
222 assert_eq!(h.subject, "wip: halfway through");
223 assert!(!h.is_conventional());
224 }
225
226 #[test]
227 fn a_head_with_a_space_in_it_was_never_a_type_word() {
228 // `git merge` writes subjects that contain `: `; reading the left half as a type
229 // would make every merge in the history a commit with a scope.
230 let h = CommitHeader::parse("Merge branch 'master' of gitlab.com: into feature/x");
231 assert_eq!(h.word, "");
232 assert_eq!(h.scope, None);
233 assert_eq!(
234 h.subject,
235 "Merge branch 'master' of gitlab.com: into feature/x"
236 );
237 }
238
239 #[test]
240 fn every_conventional_header_survives_the_round_trip() {
241 for line in [
242 "feat(commit)!: the header is parsed once",
243 "fix: one thing",
244 "chore(deps): bump the toolchain",
245 "refactor!: the module moved",
246 ] {
247 assert_eq!(CommitHeader::parse(line).render(), line, "{line}");
248 }
249 // and a line that is not a header renders as itself
250 let odd = "Merge pull request #1 from a/b";
251 assert_eq!(CommitHeader::parse(odd).render(), odd);
252 }
253}