1use 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#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct Config {
24 #[serde(default)]
26 pub ui: UiSection,
27 #[serde(default)]
29 pub keys: HashMap<String, String>,
30 #[serde(default)]
32 pub theme: ThemeSection,
33}
34
35#[derive(Debug, Default, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct UiSection {
39 pub theme: Option<ThemeArg>,
41 pub lang: Option<LangArg>,
43 pub verbose: Option<bool>,
45 pub editor: Option<String>,
48}
49
50#[derive(Debug, Default, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ThemeSection {
54 #[serde(default)]
56 pub dark: HashMap<String, ColorValue>,
57 #[serde(default)]
59 pub light: HashMap<String, ColorValue>,
60}
61
62#[derive(Debug, Clone, Deserialize)]
64#[serde(untagged)]
65pub enum ColorValue {
66 Single(String),
68 Pair([String; 2]),
70}
71
72pub 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
82pub 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#[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
114pub 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
134fn parse(text: &str) -> Result<Config> {
136 Ok(toml::from_str(text)?)
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[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 #[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 #[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 #[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 #[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 #[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}