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> {
let type_flag = ty.map(|t| format!("--type={t}"));
let mut args: Vec<&str> = vec!["config"];
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 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");
}
}