Skip to main content

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}