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}