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/// As [`typed`], but reading a POLICY-SUPPLIED literal instead of the
71/// machine's config: `git -c <key>=<raw> config --type=<ty> --get <key>`.
72/// GIT parses the value, so `Value::Bad` and `complain` work unchanged and
73/// no second config dialect exists — the founding argument of this module.
74fn typed_literal(key: &str, raw: &str, ty: Option<&str>) -> Value<String> {
75    let assignment = format!("{key}={raw}");
76    let mut args: Vec<&str> = vec!["-c", &assignment, "config"];
77    let type_flag = ty.map(|t| format!("--type={t}"));
78    if let Some(tf) = &type_flag {
79        args.push(tf);
80    }
81    args.extend(["--get", key]);
82    let Some(out) = git::output(&args) else {
83        return Value::Unset;
84    };
85    match out.code {
86        0 => Value::Set(out.stdout),
87        1 => Value::Unset,
88        _ => Value::Bad {
89            why: first_line(&out.stderr),
90        },
91    }
92}
93
94/// The ladder, as a short-circuit rather than a general mechanism:
95///
96///   policy has no value for `key`      → [`typed`], byte-for-byte as before
97///   key set at local/worktree/command  → [`typed`] — the machine wins
98///   otherwise                          → [`typed_literal`] — policy wins
99///
100/// The invariant this shape buys: a repository with no `set` lines spawns
101/// not one extra git process anywhere — the scoped scan below runs only
102/// when the policy actually carries settings.
103fn resolve(key: &str, ty: Option<&str>) -> Value<String> {
104    let policy = crate::policy::current();
105    let Some(raw) = policy.settings.get(key) else {
106        return match ty {
107            Some(t) => typed(key, t),
108            None => untyped(key),
109        };
110    };
111    if key_set_above_policy(key) {
112        return match ty {
113            Some(t) => typed(key, t),
114            None => untyped(key),
115        };
116    }
117    typed_literal(key, raw, ty)
118}
119
120/// The raw read `enumerated` has always done, factored so `resolve` can
121/// route it.
122fn untyped(key: &str) -> Value<String> {
123    let Some(out) = git::output(&["config", "--get", key]) else {
124        return Value::Unset;
125    };
126    match out.code {
127        0 => Value::Set(out.stdout),
128        1 => Value::Unset,
129        _ => Value::Bad {
130            why: first_line(&out.stderr),
131        },
132    }
133}
134
135/// Is `key` set at a scope that outranks policy (local/worktree/command)?
136///
137/// One lazily-cached `git config --show-scope --get-regexp '^amont\.'` for
138/// the whole process — this is the PRECEDENCE reader, deliberately separate
139/// from [`scope_of`], which is `--show-origin`-based and display-oriented.
140/// Scope words `system`/`global` rank below policy; every other word —
141/// `local`, `worktree`, `command`, and whatever a future git invents —
142/// ranks above, because misreading a local as below would let a file pulled
143/// from a remote silently override a person's explicit machine setting.
144///
145/// On a git too old for `--show-scope` the cache is `None` and this
146/// DEGRADES fail-safe: any set key counts as above, i.e. all git config
147/// beats policy.
148fn key_set_above_policy(key: &str) -> bool {
149    static SCOPED: std::sync::OnceLock<Option<std::collections::BTreeSet<String>>> =
150        std::sync::OnceLock::new();
151    let above = SCOPED.get_or_init(|| {
152        crate::git::stdout(&["config", "--show-scope", "--get-regexp", r"^amont\."]).map(|scoped| {
153            scoped
154                .lines()
155                .filter_map(|line| {
156                    let (scope, rest) = line.split_once('\t')?;
157                    match scope {
158                        "system" | "global" => None,
159                        _ => rest.split_whitespace().next().map(str::to_ascii_lowercase),
160                    }
161                })
162                .collect()
163        })
164    });
165    match above {
166        Some(set) => set.contains(&key.to_ascii_lowercase()),
167        // Degraded: no scope information — any set key beats policy.
168        None => untyped(key).is_set(),
169    }
170}
171
172/// Git's `fatal:` line, without the noise around it. Empty stderr still yields
173/// something printable, because a warning that names no cause is a puzzle.
174fn first_line(stderr: &str) -> String {
175    let line = stderr.lines().next().unwrap_or("").trim();
176    let line = line.strip_prefix("fatal: ").unwrap_or(line);
177    if line.is_empty() {
178        "git could not read the value".to_string()
179    } else {
180        line.to_string()
181    }
182}
183
184pub fn boolean(key: &str) -> Value<bool> {
185    match resolve(key, Some("bool")) {
186        Value::Set(v) => match v.as_str() {
187            "true" => Value::Set(true),
188            "false" => Value::Set(false),
189            other => Value::Bad {
190                why: format!("git normalised it to {other:?}, which is neither true nor false"),
191            },
192        },
193        Value::Unset => Value::Unset,
194        Value::Bad { why } => Value::Bad { why },
195    }
196}
197
198/// An integer, in git's own spelling — which includes the `k`/`m`/`g` suffixes
199/// git accepts, since `--type=int` expands them before we see them.
200pub fn integer(key: &str) -> Value<i64> {
201    match resolve(key, Some("int")) {
202        Value::Set(v) => match v.parse::<i64>() {
203            Ok(n) => Value::Set(n),
204            Err(_) => Value::Bad {
205                why: format!("git returned {v:?}, which is not a whole number"),
206            },
207        },
208        Value::Unset => Value::Unset,
209        Value::Bad { why } => Value::Bad { why },
210    }
211}
212
213/// One of a fixed set of words, compared case-insensitively.
214///
215/// Git has no `--type` for this, so the value is read raw and checked here —
216/// which means this is the one reader whose `Bad` message is ours. It names
217/// every accepted spelling, because a rejection that does not say what was
218/// wanted sends the reader to the documentation for a list we already hold.
219pub fn enumerated(key: &str, allowed: &[&'static str]) -> Value<&'static str> {
220    match resolve(key, None) {
221        Value::Set(v) => {
222            let got = v.trim().to_ascii_lowercase();
223            match allowed.iter().find(|a| a.eq_ignore_ascii_case(&got)) {
224                Some(hit) => Value::Set(hit),
225                None => Value::Bad {
226                    why: format!("{got:?} is not one of {}", allowed.join(", ")),
227                },
228            }
229        }
230        Value::Unset => Value::Unset,
231        Value::Bad { why } => Value::Bad { why },
232    }
233}
234
235/// Say once, per key, that a configured value could not be used.
236///
237/// Deduplicated because a key read twice in one run is a detail of how the
238/// code is arranged, and repeating the warning would make it look like two
239/// separate mistakes.
240/// A free-form string key, policy-aware. Untyped reads have no `--type`
241/// for git to refuse, so `Bad` cannot arise — this is Set-or-not.
242pub fn string_value(key: &str) -> Option<String> {
243    match resolve(key, None) {
244        Value::Set(v) => Some(v),
245        _ => None,
246    }
247}
248
249pub fn complain(key: &str, why: &str, using: &str) {
250    static SAID: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
251    let said = SAID.get_or_init(|| Mutex::new(BTreeSet::new()));
252    // A poisoned mutex means another thread panicked mid-insert; warning twice
253    // is strictly better than joining it in panicking.
254    let fresh = match said.lock() {
255        Ok(mut set) => set.insert(key.to_string()),
256        Err(_) => true,
257    };
258    if fresh {
259        eprintln!(
260            "{} {}: {why} — using {using}",
261            warning_sign().trim(),
262            highlight(key)
263        );
264    }
265}
266
267pub fn boolean_or(key: &str, default: bool) -> bool {
268    match boolean(key) {
269        Value::Set(v) => v,
270        Value::Unset => default,
271        Value::Bad { why } => {
272            complain(key, &why, &default.to_string());
273            default
274        }
275    }
276}
277
278/// An integer, clamped to what the setting can actually mean.
279///
280/// Out of range is treated exactly as unparseable: a `subjectMax` of 0 would
281/// block every commit forever from a config file, and one of 10_000 is not a
282/// limit. Both are mistakes, and both get the default plus a line saying so.
283pub fn integer_or(key: &str, default: i64, range: RangeInclusive<i64>) -> i64 {
284    match integer(key) {
285        Value::Set(v) if range.contains(&v) => v,
286        Value::Set(v) => {
287            complain(
288                key,
289                &format!("{v} is outside {}..={}", range.start(), range.end()),
290                &default.to_string(),
291            );
292            default
293        }
294        Value::Unset => default,
295        Value::Bad { why } => {
296            complain(key, &why, &default.to_string());
297            default
298        }
299    }
300}
301
302pub fn enumerated_or(key: &str, allowed: &[&'static str], default: &'static str) -> &'static str {
303    match enumerated(key, allowed) {
304        Value::Set(v) => v,
305        Value::Unset => default,
306        Value::Bad { why } => {
307            complain(key, &why, default);
308            default
309        }
310    }
311}
312
313/// Which keys under `prefix` are set at all — one git call for the whole
314/// family.
315///
316/// This runs on the commit path, where four independent `--get` calls would be
317/// four processes spent discovering that nobody has configured anything. One
318/// `--get-regexp` answers that, and only the keys it names are read for real.
319/// Same shape as `registry::Overrides::read`, for the same reason.
320///
321/// **Names come back lowercased.** Git lowercases the section and key parts of
322/// every name it prints, so `amont.commit.subjectMax` is reported as
323/// `amont.commit.subjectmax`. Keys are case-insensitive on lookup, so this
324/// only affects comparison here — hence [`is_present`] rather than a bare
325/// `contains`.
326pub fn present(prefix: &str) -> BTreeSet<String> {
327    let pattern = format!("^{}", regex_escape(prefix));
328    let policy_names = || -> BTreeSet<String> {
329        crate::policy::current()
330            .settings
331            .keys()
332            .filter(|k| {
333                k.to_ascii_lowercase()
334                    .starts_with(&prefix.to_ascii_lowercase())
335            })
336            .map(|k| k.to_ascii_lowercase())
337            .collect()
338    };
339    let Some(out) = git::output(&["config", "--get-regexp", &pattern]) else {
340        return policy_names();
341    };
342    if out.code != 0 {
343        return policy_names();
344    }
345    let mut names: BTreeSet<String> = out
346        .stdout
347        .lines()
348        .filter_map(|l| l.split_whitespace().next())
349        .map(|k| k.to_ascii_lowercase())
350        .collect();
351    names.extend(
352        crate::policy::current()
353            .settings
354            .keys()
355            .filter(|k| {
356                k.to_ascii_lowercase()
357                    .starts_with(&prefix.to_ascii_lowercase())
358            })
359            .map(|k| k.to_ascii_lowercase()),
360    );
361    names
362}
363
364/// Is `key` among the names [`present`] returned? Case-insensitive, because
365/// git config key names are.
366pub fn is_present(names: &BTreeSet<String>, key: &str) -> bool {
367    names.contains(&key.to_ascii_lowercase())
368}
369
370/// Escape the characters a config key can hold that a POSIX basic regex would
371/// otherwise read as syntax. Only `.` occurs in practice; the rest are here so
372/// this cannot become wrong if a caller passes something else.
373fn regex_escape(s: &str) -> String {
374    let mut out = String::with_capacity(s.len() * 2);
375    for c in s.chars() {
376        if matches!(c, '.' | '*' | '[' | ']' | '^' | '$' | '\\') {
377            out.push('\\');
378        }
379        out.push(c);
380    }
381    out
382}
383
384/// Where a key's value came from, for the commands whose job is reading
385/// configuration back.
386///
387/// This costs a second git call per key and must never be used on the commit
388/// path — `amont list` and `amont setup` are the only callers, and they
389/// are already asking git several questions to render one screen.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum Scope {
392    Default,
393    Local,
394    Global,
395    System,
396    CommandLine,
397    /// The value in effect comes from the repository's committed policy.
398    Policy,
399    Other,
400}
401
402impl Scope {
403    pub fn as_str(self) -> &'static str {
404        match self {
405            Scope::Default => "default",
406            Scope::Local => "local",
407            Scope::Global => "global",
408            Scope::System => "system",
409            Scope::CommandLine => "command line",
410            Scope::Policy => "amont.conf",
411            Scope::Other => "other",
412        }
413    }
414}
415
416/// `git config --show-origin --get <key>` → which file it came from.
417///
418/// `Default` for a key nobody set, which is also the answer when git cannot be
419/// asked — the value in use is the shipped one either way.
420pub fn scope_of(key: &str) -> Scope {
421    // Display mirrors resolution: policy owns the key unless something above
422    // it on the ladder set it. The precedence answer comes from the same
423    // classifier `resolve` uses, never re-derived from file paths.
424    if crate::policy::current().settings.contains_key(key) && !key_set_above_policy(key) {
425        return Scope::Policy;
426    }
427    let Some(out) = git::output(&["config", "--show-origin", "--get", key]) else {
428        return Scope::Default;
429    };
430    if out.code != 0 {
431        return Scope::Default;
432    }
433    // `<origin>\t<value>`; the origin is `file:/path`, `command line:` or
434    // `blob:…`, and the path is what tells local from global.
435    let origin = out.stdout.split('\t').next().unwrap_or("");
436    if origin.starts_with("command line") {
437        return Scope::CommandLine;
438    }
439    let Some(path) = origin.strip_prefix("file:") else {
440        return Scope::Other;
441    };
442    let path = path.trim();
443    // A repository's own config is the only one inside a `.git` directory;
444    // asking git for the paths rather than guessing at `~` keeps this correct
445    // under `GIT_CONFIG_GLOBAL`, worktrees and `$XDG_CONFIG_HOME`.
446    if same_file(
447        path,
448        git::stdout(&["rev-parse", "--git-path", "config"]).as_deref(),
449    ) {
450        return Scope::Local;
451    }
452    for (flag, scope) in [("--global", Scope::Global), ("--system", Scope::System)] {
453        let listed = git::output(&["config", flag, "--list", "--show-origin"]);
454        if let Some(o) = listed {
455            if o.code == 0
456                && o.stdout
457                    .lines()
458                    .filter_map(|l| l.split('\t').next())
459                    .filter_map(|o| o.strip_prefix("file:"))
460                    .any(|p| same_file(path, Some(p.trim())))
461            {
462                return scope;
463            }
464        }
465    }
466    Scope::Other
467}
468
469/// Compare two paths as the same file where the filesystem can say so, falling
470/// back to the strings. `--git-path` answers relatively (`.git/config`) while
471/// `--show-origin` may answer absolutely, so a string comparison alone
472/// misreports a local key as `other`.
473fn same_file(a: &str, b: Option<&str>) -> bool {
474    let Some(b) = b else { return false };
475    if a == b {
476        return true;
477    }
478    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
479        (Ok(x), Ok(y)) => x == y,
480        _ => false,
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    /// The distinction the whole module exists for: git's two failure exits
489    /// mean opposite things, and `first_line` is what a reader is shown for
490    /// the one that is a mistake.
491    #[test]
492    fn a_fatal_line_is_reported_without_its_prefix() {
493        assert_eq!(
494            first_line("fatal: bad numeric config value 'wide' for 'a.b'"),
495            "bad numeric config value 'wide' for 'a.b'"
496        );
497        assert_eq!(first_line("first\nsecond"), "first");
498    }
499
500    /// A warning that names no cause is a puzzle, so there is always a cause.
501    #[test]
502    fn an_empty_diagnostic_still_says_something() {
503        assert!(!first_line("").is_empty());
504        assert!(!first_line("   \n  ").is_empty());
505    }
506
507    #[test]
508    fn a_key_name_is_escaped_before_it_becomes_a_pattern() {
509        // Without escaping, `.` matches any character and the prefix would
510        // also select `amontXcommit.*`.
511        assert_eq!(regex_escape("amont.commit."), "amont\\.commit\\.");
512        assert_eq!(regex_escape("plain"), "plain");
513    }
514
515    /// Git lowercases the names it prints, so a presence test that respected
516    /// case would answer "not set" for every camelCase key we ship.
517    #[test]
518    fn presence_is_case_insensitive_because_git_lowercases_names() {
519        let names: BTreeSet<String> = ["amont.commit.subjectmax".to_string()]
520            .into_iter()
521            .collect();
522        assert!(is_present(&names, "amont.commit.subjectMax"));
523        assert!(is_present(&names, "AMONT.COMMIT.SUBJECTMAX"));
524        assert!(!is_present(&names, "amont.commit.bodyWrap"));
525    }
526
527    #[test]
528    fn every_scope_has_a_name() {
529        for s in [
530            Scope::Default,
531            Scope::Local,
532            Scope::Global,
533            Scope::System,
534            Scope::CommandLine,
535            Scope::Other,
536        ] {
537            assert!(!s.as_str().is_empty());
538        }
539    }
540}