use crate::git;
use crate::ui::{highlight, warning_sign};
use std::collections::BTreeSet;
use std::ops::RangeInclusive;
use std::sync::{Mutex, OnceLock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value<T> {
Unset,
Set(T),
Bad {
why: String,
},
}
impl<T> Value<T> {
pub fn is_set(&self) -> bool {
matches!(self, Value::Set(_))
}
}
fn typed(key: &str, ty: &str) -> Value<String> {
let type_flag = format!("--type={ty}");
let Some(out) = git::output(&["config", &type_flag, "--get", key]) 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()
}
}
pub fn boolean(key: &str) -> Value<bool> {
match typed(key, "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 },
}
}
pub fn integer(key: &str) -> Value<i64> {
match typed(key, "int") {
Value::Set(v) => match v.parse::<i64>() {
Ok(n) => Value::Set(n),
Err(_) => Value::Bad {
why: format!("git returned {v:?}, which is not a whole number"),
},
},
Value::Unset => Value::Unset,
Value::Bad { why } => Value::Bad { why },
}
}
pub fn enumerated(key: &str, allowed: &[&'static str]) -> Value<&'static str> {
let Some(out) = git::output(&["config", "--get", key]) else {
return Value::Unset;
};
match out.code {
0 => {
let got = out.stdout.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(", ")),
},
}
}
1 => Value::Unset,
_ => Value::Bad {
why: first_line(&out.stderr),
},
}
}
pub 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 integer_or(key: &str, default: i64, range: RangeInclusive<i64>) -> i64 {
match integer(key) {
Value::Set(v) if range.contains(&v) => v,
Value::Set(v) => {
complain(
key,
&format!("{v} is outside {}..={}", range.start(), range.end()),
&default.to_string(),
);
default
}
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
}
}
}
pub fn present(prefix: &str) -> BTreeSet<String> {
let pattern = format!("^{}", regex_escape(prefix));
let Some(out) = git::output(&["config", "--get-regexp", &pattern]) else {
return BTreeSet::new();
};
if out.code != 0 {
return BTreeSet::new();
}
out.stdout
.lines()
.filter_map(|l| l.split_whitespace().next())
.map(|k| k.to_ascii_lowercase())
.collect()
}
pub fn is_present(names: &BTreeSet<String>, key: &str) -> bool {
names.contains(&key.to_ascii_lowercase())
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
for c in s.chars() {
if matches!(c, '.' | '*' | '[' | ']' | '^' | '$' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Default,
Local,
Global,
System,
CommandLine,
Other,
}
impl Scope {
pub fn as_str(self) -> &'static str {
match self {
Scope::Default => "default",
Scope::Local => "local",
Scope::Global => "global",
Scope::System => "system",
Scope::CommandLine => "command line",
Scope::Other => "other",
}
}
}
pub fn scope_of(key: &str) -> Scope {
let Some(out) = git::output(&["config", "--show-origin", "--get", key]) else {
return Scope::Default;
};
if out.code != 0 {
return Scope::Default;
}
let origin = out.stdout.split('\t').next().unwrap_or("");
if origin.starts_with("command line") {
return Scope::CommandLine;
}
let Some(path) = origin.strip_prefix("file:") else {
return Scope::Other;
};
let path = path.trim();
if same_file(
path,
git::stdout(&["rev-parse", "--git-path", "config"]).as_deref(),
) {
return Scope::Local;
}
for (flag, scope) in [("--global", Scope::Global), ("--system", Scope::System)] {
let listed = git::output(&["config", flag, "--list", "--show-origin"]);
if let Some(o) = listed {
if o.code == 0
&& o.stdout
.lines()
.filter_map(|l| l.split('\t').next())
.filter_map(|o| o.strip_prefix("file:"))
.any(|p| same_file(path, Some(p.trim())))
{
return scope;
}
}
}
Scope::Other
}
fn same_file(a: &str, b: Option<&str>) -> bool {
let Some(b) = b else { return false };
if a == b {
return true;
}
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fatal_line_is_reported_without_its_prefix() {
assert_eq!(
first_line("fatal: bad numeric config value 'wide' for 'a.b'"),
"bad numeric config value 'wide' for 'a.b'"
);
assert_eq!(first_line("first\nsecond"), "first");
}
#[test]
fn an_empty_diagnostic_still_says_something() {
assert!(!first_line("").is_empty());
assert!(!first_line(" \n ").is_empty());
}
#[test]
fn a_key_name_is_escaped_before_it_becomes_a_pattern() {
assert_eq!(regex_escape("amont.commit."), "amont\\.commit\\.");
assert_eq!(regex_escape("plain"), "plain");
}
#[test]
fn presence_is_case_insensitive_because_git_lowercases_names() {
let names: BTreeSet<String> = ["amont.commit.subjectmax".to_string()]
.into_iter()
.collect();
assert!(is_present(&names, "amont.commit.subjectMax"));
assert!(is_present(&names, "AMONT.COMMIT.SUBJECTMAX"));
assert!(!is_present(&names, "amont.commit.bodyWrap"));
}
#[test]
fn every_scope_has_a_name() {
for s in [
Scope::Default,
Scope::Local,
Scope::Global,
Scope::System,
Scope::CommandLine,
Scope::Other,
] {
assert!(!s.as_str().is_empty());
}
}
}