Skip to main content

amont_runtime/
config.rs

1//! Reading configuration — and reading it the way git itself does.
2//!
3//! Everything this project can be tuned with is a `git config` key, so the
4//! honest implementation of that promise is to let git do the parsing.
5//! `git config --type=bool` implements git-config(1) by definition: `on`,
6//! `yes`, `1`, an empty value, and every capitalisation of each. A hand-rolled
7//! `matches!(v, "true" | "1" | "yes")` is our own dialect wearing git's
8//! clothes, and it had already drifted — `git config amont.fix on` looked
9//! like it worked and did not.
10//!
11//! The exit code carries the part that matters most:
12//!
13//! | exit | means |
14//! |---|---|
15//! | 0 | the key is set, and stdout is git's normalised value |
16//! | 1 | the key is not set anywhere git looked |
17//! | 128 | the key is set to something git refuses to parse, and said so on stderr |
18//!
19//! Collapsing 1 and 128 into "no" is the bug this module exists to prevent: a
20//! limit you believe you raised and did not is exactly the silent-config
21//! failure that `hook.skip` announcements were introduced for. So a bad value
22//! falls back to the shipped default **and says so**, once per key per run.
23
24use crate::git;
25use crate::ui::{highlight, warning_sign};
26use std::collections::BTreeSet;
27use std::ops::RangeInclusive;
28use std::sync::{Mutex, OnceLock};
29
30/// What a key said. Three answers, because "unset" is a state this project
31/// makes decisions with — `amont.commit.gitmoji` has four meanings and one
32/// of them is absence.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Value<T> {
35    Unset,
36    Set(T),
37    /// Set to something that could not be read. `why` is git's own diagnostic
38    /// where git produced one, and ours where the constraint is ours (a value
39    /// outside an allowed set, or outside a range).
40    Bad {
41        why: String,
42    },
43}
44
45impl<T> Value<T> {
46    pub fn is_set(&self) -> bool {
47        matches!(self, Value::Set(_))
48    }
49}
50
51/// `git config --type=<ty> --get <key>`, with the three exits kept apart.
52///
53/// Git failing to run at all is reported as `Unset`: this crate's standing
54/// posture is that an unanswerable question takes the default rather than
55/// blocking a commit.
56fn typed(key: &str, ty: &str) -> Value<String> {
57    let type_flag = format!("--type={ty}");
58    let Some(out) = git::output(&["config", &type_flag, "--get", key]) else {
59        return Value::Unset;
60    };
61    match out.code {
62        0 => Value::Set(out.stdout),
63        1 => Value::Unset,
64        _ => Value::Bad {
65            why: first_line(&out.stderr),
66        },
67    }
68}
69
70/// Git's `fatal:` line, without the noise around it. Empty stderr still yields
71/// something printable, because a warning that names no cause is a puzzle.
72fn first_line(stderr: &str) -> String {
73    let line = stderr.lines().next().unwrap_or("").trim();
74    let line = line.strip_prefix("fatal: ").unwrap_or(line);
75    if line.is_empty() {
76        "git could not read the value".to_string()
77    } else {
78        line.to_string()
79    }
80}
81
82pub fn boolean(key: &str) -> Value<bool> {
83    match typed(key, "bool") {
84        Value::Set(v) => match v.as_str() {
85            "true" => Value::Set(true),
86            "false" => Value::Set(false),
87            other => Value::Bad {
88                why: format!("git normalised it to {other:?}, which is neither true nor false"),
89            },
90        },
91        Value::Unset => Value::Unset,
92        Value::Bad { why } => Value::Bad { why },
93    }
94}
95
96/// An integer, in git's own spelling — which includes the `k`/`m`/`g` suffixes
97/// git accepts, since `--type=int` expands them before we see them.
98pub fn integer(key: &str) -> Value<i64> {
99    match typed(key, "int") {
100        Value::Set(v) => match v.parse::<i64>() {
101            Ok(n) => Value::Set(n),
102            Err(_) => Value::Bad {
103                why: format!("git returned {v:?}, which is not a whole number"),
104            },
105        },
106        Value::Unset => Value::Unset,
107        Value::Bad { why } => Value::Bad { why },
108    }
109}
110
111/// One of a fixed set of words, compared case-insensitively.
112///
113/// Git has no `--type` for this, so the value is read raw and checked here —
114/// which means this is the one reader whose `Bad` message is ours. It names
115/// every accepted spelling, because a rejection that does not say what was
116/// wanted sends the reader to the documentation for a list we already hold.
117pub fn enumerated(key: &str, allowed: &[&'static str]) -> Value<&'static str> {
118    let Some(out) = git::output(&["config", "--get", key]) else {
119        return Value::Unset;
120    };
121    match out.code {
122        0 => {
123            let got = out.stdout.trim().to_ascii_lowercase();
124            match allowed.iter().find(|a| a.eq_ignore_ascii_case(&got)) {
125                Some(hit) => Value::Set(hit),
126                None => Value::Bad {
127                    why: format!("{got:?} is not one of {}", allowed.join(", ")),
128                },
129            }
130        }
131        1 => Value::Unset,
132        _ => Value::Bad {
133            why: first_line(&out.stderr),
134        },
135    }
136}
137
138/// Say once, per key, that a configured value could not be used.
139///
140/// Deduplicated because a key read twice in one run is a detail of how the
141/// code is arranged, and repeating the warning would make it look like two
142/// separate mistakes.
143pub fn complain(key: &str, why: &str, using: &str) {
144    static SAID: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
145    let said = SAID.get_or_init(|| Mutex::new(BTreeSet::new()));
146    // A poisoned mutex means another thread panicked mid-insert; warning twice
147    // is strictly better than joining it in panicking.
148    let fresh = match said.lock() {
149        Ok(mut set) => set.insert(key.to_string()),
150        Err(_) => true,
151    };
152    if fresh {
153        eprintln!(
154            "{} {}: {why} — using {using}",
155            warning_sign().trim(),
156            highlight(key)
157        );
158    }
159}
160
161pub fn boolean_or(key: &str, default: bool) -> bool {
162    match boolean(key) {
163        Value::Set(v) => v,
164        Value::Unset => default,
165        Value::Bad { why } => {
166            complain(key, &why, &default.to_string());
167            default
168        }
169    }
170}
171
172/// An integer, clamped to what the setting can actually mean.
173///
174/// Out of range is treated exactly as unparseable: a `subjectMax` of 0 would
175/// block every commit forever from a config file, and one of 10_000 is not a
176/// limit. Both are mistakes, and both get the default plus a line saying so.
177pub fn integer_or(key: &str, default: i64, range: RangeInclusive<i64>) -> i64 {
178    match integer(key) {
179        Value::Set(v) if range.contains(&v) => v,
180        Value::Set(v) => {
181            complain(
182                key,
183                &format!("{v} is outside {}..={}", range.start(), range.end()),
184                &default.to_string(),
185            );
186            default
187        }
188        Value::Unset => default,
189        Value::Bad { why } => {
190            complain(key, &why, &default.to_string());
191            default
192        }
193    }
194}
195
196pub fn enumerated_or(key: &str, allowed: &[&'static str], default: &'static str) -> &'static str {
197    match enumerated(key, allowed) {
198        Value::Set(v) => v,
199        Value::Unset => default,
200        Value::Bad { why } => {
201            complain(key, &why, default);
202            default
203        }
204    }
205}
206
207/// Which keys under `prefix` are set at all — one git call for the whole
208/// family.
209///
210/// This runs on the commit path, where four independent `--get` calls would be
211/// four processes spent discovering that nobody has configured anything. One
212/// `--get-regexp` answers that, and only the keys it names are read for real.
213/// Same shape as `registry::Overrides::read`, for the same reason.
214///
215/// **Names come back lowercased.** Git lowercases the section and key parts of
216/// every name it prints, so `amont.commit.subjectMax` is reported as
217/// `amont.commit.subjectmax`. Keys are case-insensitive on lookup, so this
218/// only affects comparison here — hence [`is_present`] rather than a bare
219/// `contains`.
220pub fn present(prefix: &str) -> BTreeSet<String> {
221    let pattern = format!("^{}", regex_escape(prefix));
222    let Some(out) = git::output(&["config", "--get-regexp", &pattern]) else {
223        return BTreeSet::new();
224    };
225    if out.code != 0 {
226        return BTreeSet::new();
227    }
228    out.stdout
229        .lines()
230        .filter_map(|l| l.split_whitespace().next())
231        .map(|k| k.to_ascii_lowercase())
232        .collect()
233}
234
235/// Is `key` among the names [`present`] returned? Case-insensitive, because
236/// git config key names are.
237pub fn is_present(names: &BTreeSet<String>, key: &str) -> bool {
238    names.contains(&key.to_ascii_lowercase())
239}
240
241/// Escape the characters a config key can hold that a POSIX basic regex would
242/// otherwise read as syntax. Only `.` occurs in practice; the rest are here so
243/// this cannot become wrong if a caller passes something else.
244fn regex_escape(s: &str) -> String {
245    let mut out = String::with_capacity(s.len() * 2);
246    for c in s.chars() {
247        if matches!(c, '.' | '*' | '[' | ']' | '^' | '$' | '\\') {
248            out.push('\\');
249        }
250        out.push(c);
251    }
252    out
253}
254
255/// Where a key's value came from, for the commands whose job is reading
256/// configuration back.
257///
258/// This costs a second git call per key and must never be used on the commit
259/// path — `amont list` and `amont setup` are the only callers, and they
260/// are already asking git several questions to render one screen.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum Scope {
263    Default,
264    Local,
265    Global,
266    System,
267    CommandLine,
268    Other,
269}
270
271impl Scope {
272    pub fn as_str(self) -> &'static str {
273        match self {
274            Scope::Default => "default",
275            Scope::Local => "local",
276            Scope::Global => "global",
277            Scope::System => "system",
278            Scope::CommandLine => "command line",
279            Scope::Other => "other",
280        }
281    }
282}
283
284/// `git config --show-origin --get <key>` → which file it came from.
285///
286/// `Default` for a key nobody set, which is also the answer when git cannot be
287/// asked — the value in use is the shipped one either way.
288pub fn scope_of(key: &str) -> Scope {
289    let Some(out) = git::output(&["config", "--show-origin", "--get", key]) else {
290        return Scope::Default;
291    };
292    if out.code != 0 {
293        return Scope::Default;
294    }
295    // `<origin>\t<value>`; the origin is `file:/path`, `command line:` or
296    // `blob:…`, and the path is what tells local from global.
297    let origin = out.stdout.split('\t').next().unwrap_or("");
298    if origin.starts_with("command line") {
299        return Scope::CommandLine;
300    }
301    let Some(path) = origin.strip_prefix("file:") else {
302        return Scope::Other;
303    };
304    let path = path.trim();
305    // A repository's own config is the only one inside a `.git` directory;
306    // asking git for the paths rather than guessing at `~` keeps this correct
307    // under `GIT_CONFIG_GLOBAL`, worktrees and `$XDG_CONFIG_HOME`.
308    if same_file(
309        path,
310        git::stdout(&["rev-parse", "--git-path", "config"]).as_deref(),
311    ) {
312        return Scope::Local;
313    }
314    for (flag, scope) in [("--global", Scope::Global), ("--system", Scope::System)] {
315        let listed = git::output(&["config", flag, "--list", "--show-origin"]);
316        if let Some(o) = listed {
317            if o.code == 0
318                && o.stdout
319                    .lines()
320                    .filter_map(|l| l.split('\t').next())
321                    .filter_map(|o| o.strip_prefix("file:"))
322                    .any(|p| same_file(path, Some(p.trim())))
323            {
324                return scope;
325            }
326        }
327    }
328    Scope::Other
329}
330
331/// Compare two paths as the same file where the filesystem can say so, falling
332/// back to the strings. `--git-path` answers relatively (`.git/config`) while
333/// `--show-origin` may answer absolutely, so a string comparison alone
334/// misreports a local key as `other`.
335fn same_file(a: &str, b: Option<&str>) -> bool {
336    let Some(b) = b else { return false };
337    if a == b {
338        return true;
339    }
340    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
341        (Ok(x), Ok(y)) => x == y,
342        _ => false,
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    /// The distinction the whole module exists for: git's two failure exits
351    /// mean opposite things, and `first_line` is what a reader is shown for
352    /// the one that is a mistake.
353    #[test]
354    fn a_fatal_line_is_reported_without_its_prefix() {
355        assert_eq!(
356            first_line("fatal: bad numeric config value 'wide' for 'a.b'"),
357            "bad numeric config value 'wide' for 'a.b'"
358        );
359        assert_eq!(first_line("first\nsecond"), "first");
360    }
361
362    /// A warning that names no cause is a puzzle, so there is always a cause.
363    #[test]
364    fn an_empty_diagnostic_still_says_something() {
365        assert!(!first_line("").is_empty());
366        assert!(!first_line("   \n  ").is_empty());
367    }
368
369    #[test]
370    fn a_key_name_is_escaped_before_it_becomes_a_pattern() {
371        // Without escaping, `.` matches any character and the prefix would
372        // also select `amontXcommit.*`.
373        assert_eq!(regex_escape("amont.commit."), "amont\\.commit\\.");
374        assert_eq!(regex_escape("plain"), "plain");
375    }
376
377    /// Git lowercases the names it prints, so a presence test that respected
378    /// case would answer "not set" for every camelCase key we ship.
379    #[test]
380    fn presence_is_case_insensitive_because_git_lowercases_names() {
381        let names: BTreeSet<String> = ["amont.commit.subjectmax".to_string()]
382            .into_iter()
383            .collect();
384        assert!(is_present(&names, "amont.commit.subjectMax"));
385        assert!(is_present(&names, "AMONT.COMMIT.SUBJECTMAX"));
386        assert!(!is_present(&names, "amont.commit.bodyWrap"));
387    }
388
389    #[test]
390    fn every_scope_has_a_name() {
391        for s in [
392            Scope::Default,
393            Scope::Local,
394            Scope::Global,
395            Scope::System,
396            Scope::CommandLine,
397            Scope::Other,
398        ] {
399            assert!(!s.as_str().is_empty());
400        }
401    }
402}