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() -> 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}