1use serde::Deserialize;
2
3#[derive(Debug, Clone, Default, Deserialize)]
4#[serde(default)]
5pub struct HotlConfig {
6 pub settings: Settings,
7 pub plugins: Plugins,
8}
9
10#[derive(Debug, Clone, Deserialize)]
11#[serde(default)]
12pub struct Settings {
13 pub ping_on_blocked: bool,
14 pub poll_interval_ms: u64,
15 pub agents: Vec<String>,
16 pub vim_mode: bool,
17 pub theme: ThemeConfig,
18}
19
20impl Default for Settings {
21 fn default() -> Self {
22 Settings {
23 ping_on_blocked: true,
24 poll_interval_ms: 1000,
25 agents: vec!["claude".into(), "codex".into()],
26 vim_mode: true,
27 theme: ThemeConfig::default(),
28 }
29 }
30}
31
32pub const MIN_POLL_INTERVAL_MS: u64 = 100;
37
38impl Settings {
39 pub fn poll_interval_ms_clamped(&self) -> u64 {
41 self.poll_interval_ms.max(MIN_POLL_INTERVAL_MS)
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Deserialize)]
46#[serde(default)]
47pub struct Theme {
48 pub active: String,
49 pub blocked: String,
50 pub idle: String,
51 pub ink: String,
52 pub muted: String,
53 pub faint: String,
54 pub accent: String,
55 pub band: String,
56}
57
58impl Default for Theme {
59 fn default() -> Self {
60 Theme {
61 active: "#f2c14e".into(),
62 blocked: "#e06c6c".into(),
63 idle: "#7ee07e".into(),
64 ink: "#e6e9f0".into(),
65 muted: "#8a92a6".into(),
66 faint: "#596072".into(),
67 accent: "#6c8cff".into(),
68 band: "#2a3350".into(),
69 }
70 }
71}
72
73#[derive(Debug, Clone, Default, Deserialize)]
76#[serde(default)]
77pub struct ThemeConfig {
78 pub preset: Option<String>,
79 pub active: Option<String>,
80 pub blocked: Option<String>,
81 pub idle: Option<String>,
82 pub ink: Option<String>,
83 pub muted: Option<String>,
84 pub faint: Option<String>,
85 pub accent: Option<String>,
86 pub band: Option<String>,
87}
88
89pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
94 let s = s.strip_prefix('#').unwrap_or(s);
95 if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
99 return None;
100 }
101 Some([
102 u8::from_str_radix(&s[0..2], 16).ok()?,
103 u8::from_str_radix(&s[2..4], 16).ok()?,
104 u8::from_str_radix(&s[4..6], 16).ok()?,
105 ])
106}
107
108impl ThemeConfig {
109 pub fn resolve(&self) -> (Theme, Option<String>) {
114 let (mut base, preset_warn) = match &self.preset {
115 None => (Theme::default(), None),
116 Some(n) => match preset(n) {
117 Some(t) => (t, None),
118 None => (Theme::default(), Some(format!("unknown theme '{n}' — using default"))),
119 },
120 };
121
122 let mut bad: Vec<&'static str> = Vec::new();
123 let mut apply = |slot: &mut String, name: &'static str, value: &Option<String>| {
124 if let Some(c) = value {
125 if parse_hex(c).is_some() {
126 *slot = c.clone();
127 } else {
128 bad.push(name);
129 }
130 }
131 };
132 apply(&mut base.active, "active", &self.active);
133 apply(&mut base.blocked, "blocked", &self.blocked);
134 apply(&mut base.idle, "idle", &self.idle);
135 apply(&mut base.ink, "ink", &self.ink);
136 apply(&mut base.muted, "muted", &self.muted);
137 apply(&mut base.faint, "faint", &self.faint);
138 apply(&mut base.accent, "accent", &self.accent);
139 apply(&mut base.band, "band", &self.band);
140
141 let color_warn = (!bad.is_empty())
142 .then(|| format!("ignoring invalid theme color(s): {}", bad.join(", ")));
143
144 let warn = match (preset_warn, color_warn) {
146 (Some(p), Some(c)) => Some(format!("{p}; {c}")),
147 (Some(w), None) | (None, Some(w)) => Some(w),
148 (None, None) => None,
149 };
150 (base, warn)
151 }
152}
153
154#[derive(Debug, Clone, Default, Deserialize)]
156#[serde(default)]
157pub struct Plugins {}
158
159impl HotlConfig {
160 pub fn parse(toml_str: &str) -> HotlConfig {
161 HotlConfig::parse_checked(toml_str).0
162 }
163
164 pub fn parse_checked(toml_str: &str) -> (HotlConfig, Option<String>) {
166 match toml::from_str(toml_str) {
167 Ok(cfg) => (cfg, None),
168 Err(e) => (HotlConfig::default(), Some(format!("config parse error: {e}"))),
169 }
170 }
171
172 pub fn load() -> HotlConfig {
173 HotlConfig::load_with_warning().0
174 }
175
176 pub fn load_with_warning() -> (HotlConfig, Option<String>) {
177 let (cfg, parse_warn) = match dirs_config_path().and_then(|p| std::fs::read_to_string(p).ok()) {
178 Some(s) => HotlConfig::parse_checked(&s),
179 None => (HotlConfig::default(), None),
180 };
181 let warn = parse_warn.or_else(|| cfg.settings.theme.resolve().1);
182 (cfg, warn)
183 }
184}
185
186fn dirs_config_path() -> Option<std::path::PathBuf> {
187 let base = std::env::var_os("XDG_CONFIG_HOME")
188 .map(std::path::PathBuf::from)
189 .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))?;
190 Some(base.join("hotl").join("config.toml"))
191}
192
193pub fn preset(name: &str) -> Option<Theme> {
195 Some(match name {
196 "default" => Theme::default(),
197 "tokyo-night" => Theme {
198 active: "#e0af68".into(), blocked: "#f7768e".into(), idle: "#9ece6a".into(),
199 ink: "#c0caf5".into(), muted: "#787c99".into(), faint: "#565f89".into(),
200 accent: "#7aa2f7".into(), band: "#292e42".into(),
201 },
202 "catppuccin" => Theme {
203 active: "#f9e2af".into(), blocked: "#f38ba8".into(), idle: "#a6e3a1".into(),
204 ink: "#cdd6f4".into(), muted: "#a6adc8".into(), faint: "#6c7086".into(),
205 accent: "#89b4fa".into(), band: "#313244".into(),
206 },
207 "gruvbox" => Theme {
208 active: "#d79921".into(), blocked: "#cc241d".into(), idle: "#98971a".into(),
209 ink: "#ebdbb2".into(), muted: "#a89984".into(), faint: "#665c54".into(),
210 accent: "#458588".into(), band: "#3c3836".into(),
211 },
212 "nord" => Theme {
213 active: "#ebcb8b".into(), blocked: "#bf616a".into(), idle: "#a3be8c".into(),
214 ink: "#eceff4".into(), muted: "#d8dee9".into(), faint: "#4c566a".into(),
215 accent: "#88c0d0".into(), band: "#3b4252".into(),
216 },
217 "dracula" => Theme {
218 active: "#f1fa8c".into(), blocked: "#ff5555".into(), idle: "#50fa7b".into(),
219 ink: "#f8f8f2".into(), muted: "#b8b8c0".into(), faint: "#6272a4".into(),
220 accent: "#bd93f9".into(), band: "#44475a".into(),
221 },
222 _ => return None,
223 })
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn defaults_when_empty() {
232 let c = HotlConfig::parse("");
233 assert!(c.settings.ping_on_blocked);
234 assert_eq!(c.settings.poll_interval_ms, 1000);
235 assert_eq!(c.settings.agents, vec!["claude".to_string(), "codex".to_string()]);
236 assert_eq!(c.settings.theme.resolve().0.active, "#f2c14e");
237 }
238
239 #[test]
240 fn partial_settings_override_only_given_fields() {
241 let c = HotlConfig::parse("[settings]\nping_on_blocked = false\npoll_interval_ms = 500\n");
242 assert!(!c.settings.ping_on_blocked);
243 assert_eq!(c.settings.poll_interval_ms, 500);
244 assert_eq!(c.settings.agents, vec!["claude".to_string(), "codex".to_string()]);
245 }
246
247 #[test]
248 fn theme_override() {
249 let c = HotlConfig::parse("[settings.theme]\nblocked = \"#ff0000\"\n");
250 let (theme, _) = c.settings.theme.resolve();
251 assert_eq!(theme.blocked, "#ff0000");
252 assert_eq!(theme.idle, "#7ee07e");
253 }
254
255 #[test]
256 fn agents_override() {
257 let c = HotlConfig::parse("[settings]\nagents = [\"claude\"]\n");
258 assert_eq!(c.settings.agents, vec!["claude".to_string()]);
259 }
260
261 #[test]
262 fn malformed_toml_falls_back_to_defaults() {
263 let c = HotlConfig::parse("this is not = = toml [[[");
264 assert!(c.settings.ping_on_blocked);
265 }
266
267 #[test]
268 fn malformed_toml_reports_a_warning() {
269 let (c, warn) = HotlConfig::parse_checked("this is not = = toml [[[");
270 assert!(c.settings.ping_on_blocked);
271 assert!(warn.is_some(), "malformed config should yield a warning");
272 }
273
274 #[test]
275 fn valid_toml_has_no_warning() {
276 let (_, warn) = HotlConfig::parse_checked("[settings]\nping_on_blocked = false\n");
277 assert!(warn.is_none());
278 }
279
280 #[test]
281 fn plugins_section_parses_and_is_inert() {
282 let c = HotlConfig::parse("[plugins]\n");
283 assert!(c.settings.ping_on_blocked);
284 }
285
286 #[test]
287 fn vim_mode_defaults_true() {
288 assert!(HotlConfig::parse("").settings.vim_mode);
289 }
290
291 #[test]
292 fn vim_mode_can_be_disabled() {
293 let c = HotlConfig::parse("[settings]\nvim_mode = false\n");
294 assert!(!c.settings.vim_mode);
295 assert!(c.settings.ping_on_blocked);
296 }
297
298 #[test]
299 fn preset_default_equals_theme_default() {
300 assert_eq!(preset("default"), Some(Theme::default()));
301 }
302 #[test]
303 fn preset_known_returns_palette() {
304 let t = preset("tokyo-night").expect("exists");
305 assert_eq!(t.blocked, "#f7768e");
306 assert_eq!(t.accent, "#7aa2f7");
307 }
308 #[test]
309 fn preset_unknown_is_none() {
310 assert_eq!(preset("nope"), None);
311 }
312 #[test]
313 fn all_named_presets_exist() {
314 for name in ["default","tokyo-night","catppuccin","gruvbox","nord","dracula"] {
315 assert!(preset(name).is_some(), "missing {name}");
316 }
317 }
318 #[test]
319 fn resolve_no_preset_is_default() {
320 let (theme, warn) = ThemeConfig::default().resolve();
321 assert_eq!(theme, Theme::default());
322 assert!(warn.is_none());
323 }
324 #[test]
325 fn resolve_known_preset() {
326 let tc = ThemeConfig { preset: Some("gruvbox".into()), ..Default::default() };
327 let (theme, warn) = tc.resolve();
328 assert_eq!(theme, preset("gruvbox").unwrap());
329 assert!(warn.is_none());
330 }
331 #[test]
332 fn resolve_unknown_preset_defaults_with_warning() {
333 let tc = ThemeConfig { preset: Some("typo".into()), ..Default::default() };
334 let (theme, warn) = tc.resolve();
335 assert_eq!(theme, Theme::default());
336 assert!(warn.as_deref().unwrap().contains("typo"));
337 }
338 #[test]
339 fn resolve_preset_with_override() {
340 let tc = ThemeConfig { preset: Some("gruvbox".into()), blocked: Some("#ff0000".into()), ..Default::default() };
341 let (theme, _) = tc.resolve();
342 assert_eq!(theme.blocked, "#ff0000");
343 assert_eq!(theme.idle, preset("gruvbox").unwrap().idle);
344 }
345 #[test]
346 fn settings_theme_parses_preset_and_override() {
347 let c = HotlConfig::parse("[settings.theme]\npreset = \"nord\"\nblocked = \"#ff0000\"\n");
348 assert_eq!(c.settings.theme.preset.as_deref(), Some("nord"));
349 assert_eq!(c.settings.theme.blocked.as_deref(), Some("#ff0000"));
350 }
351
352 #[test]
353 fn poll_interval_is_clamped_to_minimum() {
354 let c = HotlConfig::parse("[settings]\npoll_interval_ms = 0\n");
355 assert_eq!(c.settings.poll_interval_ms, 0, "raw value preserved");
356 assert_eq!(c.settings.poll_interval_ms_clamped(), MIN_POLL_INTERVAL_MS);
357 }
358
359 #[test]
360 fn poll_interval_above_minimum_is_unchanged() {
361 let c = HotlConfig::parse("[settings]\npoll_interval_ms = 500\n");
362 assert_eq!(c.settings.poll_interval_ms_clamped(), 500);
363 }
364
365 #[test]
366 fn parse_hex_accepts_valid_with_and_without_hash() {
367 assert_eq!(parse_hex("#7ee07e"), Some([0x7e, 0xe0, 0x7e]));
368 assert_eq!(parse_hex("7ee07e"), Some([0x7e, 0xe0, 0x7e]));
369 }
370
371 #[test]
372 fn parse_hex_rejects_non_hex_and_wrong_length() {
373 assert_eq!(parse_hex("nonsense"), None);
374 assert_eq!(parse_hex("#fff"), None);
375 assert_eq!(parse_hex("#gggggg"), None);
376 }
377
378 #[test]
379 fn parse_hex_rejects_six_byte_multibyte_without_panicking() {
380 assert_eq!(parse_hex("aéaé"), None);
382 }
383
384 #[test]
385 fn resolve_ignores_invalid_color_and_warns() {
386 let tc = ThemeConfig { active: Some("notacolor".into()), ..Default::default() };
387 let (theme, warn) = tc.resolve();
388 assert_eq!(theme.active, Theme::default().active, "bad color keeps base");
389 let w = warn.expect("invalid color should warn");
390 assert!(w.contains("active"), "warning names the offending slot: {w}");
391 }
392
393 #[test]
394 fn resolve_reports_both_unknown_preset_and_bad_color() {
395 let tc = ThemeConfig {
396 preset: Some("typo".into()),
397 blocked: Some("xyz".into()),
398 ..Default::default()
399 };
400 let w = tc.resolve().1.expect("should warn");
401 assert!(w.contains("typo"), "mentions preset: {w}");
402 assert!(w.contains("blocked"), "mentions color: {w}");
403 }
404}