amont_runtime/commit_style.rs
1//! What `commit-msg` enforces, and how it decorates — as four `git config`
2//! keys.
3//!
4//! `commit-msg` is an entrypoint rather than a `Check` (see `registry.rs`), so
5//! `hook.skip` and `amont.severity.*` do not reach it, and git exempts it
6//! from `--no-verify`. That left the hook that touches every single commit with
7//! no dial at all: its two most divisive rules — the gitmoji and the 50
8//! character description budget — could be complied with or uninstalled, and
9//! nothing in between. Every other opinion this project holds has a dial. These
10//! are the ones this one was missing.
11//!
12//! The keys are read through [`crate::config`], so a value git cannot parse
13//! falls back to the shipped default **and says so**.
14
15use crate::config::{self, Scope};
16
17pub const KEY_GITMOJI: &str = "amont.commit.gitmoji";
18pub const KEY_SUBJECT_MAX: &str = "amont.commit.subjectMax";
19pub const KEY_DESCRIPTION_MAX: &str = "amont.commit.descriptionMax";
20pub const KEY_BODY_WRAP: &str = "amont.commit.bodyWrap";
21
22/// The prefix every key above shares — one `--get-regexp` finds the family.
23const PREFIX: &str = "amont.commit.";
24
25pub const DEFAULT_GITMOJI: Gitmoji = Gitmoji::None;
26pub const DEFAULT_SUBJECT_MAX: usize = 72;
27pub const DEFAULT_DESCRIPTION_MAX: usize = 50;
28pub const DEFAULT_BODY_WRAP: usize = 72;
29
30/// A limit below 1 is unsatisfiable and would block every commit forever from a
31/// config file; above 1000 it is not a limit. Both are mistakes, and
32/// [`config::integer_or`] reports them as such.
33const LIMIT_RANGE: std::ops::RangeInclusive<i64> = 1..=1000;
34/// The same, plus `0` — which for the wrap column means "leave my body alone",
35/// the setting that keeps a stack trace or a fenced code block intact.
36const WRAP_RANGE: std::ops::RangeInclusive<i64> = 0..=1000;
37
38/// The shortest thing that can stand before a description: the shortest type
39/// (`add`, `fix`) plus the required colon and space.
40const SHORTEST_PREFIX: usize = 5;
41
42/// Where the type's gitmoji goes, if anywhere.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Gitmoji {
45 /// Leave the subject as written. The type prefix is still required and
46 /// still validated — this decides decoration, never enforcement.
47 None,
48 /// `✨ feat: add a cart`
49 Prefix,
50 /// `feat: add a cart ✨` — the tooling-friendly placement: commitlint,
51 /// changelog generators and `git log --grep '^feat'` all still see a clean
52 /// conventional subject at the start of the line.
53 Suffix,
54 /// `✨ add a cart` — the emoji stands in for the type word.
55 ///
56 /// You still *write* `feat: add a cart`, and it is still validated as
57 /// such; only what gets stored differs. Know what it costs: the stored
58 /// history is no longer parseable by conventional-commit tooling, because
59 /// the type is now carried by an emoji. This mode chooses how the log looks
60 /// over what can read it, and that is a real trade, not a free one.
61 Replace,
62}
63
64impl Gitmoji {
65 pub const ALL: [Gitmoji; 4] = [
66 Gitmoji::None,
67 Gitmoji::Prefix,
68 Gitmoji::Suffix,
69 Gitmoji::Replace,
70 ];
71
72 pub fn as_str(self) -> &'static str {
73 match self {
74 Gitmoji::None => "none",
75 Gitmoji::Prefix => "prefix",
76 Gitmoji::Suffix => "suffix",
77 Gitmoji::Replace => "replace",
78 }
79 }
80
81 pub fn parse(s: &str) -> Option<Gitmoji> {
82 Gitmoji::ALL.into_iter().find(|g| g.as_str() == s)
83 }
84
85 /// The one-line description `amont setup` and `amont list` show, so
86 /// the four words never have to be looked up elsewhere.
87 pub fn explain(self) -> &'static str {
88 match self {
89 Gitmoji::None => "leave the subject as written",
90 Gitmoji::Prefix => "before the type",
91 Gitmoji::Suffix => "after the description — tooling still reads the type",
92 Gitmoji::Replace => "instead of the type — conventional-commit tools stop reading it",
93 }
94 }
95
96 /// `feat: add a cart` rendered in this placement, for a menu.
97 pub fn example(self) -> String {
98 render_subject(self, "feat", "", "", "add a cart")
99 }
100}
101
102/// The one place a decorated subject line is built.
103///
104/// `commit-msg` writes the real thing and `amont setup` renders the menu
105/// through the same function, so the example somebody chooses from is produced
106/// by the code that will run — not by a string that has to be kept in step
107/// with it.
108pub fn render_subject(
109 placement: Gitmoji,
110 prefix: &str,
111 scope: &str,
112 breaking: &str,
113 description: &str,
114) -> String {
115 let emoji = crate::vocabulary::emoji_for(prefix);
116 let conventional = format!("{prefix}{scope}{breaking}: {description}");
117 match placement {
118 Gitmoji::None => conventional,
119 Gitmoji::Prefix => format!("{emoji} {conventional}"),
120 Gitmoji::Suffix => format!("{conventional} {emoji}"),
121 // The type word is what the emoji stands in for; a scope and a
122 // breaking marker are not types and stay exactly where they were.
123 Gitmoji::Replace => {
124 let rest = format!("{scope}{breaking}");
125 if rest.is_empty() {
126 format!("{emoji} {description}")
127 } else {
128 format!("{emoji} {rest}: {description}")
129 }
130 }
131 }
132}
133
134const GITMOJI_WORDS: [&str; 4] = ["none", "prefix", "suffix", "replace"];
135
136/// Everything `commit-msg` needs to know about how this repository wants its
137/// messages checked and formatted.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct Style {
140 pub gitmoji: Gitmoji,
141 pub subject_max: usize,
142 pub description_max: usize,
143 /// `0` means never wrap.
144 pub body_wrap: usize,
145}
146
147impl Default for Style {
148 fn default() -> Self {
149 Style {
150 gitmoji: DEFAULT_GITMOJI,
151 subject_max: DEFAULT_SUBJECT_MAX,
152 description_max: DEFAULT_DESCRIPTION_MAX,
153 body_wrap: DEFAULT_BODY_WRAP,
154 }
155 }
156}
157
158impl Style {
159 /// Read the four keys from the repository's git config.
160 ///
161 /// One `--get-regexp` first, then a typed read only for the keys it named.
162 /// With nothing configured — the overwhelming case, and the one on the
163 /// commit path — that is a single extra process rather than four.
164 ///
165 /// Measured on the unconfigured path, 50 runs each: 26.2 ms before this
166 /// landed, 26.0 ms after. A wash, because the prescan replaced a process
167 /// rather than adding one — the `git remote get-url upstream` call the old
168 /// fork-suppression heuristic made on every single commit is gone.
169 pub fn resolve() -> Style {
170 let names = config::present(PREFIX);
171 if names.is_empty() {
172 return Style::default();
173 }
174 let d = Style::default();
175 Style {
176 gitmoji: if config::is_present(&names, KEY_GITMOJI) {
177 Gitmoji::parse(config::enumerated_or(
178 KEY_GITMOJI,
179 &GITMOJI_WORDS,
180 d.gitmoji.as_str(),
181 ))
182 .unwrap_or(d.gitmoji)
183 } else {
184 d.gitmoji
185 },
186 subject_max: read_limit(&names, KEY_SUBJECT_MAX, d.subject_max, LIMIT_RANGE),
187 description_max: read_limit(
188 &names,
189 KEY_DESCRIPTION_MAX,
190 d.description_max,
191 LIMIT_RANGE,
192 ),
193 body_wrap: read_limit(&names, KEY_BODY_WRAP, d.body_wrap, WRAP_RANGE),
194 }
195 }
196
197 /// Settings that cannot do what they look like they do.
198 ///
199 /// Deliberately **not** printed by the hook. The commit path announces what
200 /// is in effect; `amont list` and `amont setup` — the two commands
201 /// whose whole job is reading configuration back — are where a setting that
202 /// makes no sense belongs. Putting a coherence essay in front of every
203 /// commit is how people learn to stop reading hook output.
204 pub fn warnings(&self) -> Vec<String> {
205 let mut out = Vec::new();
206 if self.description_max + SHORTEST_PREFIX > self.subject_max {
207 out.push(format!(
208 "{KEY_DESCRIPTION_MAX} ({}) can never bind — the subject limit is {} and the \
209 shortest prefix is {SHORTEST_PREFIX} characters",
210 self.description_max, self.subject_max
211 ));
212 }
213 out
214 }
215}
216
217fn read_limit(
218 names: &std::collections::BTreeSet<String>,
219 key: &str,
220 default: usize,
221 range: std::ops::RangeInclusive<i64>,
222) -> usize {
223 if !config::is_present(names, key) {
224 return default;
225 }
226 config::integer_or(key, default as i64, range).max(0) as usize
227}
228
229/// One row of `amont list`'s commit-style block.
230pub struct Setting {
231 pub key: &'static str,
232 /// The words a human reads, e.g. `description max`.
233 pub label: &'static str,
234 pub value: String,
235 pub default: String,
236 /// The effective value differs from the shipped one — the same meaning
237 /// `CheckListing::severity_overridden` carries.
238 pub overridden: bool,
239 /// Somebody set this key, wherever the value landed. Distinct from
240 /// `overridden` on purpose: a key pinned to the default value is still
241 /// worth showing the origin of, because a reader deciding whether to
242 /// change it wants to know a file already mentions it.
243 pub set_here: bool,
244 pub scope: Scope,
245}
246
247/// The effective style, plus where each value came from.
248///
249/// Costs one `--show-origin` call per overridden key, so this is for `list` and
250/// `setup` only — never the commit path. See [`config::scope_of`].
251pub fn describe() -> (Style, Vec<Setting>) {
252 let style = Style::resolve();
253 let d = Style::default();
254 let rows = vec![
255 row(
256 KEY_GITMOJI,
257 "gitmoji",
258 style.gitmoji.as_str().to_string(),
259 d.gitmoji.as_str().to_string(),
260 ),
261 row(
262 KEY_SUBJECT_MAX,
263 "subject max",
264 style.subject_max.to_string(),
265 d.subject_max.to_string(),
266 ),
267 row(
268 KEY_DESCRIPTION_MAX,
269 "description max",
270 style.description_max.to_string(),
271 d.description_max.to_string(),
272 ),
273 row(
274 KEY_BODY_WRAP,
275 "body wrap",
276 wrap_word(style.body_wrap),
277 wrap_word(d.body_wrap),
278 ),
279 ];
280 (style, rows)
281}
282
283/// `0` is a column number nobody set out to choose; the word says what it does.
284fn wrap_word(n: usize) -> String {
285 if n == 0 {
286 "off".to_string()
287 } else {
288 n.to_string()
289 }
290}
291
292fn row(key: &'static str, label: &'static str, value: String, default: String) -> Setting {
293 let scope = config::scope_of(key);
294 Setting {
295 key,
296 label,
297 overridden: value != default,
298 set_here: scope != Scope::Default,
299 value,
300 default,
301 scope,
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn the_shipped_defaults_are_what_the_docs_promise() {
311 let d = Style::default();
312 assert_eq!(d.gitmoji, Gitmoji::None);
313 assert_eq!(d.subject_max, 72);
314 assert_eq!(d.description_max, 50);
315 assert_eq!(d.body_wrap, 72);
316 }
317
318 /// Every placement round-trips through the word that names it, because
319 /// that word is what `git config` stores and `amont setup` writes.
320 #[test]
321 fn every_placement_parses_from_its_own_name() {
322 for g in Gitmoji::ALL {
323 assert_eq!(Gitmoji::parse(g.as_str()), Some(g), "{}", g.as_str());
324 assert!(!g.explain().is_empty());
325 }
326 assert_eq!(Gitmoji::parse("sideways"), None);
327 assert_eq!(GITMOJI_WORDS.len(), Gitmoji::ALL.len());
328 }
329
330 /// The words offered to `git config` and the variants the code knows must
331 /// be the same set, or a value the wizard writes is one the hook rejects.
332 #[test]
333 fn the_accepted_words_are_exactly_the_placements() {
334 for word in GITMOJI_WORDS {
335 assert!(Gitmoji::parse(word).is_some(), "{word} has no variant");
336 }
337 }
338
339 /// The defaults must not warn about themselves.
340 #[test]
341 fn the_defaults_are_coherent() {
342 assert!(Style::default().warnings().is_empty());
343 }
344
345 /// A description budget the subject limit can never accommodate is a
346 /// setting that silently does nothing — the exact shape of misconfiguration
347 /// this project refuses to stay quiet about.
348 #[test]
349 fn a_description_budget_that_can_never_bind_is_reported() {
350 let s = Style {
351 description_max: 50,
352 subject_max: 52,
353 ..Style::default()
354 };
355 let w = s.warnings();
356 assert_eq!(w.len(), 1, "{w:?}");
357 assert!(w[0].contains(KEY_DESCRIPTION_MAX), "{w:?}");
358
359 // Exactly enough room is not a warning: `add: ` + 50 = 55.
360 let ok = Style {
361 description_max: 50,
362 subject_max: 55,
363 ..Style::default()
364 };
365 assert!(ok.warnings().is_empty(), "{:?}", ok.warnings());
366 }
367
368 #[test]
369 fn a_zero_wrap_column_reads_as_off() {
370 assert_eq!(wrap_word(0), "off");
371 assert_eq!(wrap_word(72), "72");
372 }
373}