Skip to main content

git_pincer/
config.rs

1//! User configuration file: locating, loading and parsing.
2//!
3//! The file lives at `$XDG_CONFIG_HOME/git-pincer/config.toml` (falling back
4//! to `~/.config/git-pincer/config.toml`, macOS included, following the
5//! lazygit/delta convention) or `%APPDATA%\git-pincer\config.toml` on
6//! Windows; `GIT_PINCER_CONFIG` overrides the path entirely. A missing file
7//! yields the defaults; a malformed one fails fast with a readable error.
8//! Scope is deliberately behavior-only: theme, key bindings and CLI-option
9//! defaults — UI texts are not configurable.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use anyhow::{Context, Result};
15use serde::Deserialize;
16
17use crate::cli::{LangArg, ThemeArg};
18use crate::i18n::tr_f;
19
20/// 用户配置文件的根结构。
21#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Config {
24    /// `[ui]`:全局命令参数的默认值
25    #[serde(default)]
26    pub ui: UiSection,
27    /// `[keys]`:按键覆盖(动作名 → 键位描述)
28    #[serde(default)]
29    pub keys: HashMap<String, String>,
30    /// `[theme.dark]` / `[theme.light]`:主题色覆盖
31    #[serde(default)]
32    pub theme: ThemeSection,
33}
34
35/// `[ui]` 段:与同名命令行参数等价,优先级低于命令行。
36#[derive(Debug, Default, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct UiSection {
39    /// 界面主题(auto | dark | light)
40    pub theme: Option<ThemeArg>,
41    /// 界面语言(auto | zh | en)
42    pub lang: Option<LangArg>,
43    /// 是否回显执行的 git 命令
44    pub verbose: Option<bool>,
45    /// 块编辑(e 键)使用的编辑器命令,可含参数(如 "code --wait");
46    /// 完整优先级:此处 > $VISUAL > $EDITOR > vim / vi(Windows 为 notepad)
47    pub editor: Option<String>,
48}
49
50/// `[theme]` 段:深 / 浅两套主题的颜色覆盖(颜色名 → 值)。
51#[derive(Debug, Default, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ThemeSection {
54    /// 深色主题覆盖
55    #[serde(default)]
56    pub dark: HashMap<String, ColorValue>,
57    /// 浅色主题覆盖
58    #[serde(default)]
59    pub light: HashMap<String, ColorValue>,
60}
61
62/// 颜色值:单色 `"#RRGGBB"`,或色带 / 强调类的 `["#普通", "#选中"]` 双色。
63#[derive(Debug, Clone, Deserialize)]
64#[serde(untagged)]
65pub enum ColorValue {
66    /// 单个前景 / 背景色
67    Single(String),
68    /// (普通, 选中) 双色对
69    Pair([String; 2]),
70}
71
72/// 解析 `#RRGGBB` 十六进制颜色为 RGB 三元组。
73pub fn parse_hex(value: &str) -> Option<(u8, u8, u8)> {
74    let hex = value.strip_prefix('#')?;
75    if hex.len() != 6 {
76        return None;
77    }
78    let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
79    Some((byte(0)?, byte(2)?, byte(4)?))
80}
81
82/// 定位配置文件路径;无法确定用户目录时返回 None(视同无配置)。
83pub fn config_path() -> Option<PathBuf> {
84    if let Some(path) = std::env::var_os("GIT_PINCER_CONFIG") {
85        return Some(PathBuf::from(path));
86    }
87    #[cfg(windows)]
88    {
89        Some(
90            PathBuf::from(std::env::var_os("APPDATA")?)
91                .join("git-pincer")
92                .join("config.toml"),
93        )
94    }
95    #[cfg(not(windows))]
96    {
97        resolve_unix_path(
98            std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
99            std::env::home_dir(),
100        )
101    }
102}
103
104/// Unix 系路径解析:`$XDG_CONFIG_HOME` 优先,否则 `~/.config`。
105#[cfg(not(windows))]
106fn resolve_unix_path(xdg: Option<PathBuf>, home: Option<PathBuf>) -> Option<PathBuf> {
107    let base = match xdg {
108        Some(dir) if !dir.as_os_str().is_empty() => dir,
109        _ => home?.join(".config"),
110    };
111    Some(base.join("git-pincer").join("config.toml"))
112}
113
114/// 读取并解析配置:文件不存在返回默认值,内容非法则报可读错误。
115pub fn load() -> Result<Config> {
116    let Some(path) = config_path() else {
117        return Ok(Config::default());
118    };
119    let text = match std::fs::read_to_string(&path) {
120        Ok(text) => text,
121        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
122        Err(e) => {
123            return Err(e).with_context(|| {
124                tr_f(
125                    "config.unreadable",
126                    &[("path", &path.display().to_string())],
127                )
128            });
129        }
130    };
131    parse(&text).with_context(|| tr_f("config.invalid", &[("path", &path.display().to_string())]))
132}
133
134/// 从 TOML 文本解析配置(load 的纯逻辑部分,便于测试)。
135fn parse(text: &str) -> Result<Config> {
136    Ok(toml::from_str(text)?)
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// 完整配置解析:各段落与两种颜色值形态
144    #[test]
145    fn parses_full_config() {
146        let config = parse(
147            r##"
148[ui]
149theme = "dark"
150lang = "zh"
151verbose = true
152
153[keys]
154take-local = "o"
155write = "ctrl+s"
156
157[theme.dark]
158rpg_accent = "#ff7a2f"
159band_conflict = ["#3a1e22", "#5e2d35"]
160"##,
161        )
162        .unwrap();
163        assert_eq!(config.ui.theme, Some(ThemeArg::Dark));
164        assert_eq!(config.ui.lang, Some(LangArg::Zh));
165        assert_eq!(config.ui.verbose, Some(true));
166        assert_eq!(config.keys["take-local"], "o");
167        assert!(matches!(
168            config.theme.dark["rpg_accent"],
169            ColorValue::Single(_)
170        ));
171        assert!(matches!(
172            config.theme.dark["band_conflict"],
173            ColorValue::Pair(_)
174        ));
175    }
176
177    /// 空文本与缺省段落都取默认值
178    #[test]
179    fn empty_config_is_default() {
180        let config = parse("").unwrap();
181        assert!(config.ui.theme.is_none());
182        assert!(config.keys.is_empty());
183        assert!(config.theme.dark.is_empty());
184    }
185
186    /// 未知字段被拒绝,错误信息包含字段名(拼写错误可定位)
187    #[test]
188    fn unknown_fields_are_rejected() {
189        let err = parse("[ui]\nthem = \"dark\"\n").unwrap_err();
190        assert!(format!("{err:#}").contains("them"));
191    }
192
193    /// 非法枚举值给出候选列表
194    #[test]
195    fn invalid_enum_lists_variants() {
196        let err = parse("[ui]\ntheme = \"drak\"\n").unwrap_err();
197        let msg = format!("{err:#}");
198        assert!(msg.contains("drak") && msg.contains("dark"));
199    }
200
201    /// 十六进制颜色解析与非法输入
202    #[test]
203    fn hex_parsing() {
204        assert_eq!(parse_hex("#ff7a2f"), Some((0xff, 0x7a, 0x2f)));
205        assert_eq!(parse_hex("#FFF"), None);
206        assert_eq!(parse_hex("ff7a2f"), None);
207        assert_eq!(parse_hex("#gg0000"), None);
208    }
209
210    /// Unix 路径解析:XDG 优先,空值回退 ~/.config,无 home 返回 None
211    #[cfg(not(windows))]
212    #[test]
213    fn unix_path_resolution() {
214        let xdg = resolve_unix_path(Some(PathBuf::from("/xdg")), Some(PathBuf::from("/home/z")));
215        assert_eq!(xdg, Some(PathBuf::from("/xdg/git-pincer/config.toml")));
216        let fallback = resolve_unix_path(None, Some(PathBuf::from("/home/z")));
217        assert_eq!(
218            fallback,
219            Some(PathBuf::from("/home/z/.config/git-pincer/config.toml"))
220        );
221        let empty_xdg = resolve_unix_path(Some(PathBuf::new()), Some(PathBuf::from("/home/z")));
222        assert_eq!(
223            empty_xdg,
224            Some(PathBuf::from("/home/z/.config/git-pincer/config.toml"))
225        );
226        assert_eq!(resolve_unix_path(None, None), None);
227    }
228}