use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::cli::{LangArg, ThemeArg};
use crate::i18n::tr_f;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub ui: UiSection,
#[serde(default)]
pub keys: HashMap<String, String>,
#[serde(default)]
pub theme: ThemeSection,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiSection {
pub theme: Option<ThemeArg>,
pub lang: Option<LangArg>,
pub verbose: Option<bool>,
pub editor: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ThemeSection {
#[serde(default)]
pub dark: HashMap<String, ColorValue>,
#[serde(default)]
pub light: HashMap<String, ColorValue>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ColorValue {
Single(String),
Pair([String; 2]),
}
pub fn parse_hex(value: &str) -> Option<(u8, u8, u8)> {
let hex = value.strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some((byte(0)?, byte(2)?, byte(4)?))
}
pub fn config_path() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("GIT_PINCER_CONFIG") {
return Some(PathBuf::from(path));
}
#[cfg(windows)]
{
Some(
PathBuf::from(std::env::var_os("APPDATA")?)
.join("git-pincer")
.join("config.toml"),
)
}
#[cfg(not(windows))]
{
resolve_unix_path(
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
std::env::home_dir(),
)
}
}
#[cfg(not(windows))]
fn resolve_unix_path(xdg: Option<PathBuf>, home: Option<PathBuf>) -> Option<PathBuf> {
let base = match xdg {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => home?.join(".config"),
};
Some(base.join("git-pincer").join("config.toml"))
}
pub fn load() -> Result<Config> {
let Some(path) = config_path() else {
return Ok(Config::default());
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(e) => {
return Err(e).with_context(|| {
tr_f(
"config.unreadable",
&[("path", &path.display().to_string())],
)
});
}
};
parse(&text).with_context(|| tr_f("config.invalid", &[("path", &path.display().to_string())]))
}
fn parse(text: &str) -> Result<Config> {
Ok(toml::from_str(text)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_full_config() {
let config = parse(
r##"
[ui]
theme = "dark"
lang = "zh"
verbose = true
[keys]
take-local = "o"
write = "ctrl+s"
[theme.dark]
rpg_accent = "#ff7a2f"
band_conflict = ["#3a1e22", "#5e2d35"]
"##,
)
.unwrap();
assert_eq!(config.ui.theme, Some(ThemeArg::Dark));
assert_eq!(config.ui.lang, Some(LangArg::Zh));
assert_eq!(config.ui.verbose, Some(true));
assert_eq!(config.keys["take-local"], "o");
assert!(matches!(
config.theme.dark["rpg_accent"],
ColorValue::Single(_)
));
assert!(matches!(
config.theme.dark["band_conflict"],
ColorValue::Pair(_)
));
}
#[test]
fn empty_config_is_default() {
let config = parse("").unwrap();
assert!(config.ui.theme.is_none());
assert!(config.keys.is_empty());
assert!(config.theme.dark.is_empty());
}
#[test]
fn unknown_fields_are_rejected() {
let err = parse("[ui]\nthem = \"dark\"\n").unwrap_err();
assert!(format!("{err:#}").contains("them"));
}
#[test]
fn invalid_enum_lists_variants() {
let err = parse("[ui]\ntheme = \"drak\"\n").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("drak") && msg.contains("dark"));
}
#[test]
fn hex_parsing() {
assert_eq!(parse_hex("#ff7a2f"), Some((0xff, 0x7a, 0x2f)));
assert_eq!(parse_hex("#FFF"), None);
assert_eq!(parse_hex("ff7a2f"), None);
assert_eq!(parse_hex("#gg0000"), None);
}
#[cfg(not(windows))]
#[test]
fn unix_path_resolution() {
let xdg = resolve_unix_path(Some(PathBuf::from("/xdg")), Some(PathBuf::from("/home/z")));
assert_eq!(xdg, Some(PathBuf::from("/xdg/git-pincer/config.toml")));
let fallback = resolve_unix_path(None, Some(PathBuf::from("/home/z")));
assert_eq!(
fallback,
Some(PathBuf::from("/home/z/.config/git-pincer/config.toml"))
);
let empty_xdg = resolve_unix_path(Some(PathBuf::new()), Some(PathBuf::from("/home/z")));
assert_eq!(
empty_xdg,
Some(PathBuf::from("/home/z/.config/git-pincer/config.toml"))
);
assert_eq!(resolve_unix_path(None, None), None);
}
}