Skip to main content

majordomus_cli/commit/
message.rs

1//! A whole commit message: the header, the body, and the trailers under it.
2//!
3//! The message is the *rendering*. What a worker states is a [`CommitMessage`]'s fields — a kind,
4//! a scope, what it did, what it refers to — and the text git stores is derived from them.
5//! That direction matters: the same value renders the commit, answers what a plan intends
6//! to do before anything is written, and is what a validator judges after it is. A free-form
7//! string as the canonical representation could do only the last of those.
8//!
9//! ```
10//! use majordomus_cli::commit::{CommitHeader, CommitMessage};
11//! use majordomus_cli::release::ChangeKind;
12//!
13//! let m = CommitMessage::parse("fix(commit): the trailer is read\n\nWhy it was wrong.\n\nRefs: I1305\n");
14//! assert_eq!(m.header.kind, ChangeKind::Fix);
15//! assert_eq!(m.body, "Why it was wrong.");
16//! assert_eq!(m.trailer("Refs"), Some("I1305"));
17//! // and the value renders back to the message it was read from
18//! assert!(m.render().starts_with("fix(commit): the trailer is read\n\nWhy it was wrong."));
19//! ```
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use super::CommitHeader;
25
26/// One `Key: value` line in the trailer block at the end of a commit message.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
28/// ```
29/// use majordomus_cli::commit::CommitMessage;
30/// use majordomus_cli::commit::CommitTrailer;
31/// let m = CommitMessage::parse("fix(x): a thing\n\nWhy.\n\nRefs: I1305\n");
32/// let t: &CommitTrailer = &m.trailers[0];
33/// assert_eq!(t.key, "Refs");
34/// assert_eq!(t.value, "I1305");
35/// ```
36pub struct CommitTrailer {
37    /// The key, as written: `Refs`, `Co-Authored-By`, `BREAKING CHANGE`.
38    pub key: String,
39    /// Everything after the colon and one space.
40    pub value: String,
41}
42
43/// A commit message taken apart: header, body, trailers.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45/// ```
46/// use majordomus_cli::commit::CommitMessage;
47/// let m = CommitMessage::parse("feat(a): a thing\n\nThe body.\n\nRefs: I1305\n");
48/// assert_eq!(m.header.subject, "a thing");
49/// assert_eq!(m.body, "The body.");
50/// assert_eq!(m.trailers.len(), 1);
51/// ```
52pub struct CommitMessage {
53    /// The first line, parsed.
54    pub header: CommitHeader,
55    /// Everything between the header and the trailer block, with surrounding blank lines
56    /// removed. Empty when the message is a subject alone.
57    pub body: String,
58    /// The `Key: value` lines of the final paragraph, in the order they were written.
59    pub trailers: Vec<CommitTrailer>,
60}
61
62/// Whether a line is a trailer: `Key: value`, where the key is a word of letters, digits
63/// and hyphens, or one of the two spellings of the breaking-change trailer.
64///
65/// Deliberately narrower than git's own interpretation. Git will treat almost any
66/// `word: rest` as a trailer if it is in the last paragraph, which turns the last sentence
67/// of a body — `The reason: it was wrong.` — into a trailer with the key `The reason`. The
68/// keys this repository writes are single words, and a paragraph that does not consist
69/// entirely of them stays a body.
70fn trailer_of(line: &str) -> Option<CommitTrailer> {
71    if let Some(rest) = line
72        .strip_prefix("BREAKING CHANGE:")
73        .or_else(|| line.strip_prefix("BREAKING-CHANGE:"))
74    {
75        return Some(CommitTrailer {
76            key: "BREAKING CHANGE".into(),
77            value: rest.trim().to_string(),
78        });
79    }
80    let (key, value) = line.split_once(':')?;
81    if key.is_empty()
82        || !key
83            .chars()
84            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
85    {
86        return None;
87    }
88    Some(CommitTrailer {
89        key: key.to_string(),
90        value: value.trim().to_string(),
91    })
92}
93
94impl CommitMessage {
95    /// Read a whole commit message. Total, like [`CommitHeader::parse`]: what cannot be read as a
96    /// header or a trailer is body, and nothing is an error.
97    /// ```
98    /// use majordomus_cli::commit::CommitMessage;
99    /// // a last paragraph of prose is body, not trailers, even though git would disagree
100    /// let m = CommitMessage::parse("fix(x): a thing\n\nThe reason: it was wrong.");
101    /// assert!(m.trailers.is_empty());
102    /// assert_eq!(m.body, "The reason: it was wrong.");
103    /// ```
104    pub fn parse(text: &str) -> CommitMessage {
105        // A message given to `git commit` may carry comment lines; git strips them and so
106        // does this, so that what is validated is what will be stored.
107        let lines: Vec<&str> = text
108            .lines()
109            .filter(|l| !l.starts_with('#'))
110            .map(|l| l.trim_end())
111            .collect();
112        let header = CommitHeader::parse(lines.first().copied().unwrap_or_default());
113        let rest = lines.get(1..).unwrap_or_default();
114        // The trailer block is the last paragraph, and only when every line of it is a
115        // trailer. Anything else is body.
116        let paragraphs: Vec<&[&str]> = rest
117            .split(|l| l.trim().is_empty())
118            .filter(|p| !p.is_empty())
119            .collect();
120        let mut trailers = Vec::new();
121        let mut body_paragraphs: Vec<String> = Vec::new();
122        for (i, para) in paragraphs.iter().enumerate() {
123            let last = i + 1 == paragraphs.len();
124            let all: Option<Vec<CommitTrailer>> = para.iter().map(|l| trailer_of(l)).collect();
125            match (last, all) {
126                (true, Some(ts)) if !ts.is_empty() => trailers = ts,
127                _ => body_paragraphs.push(para.join("\n")),
128            }
129        }
130        CommitMessage {
131            header,
132            body: body_paragraphs.join("\n\n"),
133            trailers,
134        }
135    }
136
137    /// The value of the first trailer with this key, compared case-insensitively.
138    /// ```
139    /// use majordomus_cli::commit::CommitMessage;
140    /// let m = CommitMessage::parse("fix(x): a\n\nb\n\nCo-Authored-By: Someone <s@example.org>\n");
141    /// assert_eq!(m.trailer("co-authored-by"), Some("Someone <s@example.org>"));
142    /// assert_eq!(m.trailer("Refs"), None);
143    /// ```
144    pub fn trailer(&self, key: &str) -> Option<&str> {
145        self.trailers
146            .iter()
147            .find(|t| t.key.eq_ignore_ascii_case(key))
148            .map(|t| t.value.as_str())
149    }
150
151    /// Whether this commit declares itself breaking, by either spelling: the `!` in the
152    /// header or the trailer in the message.
153    ///
154    /// ```
155    /// use majordomus_cli::commit::CommitMessage;
156    /// assert!(CommitMessage::parse("feat(api)!: the route moved").breaking());
157    /// assert!(CommitMessage::parse("feat(api): moved\n\nBREAKING CHANGE: it moved").breaking());
158    /// assert!(!CommitMessage::parse("feat(api): moved").breaking());
159    /// ```
160    pub fn breaking(&self) -> bool {
161        self.header.breaking || self.trailer("BREAKING CHANGE").is_some()
162    }
163
164    /// Render the message as git will store it: header, blank line, body, blank line,
165    /// trailers, one final newline.
166    /// ```
167    /// use majordomus_cli::commit::CommitMessage;
168    /// let text = "feat(a)!: a thing\n\nWhy.\n\nRefs: I1305\n";
169    /// assert_eq!(CommitMessage::parse(text).render(), text);
170    /// ```
171    pub fn render(&self) -> String {
172        let mut out = self.header.render();
173        if !self.body.is_empty() {
174            out.push_str("\n\n");
175            out.push_str(self.body.trim_end());
176        }
177        if !self.trailers.is_empty() {
178            out.push_str("\n\n");
179            for t in &self.trailers {
180                out.push_str(&t.key);
181                out.push_str(": ");
182                out.push_str(&t.value);
183                out.push('\n');
184            }
185            return out;
186        }
187        out.push('\n');
188        out
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::release::ChangeKind;
196
197    #[test]
198    fn a_message_is_header_body_and_trailers() {
199        let m = CommitMessage::parse(
200            "feat(commit): the message is a value\n\nFirst paragraph.\n\nSecond paragraph.\n\nRefs: I1305\nCo-Authored-By: Someone <s@example.org>\n",
201        );
202        assert_eq!(m.header.kind, ChangeKind::Feat);
203        assert_eq!(m.body, "First paragraph.\n\nSecond paragraph.");
204        assert_eq!(m.trailers.len(), 2);
205        assert_eq!(m.trailer("refs"), Some("I1305"));
206        assert_eq!(m.trailer("Co-Authored-By"), Some("Someone <s@example.org>"));
207    }
208
209    #[test]
210    fn a_last_paragraph_of_prose_is_body_and_not_trailers() {
211        // Git would read `The reason` as a trailer key here. A commit body that ends in a
212        // sentence containing a colon is normal writing, and turning it into metadata
213        // would make the validator report a key nobody wrote.
214        let m = CommitMessage::parse("fix(x): a thing\n\nThe reason: it was wrong.");
215        assert!(m.trailers.is_empty());
216        assert_eq!(m.body, "The reason: it was wrong.");
217    }
218
219    #[test]
220    fn a_subject_alone_is_a_message() {
221        let m = CommitMessage::parse("docs: one line");
222        assert_eq!(m.body, "");
223        assert!(m.trailers.is_empty());
224        assert_eq!(m.render(), "docs: one line\n");
225    }
226
227    #[test]
228    fn comment_lines_are_not_part_of_the_message() {
229        // What is judged must be what git will store, or the hook passes a message that
230        // the repository then rejects — or worse, the other way around.
231        let m =
232            CommitMessage::parse("fix(x): a thing\n# Please enter the commit message\n\nBody.\n");
233        assert_eq!(m.header.subject, "a thing");
234        assert_eq!(m.body, "Body.");
235    }
236
237    #[test]
238    fn a_message_survives_the_round_trip() {
239        let text = "feat(commit)!: the value renders\n\nWhy.\n\nRefs: I1305\n";
240        assert_eq!(CommitMessage::parse(text).render(), text);
241    }
242
243    #[test]
244    fn both_spellings_of_breaking_are_read() {
245        assert!(CommitMessage::parse("feat(api)!: moved").breaking());
246        assert!(CommitMessage::parse("feat(api): moved\n\nBREAKING CHANGE: why").breaking());
247        assert!(CommitMessage::parse("feat(api): moved\n\nBREAKING-CHANGE: why").breaking());
248        assert!(!CommitMessage::parse("feat(api): moved\n\nRefs: I1305").breaking());
249    }
250}