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(settings: &crate::config::Settings) -> Style {
170 let names = config::present(settings, 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 settings,
179 KEY_GITMOJI,
180 &GITMOJI_WORDS,
181 d.gitmoji.as_str(),
182 ))
183 .unwrap_or(d.gitmoji)
184 } else {
185 d.gitmoji
186 },
187 subject_max: read_limit(
188 settings,
189 &names,
190 KEY_SUBJECT_MAX,
191 d.subject_max,
192 LIMIT_RANGE,
193 ),
194 description_max: read_limit(
195 settings,
196 &names,
197 KEY_DESCRIPTION_MAX,
198 d.description_max,
199 LIMIT_RANGE,
200 ),
201 body_wrap: read_limit(settings, &names, KEY_BODY_WRAP, d.body_wrap, WRAP_RANGE),
202 }
203 }
204
205 /// Settings that cannot do what they look like they do.
206 ///
207 /// Deliberately **not** printed by the hook. The commit path announces what
208 /// is in effect; `amont list` and `amont setup` — the two commands
209 /// whose whole job is reading configuration back — are where a setting that
210 /// makes no sense belongs. Putting a coherence essay in front of every
211 /// commit is how people learn to stop reading hook output.
212 pub fn warnings(&self) -> Vec<String> {
213 let mut out = Vec::new();
214 if self.description_max + SHORTEST_PREFIX > self.subject_max {
215 out.push(format!(
216 "{KEY_DESCRIPTION_MAX} ({}) can never bind — the subject limit is {} and the \
217 shortest prefix is {SHORTEST_PREFIX} characters",
218 self.description_max, self.subject_max
219 ));
220 }
221 out
222 }
223}
224
225fn read_limit(
226 settings: &crate::config::Settings,
227 names: &std::collections::BTreeSet<String>,
228 key: &str,
229 default: usize,
230 range: std::ops::RangeInclusive<i64>,
231) -> usize {
232 if !config::is_present(names, key) {
233 return default;
234 }
235 config::integer_or(settings, key, default as i64, range).max(0) as usize
236}
237
238/// One row of `amont list`'s commit-style block.
239pub struct Setting {
240 pub key: &'static str,
241 /// The words a human reads, e.g. `description max`.
242 pub label: &'static str,
243 pub value: String,
244 pub default: String,
245 /// The effective value differs from the shipped one — the same meaning
246 /// `CheckListing::severity_overridden` carries.
247 pub overridden: bool,
248 /// Somebody set this key, wherever the value landed. Distinct from
249 /// `overridden` on purpose: a key pinned to the default value is still
250 /// worth showing the origin of, because a reader deciding whether to
251 /// change it wants to know a file already mentions it.
252 pub set_here: bool,
253 pub scope: Scope,
254}
255
256/// The effective style, plus where each value came from.
257///
258/// Costs one `--show-origin` call per overridden key, so this is for `list` and
259/// `setup` only — never the commit path. See [`config::scope_of`].
260pub fn describe(settings: &crate::config::Settings) -> (Style, Vec<Setting>) {
261 let style = Style::resolve(settings);
262 let d = Style::default();
263 let rows = vec![
264 row(
265 settings,
266 KEY_GITMOJI,
267 "gitmoji",
268 style.gitmoji.as_str().to_string(),
269 d.gitmoji.as_str().to_string(),
270 ),
271 row(
272 settings,
273 KEY_SUBJECT_MAX,
274 "subject max",
275 style.subject_max.to_string(),
276 d.subject_max.to_string(),
277 ),
278 row(
279 settings,
280 KEY_DESCRIPTION_MAX,
281 "description max",
282 style.description_max.to_string(),
283 d.description_max.to_string(),
284 ),
285 row(
286 settings,
287 KEY_BODY_WRAP,
288 "body wrap",
289 wrap_word(style.body_wrap),
290 wrap_word(d.body_wrap),
291 ),
292 ];
293 (style, rows)
294}
295
296/// `0` is a column number nobody set out to choose; the word says what it does.
297fn wrap_word(n: usize) -> String {
298 if n == 0 {
299 "off".to_string()
300 } else {
301 n.to_string()
302 }
303}
304
305fn row(
306 settings: &crate::config::Settings,
307 key: &'static str,
308 label: &'static str,
309 value: String,
310 default: String,
311) -> Setting {
312 let scope = config::scope_of(settings, key);
313 Setting {
314 key,
315 label,
316 overridden: value != default,
317 set_here: scope != Scope::Default,
318 value,
319 default,
320 scope,
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn the_shipped_defaults_are_what_the_docs_promise() {
330 let d = Style::default();
331 assert_eq!(d.gitmoji, Gitmoji::None);
332 assert_eq!(d.subject_max, 72);
333 assert_eq!(d.description_max, 50);
334 assert_eq!(d.body_wrap, 72);
335 }
336
337 /// Every placement round-trips through the word that names it, because
338 /// that word is what `git config` stores and `amont setup` writes.
339 #[test]
340 fn every_placement_parses_from_its_own_name() {
341 for g in Gitmoji::ALL {
342 assert_eq!(Gitmoji::parse(g.as_str()), Some(g), "{}", g.as_str());
343 assert!(!g.explain().is_empty());
344 }
345 assert_eq!(Gitmoji::parse("sideways"), None);
346 assert_eq!(GITMOJI_WORDS.len(), Gitmoji::ALL.len());
347 }
348
349 /// The words offered to `git config` and the variants the code knows must
350 /// be the same set, or a value the wizard writes is one the hook rejects.
351 #[test]
352 fn the_accepted_words_are_exactly_the_placements() {
353 for word in GITMOJI_WORDS {
354 assert!(Gitmoji::parse(word).is_some(), "{word} has no variant");
355 }
356 }
357
358 /// The defaults must not warn about themselves.
359 #[test]
360 fn the_defaults_are_coherent() {
361 assert!(Style::default().warnings().is_empty());
362 }
363
364 /// A description budget the subject limit can never accommodate is a
365 /// setting that silently does nothing — the exact shape of misconfiguration
366 /// this project refuses to stay quiet about.
367 #[test]
368 fn a_description_budget_that_can_never_bind_is_reported() {
369 let s = Style {
370 description_max: 50,
371 subject_max: 52,
372 ..Style::default()
373 };
374 let w = s.warnings();
375 assert_eq!(w.len(), 1, "{w:?}");
376 assert!(w[0].contains(KEY_DESCRIPTION_MAX), "{w:?}");
377
378 // Exactly enough room is not a warning: `add: ` + 50 = 55.
379 let ok = Style {
380 description_max: 50,
381 subject_max: 55,
382 ..Style::default()
383 };
384 assert!(ok.warnings().is_empty(), "{:?}", ok.warnings());
385 }
386
387 #[test]
388 fn a_zero_wrap_column_reads_as_off() {
389 assert_eq!(wrap_word(0), "off");
390 assert_eq!(wrap_word(72), "72");
391 }
392}