Skip to main content

types/
config.rs

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(), "pi".into(), "opencode".into()],
26            vim_mode: true,
27            theme: ThemeConfig::default(),
28        }
29    }
30}
31
32/// Lower bound on the poll interval, in milliseconds. A too-small (or zero)
33/// value would busy-loop the event poll and peg a CPU core, so every consumer
34/// must go through [`Settings::poll_interval_ms_clamped`] rather than reading
35/// the raw field.
36pub const MIN_POLL_INTERVAL_MS: u64 = 100;
37
38impl Settings {
39    /// The configured poll interval, floored at [`MIN_POLL_INTERVAL_MS`].
40    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/// Theme selection: an optional `preset` base palette plus optional per-slot
74/// color overrides. Absent fields fall back to the preset (or default).
75#[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
89/// Parse a `#rrggbb` (or `rrggbb`) hex color into its RGB bytes. Returns `None`
90/// for any string that isn't exactly six hex digits — the single source of
91/// truth for what counts as a valid color, shared by config validation and the
92/// TUI renderer.
93pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
94    let s = s.strip_prefix('#').unwrap_or(s);
95    // Guard on the hex-digit charset first: this also guarantees ASCII, so the
96    // byte slices below always land on char boundaries (no panic on multi-byte
97    // input like "aéaé", which is 6 bytes but not 6 hex digits).
98    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    /// Resolve to a concrete palette. Unknown preset name -> default base + a
110    /// warning; set color fields override the base slot-by-slot. Any override
111    /// that isn't a valid `#rrggbb` color is ignored (base kept) and reported
112    /// in the warning rather than silently swallowed downstream.
113    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        // Surface both problems if present; neither is silently dropped.
145        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// Declared extension point; inert in v1 (parsed, not acted on).
155#[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    // On a TOML parse error, returns defaults; caller may warn using the message.
165    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
193/// Look up a built-in palette by name. `None` if the name is unknown.
194pub 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(), "pi".to_string(), "opencode".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(), "pi".to_string(), "opencode".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        // "aéaé" is 6 bytes but not char-aligned at 2/4; must reject, not panic.
381        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}