use std::collections::BTreeSet;
use std::sync::{Mutex, OnceLock};
use crate::git;
use crate::ui::{highlight, warning_sign};
pub enum Value<T> {
Set(T),
Unset,
Bad { why: String },
}
fn read(key: &str, ty: Option<&str>) -> Value<String> {
match read_scope(0, "--global", key, ty) {
Value::Set(v) => Value::Set(v),
Value::Bad { why } => Value::Bad { why },
Value::Unset => read_scope(1, "--system", key, ty),
}
}
fn read_scope(index: usize, scope: &str, key: &str, ty: Option<&str>) -> Value<String> {
if key.starts_with(PREFIX) {
read_cached(index, scope, key, ty)
} else {
ask_git(scope, key, ty)
}
}
const PREFIX: &str = "amont.agent.";
enum Snapshot {
Keys(Vec<(String, Option<String>)>),
Refused(String),
}
type Snapshots = [Option<Snapshot>; 2];
fn snapshots() -> &'static Mutex<Snapshots> {
static CELL: OnceLock<Mutex<Snapshots>> = OnceLock::new();
CELL.get_or_init(|| Mutex::new([None, None]))
}
pub fn forget() {
if let Ok(mut s) = snapshots().lock() {
*s = [None, None];
}
}
fn snapshot_of(scope: &str) -> Snapshot {
let Some(out) = git::output(&[
"config",
scope,
"--includes",
"--null",
"--get-regexp",
r"^amont\.agent\.",
]) else {
return Snapshot::Keys(Vec::new());
};
match out.code {
0 => Snapshot::Keys(parse_null(&out.stdout)),
1 => Snapshot::Keys(Vec::new()),
_ => Snapshot::Refused(first_line(&out.stderr)),
}
}
fn fill(all: &mut Snapshots) {
let want = [all[0].is_none(), all[1].is_none()];
if want == [false, false] {
return;
}
let read = |i: usize| {
let scope = if i == 0 { "--global" } else { "--system" };
snapshot_of(scope)
};
let got = std::thread::scope(|sc| {
let handles = [0, 1].map(|i| want[i].then(|| sc.spawn(move || read(i))));
handles.map(|h| h.map(|h| h.join().unwrap_or(Snapshot::Keys(Vec::new()))))
});
for (slot, snap) in all.iter_mut().zip(got) {
if let Some(snap) = snap {
*slot = Some(snap);
}
}
}
fn parse_null(raw: &str) -> Vec<(String, Option<String>)> {
raw.split('\0')
.filter(|e| !e.is_empty())
.map(|e| match e.split_once('\n') {
Some((k, v)) => (k.to_string(), Some(v.to_string())),
None => (e.to_string(), None),
})
.collect()
}
fn canonical(key: &str) -> String {
match (key.find('.'), key.rfind('.')) {
(Some(first), Some(last)) if first < last => format!(
"{}{}{}",
key[..first].to_ascii_lowercase(),
&key[first..last],
key[last..].to_ascii_lowercase()
),
_ => key.to_ascii_lowercase(),
}
}
fn lookup(scope_index: usize, scope: &str, key: &str) -> Value<Option<String>> {
let Ok(mut all) = snapshots().lock() else {
return Value::Unset;
};
fill(&mut all);
let snap = all[scope_index].get_or_insert_with(|| snapshot_of(scope));
match snap {
Snapshot::Refused(why) => Value::Bad { why: why.clone() },
Snapshot::Keys(keys) => {
let want = canonical(key);
match keys.iter().rev().find(|(k, _)| canonical(k) == want) {
Some((_, v)) => Value::Set(v.clone()),
None => Value::Unset,
}
}
}
}
fn read_cached(scope_index: usize, scope: &str, key: &str, ty: Option<&str>) -> Value<String> {
match lookup(scope_index, scope, key) {
Value::Unset => Value::Unset,
Value::Bad { why } => Value::Bad { why },
Value::Set(_) if ty.is_some() => ask_git(scope, key, ty),
Value::Set(v) => Value::Set(v.unwrap_or_default()),
}
}
fn ask_git(scope: &str, key: &str, ty: Option<&str>) -> Value<String> {
let type_flag = ty.map(|t| format!("--type={t}"));
let mut args: Vec<&str> = vec!["config", scope, "--includes"];
if let Some(tf) = &type_flag {
args.push(tf);
}
args.extend(["--get", key]);
let Some(out) = git::output(&args) else {
return Value::Unset;
};
match out.code {
0 => Value::Set(out.stdout),
1 => Value::Unset,
_ => Value::Bad {
why: first_line(&out.stderr),
},
}
}
fn first_line(stderr: &str) -> String {
let line = stderr.lines().next().unwrap_or("").trim();
let line = line.strip_prefix("fatal: ").unwrap_or(line);
if line.is_empty() {
"git could not read the value".to_string()
} else {
line.to_string()
}
}
fn boolean(key: &str) -> Value<bool> {
match read(key, Some("bool")) {
Value::Set(v) => match v.as_str() {
"true" => Value::Set(true),
"false" => Value::Set(false),
other => Value::Bad {
why: format!("git normalised it to {other:?}, which is neither true nor false"),
},
},
Value::Unset => Value::Unset,
Value::Bad { why } => Value::Bad { why },
}
}
fn enumerated(key: &str, allowed: &[&'static str]) -> Value<&'static str> {
match read(key, None) {
Value::Set(v) => {
let got = v.trim().to_ascii_lowercase();
match allowed.iter().find(|a| a.eq_ignore_ascii_case(&got)) {
Some(hit) => Value::Set(hit),
None => Value::Bad {
why: format!("{got:?} is not one of {}", allowed.join(", ")),
},
}
}
Value::Unset => Value::Unset,
Value::Bad { why } => Value::Bad { why },
}
}
fn complain(key: &str, why: &str, using: &str) {
static SAID: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
let said = SAID.get_or_init(|| Mutex::new(BTreeSet::new()));
let fresh = match said.lock() {
Ok(mut set) => set.insert(key.to_string()),
Err(_) => true,
};
if fresh {
eprintln!(
"{} {}: {why} — using {using}",
warning_sign().trim(),
highlight(key)
);
}
}
pub fn boolean_or(key: &str, default: bool) -> bool {
match boolean(key) {
Value::Set(v) => v,
Value::Unset => default,
Value::Bad { why } => {
complain(key, &why, &default.to_string());
default
}
}
}
pub fn enumerated_or(key: &str, allowed: &[&'static str], default: &'static str) -> &'static str {
match enumerated(key, allowed) {
Value::Set(v) => v,
Value::Unset => default,
Value::Bad { why } => {
complain(key, &why, default);
default
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn boolean_accepts_only_gits_normalised_pair() {
fn classify(v: &str) -> Option<bool> {
match v {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
assert_eq!(classify("true"), Some(true));
assert_eq!(classify("false"), Some(false));
assert_eq!(classify("yes"), None);
assert_eq!(classify("on"), None);
assert_eq!(classify("1"), None);
}
#[test]
fn enumerated_matches_case_insensitively_and_names_the_alternatives() {
const ALLOWED: &[&str] = &["observe", "advise", "deny"];
fn pick(v: &str) -> Result<&'static str, String> {
let got = v.trim().to_ascii_lowercase();
ALLOWED
.iter()
.find(|a| a.eq_ignore_ascii_case(&got))
.copied()
.ok_or_else(|| format!("{got:?} is not one of {}", ALLOWED.join(", ")))
}
assert_eq!(pick("deny"), Ok("deny"));
assert_eq!(pick("DENY"), Ok("deny"));
assert_eq!(pick(" Observe "), Ok("observe"));
assert_eq!(
pick("nonsense"),
Err("\"nonsense\" is not one of observe, advise, deny".to_string())
);
}
#[test]
fn null_output_keeps_values_whole_and_valueless_keys_apart() {
let raw = "amont.agent.enabled\0amont.agent.stance\nadvise\0\
amont.agent.x.stance\nline one\nline two\0amont.agent.y.stance\n\0";
assert_eq!(
parse_null(raw),
vec![
("amont.agent.enabled".to_string(), None),
("amont.agent.stance".to_string(), Some("advise".to_string())),
(
"amont.agent.x.stance".to_string(),
Some("line one\nline two".to_string())
),
("amont.agent.y.stance".to_string(), Some(String::new())),
]
);
}
#[test]
fn keys_compare_the_way_git_compares_them() {
assert_eq!(
canonical("amont.agent.agentsMdNotice"),
canonical("amont.agent.agentsmdnotice")
);
assert_eq!(
canonical("AMONT.agent.pipe-to-tail.Stance"),
"amont.agent.pipe-to-tail.stance"
);
assert_ne!(
canonical("amont.Agent.stance"),
canonical("amont.agent.stance")
);
}
#[test]
fn first_line_strips_fatal_and_survives_empty_stderr() {
assert_eq!(
first_line("fatal: bad boolean config value 'maybe'\nsecond line"),
"bad boolean config value 'maybe'"
);
assert_eq!(first_line(""), "git could not read the value");
assert_eq!(first_line(" \n "), "git could not read the value");
}
}