Skip to main content

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}