Skip to main content

santui_core/
config.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::sync::mpsc;
4use std::time::{Duration, SystemTime};
5
6use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
7
8/// Top-level Santui configuration, deserialized from `config.toml`.
9#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
10pub struct Config {
11    /// Default theme name (must match a built-in theme or a custom theme name).
12    pub theme: Option<String>,
13    /// Custom theme color overrides.
14    pub custom_theme: Option<CustomThemeColors>,
15    /// Key-binding overrides.
16    #[serde(default)]
17    pub keybindings: KeyBindings,
18    /// Plugin-specific settings (reserved — schema defined for future use).
19    #[serde(default)]
20    pub plugins: Option<PluginConfig>,
21}
22
23/// Per-color-field overrides for a custom theme.
24///
25/// Each field is an optional hex colour string like `"#ff8800"` or `"ff8800"`.
26#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
27pub struct CustomThemeColors {
28    pub name: Option<String>,
29    pub accent: Option<String>,
30    pub highlight: Option<String>,
31    pub logo: Option<String>,
32    pub text: Option<String>,
33    pub text_muted: Option<String>,
34    pub background: Option<String>,
35    pub background_panel: Option<String>,
36    pub background_overlay: Option<String>,
37    pub border: Option<String>,
38    pub success: Option<String>,
39    pub error: Option<String>,
40    pub inverted_text: Option<String>,
41}
42
43/// Key-binding overrides.
44#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct KeyBindings {
46    /// Key to open the command palette. Default: Ctrl+P.
47    /// Format: "ctrl+p", "ctrl+space", "alt+p"
48    #[serde(default = "KeyBindings::default_open_palette")]
49    pub open_palette: String,
50
51    /// Key to quit the application. Default: "q"
52    #[serde(default = "KeyBindings::default_quit")]
53    pub quit: String,
54
55    /// Key to show the about screen. Default: "?"
56    #[serde(default = "KeyBindings::default_about")]
57    pub about: String,
58}
59
60impl Default for KeyBindings {
61    fn default() -> Self {
62        Self {
63            open_palette: Self::default_open_palette(),
64            quit: Self::default_quit(),
65            about: Self::default_about(),
66        }
67    }
68}
69
70impl KeyBindings {
71    fn default_open_palette() -> String {
72        "ctrl+p".into()
73    }
74
75    fn default_quit() -> String {
76        "q".into()
77    }
78
79    fn default_about() -> String {
80        "?".into()
81    }
82
83    /// Parse "ctrl+p" → (KeyCode::Char('p'), KeyModifiers::CONTROL).
84    pub fn parse_key(
85        s: &str,
86    ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
87        let s = s.to_lowercase();
88        let parts: Vec<&str> = s.split('+').collect();
89        let key_str = *parts.last()?;
90        let mut mods = crossterm::event::KeyModifiers::NONE;
91        for part in &parts[..parts.len().saturating_sub(1)] {
92            match *part {
93                "ctrl" => mods |= crossterm::event::KeyModifiers::CONTROL,
94                "alt" => mods |= crossterm::event::KeyModifiers::ALT,
95                "shift" => mods |= crossterm::event::KeyModifiers::SHIFT,
96                _ => return None,
97            }
98        }
99        let code = match key_str {
100            "space" => crossterm::event::KeyCode::Char(' '),
101            s if s.len() == 1 => crossterm::event::KeyCode::Char(s.chars().next()?),
102            _ => return None,
103        };
104        Some((code, mods))
105    }
106}
107
108/// Plugin-specific configuration (reserved for future use).
109#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
110pub struct PluginConfig {}
111
112impl Config {
113    /// Load `config.toml` from `dir` or return a default config if the file
114    /// doesn't exist.
115    pub fn load_from(dir: &std::path::Path) -> Self {
116        Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
117    }
118
119    /// Like `load_from`, but returns an error message instead of silently
120    /// falling back to defaults.
121    pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
122        let path = dir.join("config.toml");
123        if !path.exists() {
124            return Err("config.toml not found".into());
125        }
126        let content = std::fs::read_to_string(&path)
127            .map_err(|e| format!("Failed to read config.toml: {e}"))?;
128        toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
129    }
130
131    /// Write the config to `dir/config.toml`.
132    pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
133        let path = dir.join("config.toml");
134        let content = toml::to_string_pretty(self)?;
135        std::fs::write(&path, content)?;
136        Ok(())
137    }
138}
139
140/// Watches `config.toml` for changes via filesystem notifications, with
141/// periodic mtime polling as a fallback.
142///
143/// Call [`ConfigManager::poll`] once per frame in the main loop.  When the
144/// file has been modified externally `dirty` is set to `true` and the new
145/// config is available via [`ConfigManager::config`].
146pub struct ConfigManager {
147    dir: PathBuf,
148    config: Config,
149    last_modified: Option<SystemTime>,
150    /// Set to `true` by [`poll`](ConfigManager::poll) when the file changed.
151    pub dirty: bool,
152    /// Error message from the last load/parse attempt, cleared on ack.
153    error: Option<String>,
154    /// Main loop tick rate (how often the UI refreshes and polls for input).
155    tick_rate: Duration,
156    /// Throttle: only poll the filesystem every N frames (fallback path).
157    poll_skip: u32,
158    /// Filesystem watcher (kept alive for the lifetime of the manager).
159    _watcher: Option<RecommendedWatcher>,
160    /// Channel receiver for filesystem events.
161    event_rx: Option<mpsc::Receiver<notify::Result<notify::Event>>>,
162}
163
164impl fmt::Debug for ConfigManager {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.debug_struct("ConfigManager")
167            .field("dir", &self.dir)
168            .field("config", &self.config)
169            .field("last_modified", &self.last_modified)
170            .field("dirty", &self.dirty)
171            .field("error", &self.error)
172            .field("tick_rate", &self.tick_rate)
173            .field("poll_skip", &self.poll_skip)
174            .finish()
175    }
176}
177
178impl ConfigManager {
179    /// Create a new manager, immediately loading the config from `dir` and
180    /// attempting to set up a filesystem watcher.
181    pub fn new(dir: PathBuf) -> Self {
182        let last_modified = dir
183            .join("config.toml")
184            .metadata()
185            .ok()
186            .and_then(|m| m.modified().ok());
187        let (config, error) = match Config::try_load_from(&dir) {
188            Ok(cfg) => (cfg, None),
189            Err(e) => (Config::default(), Some(e)),
190        };
191
192        let (watcher, event_rx) = Self::try_create_watcher(&dir);
193
194        ConfigManager {
195            dir,
196            config,
197            last_modified,
198            dirty: false,
199            error,
200            tick_rate: Duration::from_millis(100),
201            poll_skip: 0,
202            _watcher: watcher,
203            event_rx,
204        }
205    }
206
207    /// Create a new manager without a filesystem watcher (polling only).
208    /// Behaviour is identical to [`new`](Self::new) but skips the watcher
209    /// setup entirely.  Useful in tests where deterministic poll timing is
210    /// required.
211    #[cfg(test)]
212    pub fn new_polling_only(dir: PathBuf) -> Self {
213        let last_modified = dir
214            .join("config.toml")
215            .metadata()
216            .ok()
217            .and_then(|m| m.modified().ok());
218        let (config, error) = match Config::try_load_from(&dir) {
219            Ok(cfg) => (cfg, None),
220            Err(e) => (Config::default(), Some(e)),
221        };
222        ConfigManager {
223            dir,
224            config,
225            last_modified,
226            dirty: false,
227            error,
228            tick_rate: Duration::from_millis(100),
229            poll_skip: 0,
230            _watcher: None,
231            event_rx: None,
232        }
233    }
234
235    /// Try to set up a filesystem watcher on the config directory.
236    /// Returns `None` on any failure (e.g. platform not supported).
237    fn try_create_watcher(
238        dir: &std::path::Path,
239    ) -> (
240        Option<RecommendedWatcher>,
241        Option<mpsc::Receiver<notify::Result<notify::Event>>>,
242    ) {
243        let (tx, rx) = mpsc::channel();
244        match RecommendedWatcher::new(tx, notify::Config::default()) {
245            Ok(mut w) => match w.watch(dir, RecursiveMode::NonRecursive) {
246                Ok(()) => {
247                    log::info!("[santui] Filesystem watcher active on {:?}", dir);
248                    (Some(w), Some(rx))
249                }
250                Err(e) => {
251                    log::warn!("[santui] Failed to watch config dir: {e}; falling back to polling");
252                    (None, None)
253                }
254            },
255            Err(e) => {
256                log::warn!(
257                    "[santui] Failed to create filesystem watcher: {e}; falling back to polling"
258                );
259                (None, None)
260            }
261        }
262    }
263
264    /// Re-read config from disk.  Call this once per frame.
265    ///
266    /// When a filesystem watcher is active, events are drained immediately
267    /// (no throttling).  The mtime-based polling fallback runs every 30 frames
268    /// on platforms without a working watcher.
269    pub fn poll(&mut self) {
270        // Drain filesystem events immediately when watcher is available.
271        if let Some(ref rx) = self.event_rx {
272            let mut changed = false;
273            while let Ok(Ok(event)) = rx.try_recv() {
274                if Self::event_matches_config(&event) {
275                    changed = true;
276                }
277            }
278            if changed {
279                return self.reload();
280            }
281        }
282
283        // Polling fallback throttle.
284        self.poll_skip = self.poll_skip.saturating_sub(1);
285        if self.poll_skip > 0 {
286            return;
287        }
288        self.poll_skip = 30;
289        self.reload_if_modified();
290    }
291
292    /// Check whether a notify event applies to `config.toml`.
293    fn event_matches_config(event: &notify::Event) -> bool {
294        matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_))
295            && event
296                .paths
297                .iter()
298                .any(|p| p.file_name().is_some_and(|n| n == "config.toml"))
299    }
300
301    /// Check if the file has been modified (by comparing mtime against
302    /// `last_modified`) and reload if so.  This is the polling fallback.
303    fn reload_if_modified(&mut self) {
304        let path = self.dir.join("config.toml");
305        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
306            Some(t) => t,
307            None => return,
308        };
309        let changed = match self.last_modified {
310            Some(last) => modified != last,
311            None => true,
312        };
313        if !changed {
314            return;
315        }
316        self.last_modified = Some(modified);
317        self.reload_inner();
318    }
319
320    /// Reload config from disk and update error/dirty state.
321    fn reload(&mut self) {
322        self.reload_if_modified();
323    }
324
325    fn reload_inner(&mut self) {
326        match Config::try_load_from(&self.dir) {
327            Ok(cfg) => {
328                self.config = cfg;
329                self.error = None;
330                self.dirty = true;
331            }
332            Err(e) => {
333                self.error = Some(e);
334            }
335        }
336    }
337
338    /// Acknowledge the dirty flag (call after applying changes).
339    pub fn ack(&mut self) {
340        self.dirty = false;
341    }
342
343    pub fn config(&self) -> &Config {
344        &self.config
345    }
346
347    /// Error message from the last failed config load/parse, if any.
348    pub fn error(&self) -> Option<&str> {
349        self.error.as_deref()
350    }
351
352    /// Update the `theme` field and immediately persist.
353    /// When selecting a built-in theme, custom overrides are cleared so they
354    /// don't leak into the newly chosen theme.
355    pub fn save_theme(&mut self, theme_name: &str) {
356        self.config.theme = Some(theme_name.to_string());
357        self.config.custom_theme = None;
358        self.persist();
359    }
360
361    /// Set custom theme colour overrides in config and persist.
362    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
363        self.config.custom_theme = Some(colors);
364        self.persist();
365    }
366
367    pub fn tick_rate(&self) -> Duration {
368        self.tick_rate
369    }
370
371    pub fn set_tick_rate(&mut self, duration: Duration) {
372        self.tick_rate = duration;
373    }
374
375    /// Remove custom theme colour overrides from config and persist.
376    pub fn clear_custom_theme(&mut self) {
377        if self.config.custom_theme.is_some() {
378            self.config.custom_theme = None;
379            self.persist();
380        }
381    }
382
383    /// Write the in-memory config to disk and sync the modification timestamp
384    /// so reload calls don't re-detect our own write.
385    ///
386    /// Uses a temp-file + atomic rename to eliminate the TOCTOU window between
387    /// writing and reading back the mtime (see AGENTS.md: no fragile solutions).
388    fn persist(&mut self) {
389        let path = self.dir.join("config.toml");
390        let tmp_path = self.dir.join("config.toml.tmp");
391
392        let content = match toml::to_string_pretty(&self.config) {
393            Ok(c) => c,
394            Err(e) => {
395                log::error!("[santui] Failed to serialize config: {e}");
396                return;
397            }
398        };
399
400        if let Err(e) = std::fs::write(&tmp_path, &content) {
401            log::error!("[santui] Failed to write config: {e}");
402            let _ = std::fs::remove_file(&tmp_path);
403            return;
404        }
405
406        // Capture mtime before the atomic rename — no window for an external
407        // writer to slip in between our write and our metadata read.
408        self.last_modified = tmp_path.metadata().ok().and_then(|m| m.modified().ok());
409
410        if let Err(e) = std::fs::rename(&tmp_path, &path) {
411            log::error!("[santui] Failed to rename config: {e}");
412            let _ = std::fs::remove_file(&tmp_path);
413        }
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crossterm::event::{KeyCode, KeyModifiers};
421    use std::sync::atomic::{AtomicUsize, Ordering};
422
423    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
424
425    struct TempDir {
426        path: PathBuf,
427    }
428
429    impl TempDir {
430        fn new() -> Self {
431            let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
432            let mut p = std::env::temp_dir();
433            p.push(format!("santui_cfg_test_{id}"));
434            let _ = std::fs::remove_dir_all(&p);
435            std::fs::create_dir_all(&p).unwrap();
436            TempDir { path: p }
437        }
438
439        fn path(&self) -> &std::path::Path {
440            &self.path
441        }
442    }
443
444    impl Drop for TempDir {
445        fn drop(&mut self) {
446            let _ = std::fs::remove_dir_all(&self.path);
447        }
448    }
449
450    #[test]
451    fn config_default_all_none() {
452        let cfg = Config::default();
453        assert!(cfg.theme.is_none());
454        assert!(cfg.custom_theme.is_none());
455        assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
456        assert_eq!(cfg.keybindings.quit, "q");
457        assert_eq!(cfg.keybindings.about, "?");
458        assert!(cfg.plugins.is_none());
459    }
460
461    #[test]
462    fn try_load_from_missing_dir_returns_err() {
463        let tmp = TempDir::new();
464        let result = Config::try_load_from(tmp.path());
465        assert!(result.is_err());
466        assert!(result.unwrap_err().contains("not found"));
467    }
468
469    #[test]
470    fn try_load_from_invalid_toml_returns_err() {
471        let tmp = TempDir::new();
472        std::fs::write(tmp.path().join("config.toml"), "not toml {{{").unwrap();
473        let result = Config::try_load_from(tmp.path());
474        assert!(result.is_err());
475        assert!(result.unwrap_err().contains("parse"));
476    }
477
478    #[test]
479    fn load_from_missing_dir_returns_default() {
480        let tmp = TempDir::new();
481        let cfg = Config::load_from(tmp.path());
482        assert!(cfg.theme.is_none());
483    }
484
485    #[test]
486    fn save_to_and_load_from_roundtrip() {
487        let tmp = TempDir::new();
488        let cfg = Config {
489            theme: Some("Nord".into()),
490            custom_theme: Some(CustomThemeColors {
491                name: None,
492                accent: Some("#ff8800".into()),
493                highlight: None,
494                logo: None,
495                text: None,
496                text_muted: None,
497                background: None,
498                background_panel: None,
499                background_overlay: None,
500                border: None,
501                success: None,
502                error: None,
503                inverted_text: None,
504            }),
505            keybindings: KeyBindings::default(),
506            plugins: None,
507        };
508        cfg.save_to(tmp.path()).unwrap();
509        let loaded = Config::load_from(tmp.path());
510        assert_eq!(loaded.theme.as_deref(), Some("Nord"));
511    }
512
513    #[test]
514    fn config_manager_new_sets_last_modified() {
515        let tmp = TempDir::new();
516        let p = tmp.path().join("config.toml");
517        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
518        let mgr = ConfigManager::new(tmp.path().to_path_buf());
519        assert!(mgr.last_modified.is_some());
520        assert!(!mgr.dirty);
521    }
522
523    #[test]
524    fn config_manager_error_on_missing_file() {
525        let tmp = TempDir::new();
526        let mgr = ConfigManager::new(tmp.path().to_path_buf());
527        assert!(mgr.error().is_some());
528    }
529
530    #[test]
531    fn config_manager_error_cleared_on_successful_load() {
532        let tmp = TempDir::new();
533        let p = tmp.path().join("config.toml");
534        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
535        let mgr = ConfigManager::new(tmp.path().to_path_buf());
536        assert!(mgr.error().is_none());
537    }
538
539    #[test]
540    fn config_manager_clear_noop_when_no_custom() {
541        let tmp = TempDir::new();
542        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
543        mgr.clear_custom_theme();
544        assert!(mgr.config().custom_theme.is_none());
545    }
546
547    #[test]
548    fn config_manager_tick_rate_default_and_set() {
549        let tmp = TempDir::new();
550        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
551        assert_eq!(mgr.tick_rate(), Duration::from_millis(100));
552        mgr.set_tick_rate(Duration::from_millis(200));
553        assert_eq!(mgr.tick_rate(), Duration::from_millis(200));
554    }
555
556    #[test]
557    fn config_manager_poll_throttle_skips() {
558        let tmp = TempDir::new();
559        let p = tmp.path().join("config.toml");
560        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
561        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
562        mgr.poll_skip = 5;
563        mgr.ack();
564        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
565        mgr.poll();
566        assert!(!mgr.dirty, "should skip due to poll_skip");
567    }
568
569    #[test]
570    fn config_manager_poll_no_file_returns_early() {
571        let tmp = TempDir::new();
572        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
573        mgr.poll_skip = 0;
574        // File doesn't exist, metadata returns None → poll returns early.
575        mgr.poll();
576        assert!(!mgr.dirty);
577    }
578
579    #[test]
580    fn config_manager_watcher_detects_external_change() {
581        let tmp = TempDir::new();
582        let p = tmp.path().join("config.toml");
583        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
584        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
585        mgr.ack();
586        assert!(!mgr.dirty);
587
588        // Write a different config externally; the watcher should pick it up
589        // on the next poll (regardless of poll_skip).
590        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
591        mgr.poll();
592        assert!(mgr.dirty, "watcher should detect external change");
593        assert_eq!(mgr.config().theme.as_deref(), Some("Dracula"));
594    }
595
596    #[test]
597    fn config_manager_watcher_ignores_own_write() {
598        let tmp = TempDir::new();
599        let p = tmp.path().join("config.toml");
600        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
601        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
602        mgr.ack();
603        assert!(!mgr.dirty);
604
605        // persist() sets last_modified, so the watcher event should not
606        // trigger a reload.
607        mgr.save_theme("Dracula");
608        assert!(!mgr.dirty, "own write should not set dirty");
609
610        // Reading the file back should show the saved value.
611        let loaded = Config::load_from(tmp.path());
612        assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
613    }
614
615    // ------------------------------------------------------------------
616    // KeyBindings tests
617    // ------------------------------------------------------------------
618
619    #[test]
620    fn keybindings_default_values() {
621        let kb = KeyBindings::default();
622        assert_eq!(kb.open_palette, "ctrl+p");
623        assert_eq!(kb.quit, "q");
624        assert_eq!(kb.about, "?");
625    }
626
627    #[test]
628    fn parse_key_ctrl_p() {
629        let (code, mods) = KeyBindings::parse_key("ctrl+p").unwrap();
630        assert_eq!(code, KeyCode::Char('p'));
631        assert!(mods.contains(KeyModifiers::CONTROL));
632    }
633
634    #[test]
635    fn parse_key_plain_char() {
636        let (code, mods) = KeyBindings::parse_key("q").unwrap();
637        assert_eq!(code, KeyCode::Char('q'));
638        assert!(mods.is_empty());
639    }
640
641    #[test]
642    fn parse_key_question_mark() {
643        let (code, mods) = KeyBindings::parse_key("?").unwrap();
644        assert_eq!(code, KeyCode::Char('?'));
645        assert!(mods.is_empty());
646    }
647
648    #[test]
649    fn parse_key_ctrl_space() {
650        let (code, mods) = KeyBindings::parse_key("ctrl+space").unwrap();
651        assert_eq!(code, KeyCode::Char(' '));
652        assert!(mods.contains(KeyModifiers::CONTROL));
653    }
654
655    #[test]
656    fn parse_key_invalid_returns_none() {
657        assert!(KeyBindings::parse_key("xyz+abc").is_none());
658    }
659
660    #[test]
661    fn keybindings_deserialize_from_toml() {
662        let toml_str = r#"
663            [keybindings]
664            open_palette = "alt+p"
665            quit = "ctrl+q"
666            about = "shift+/"
667        "#;
668        let cfg: Config = toml::from_str(toml_str).unwrap();
669        assert_eq!(cfg.keybindings.open_palette, "alt+p");
670        assert_eq!(cfg.keybindings.quit, "ctrl+q");
671        assert_eq!(cfg.keybindings.about, "shift+/");
672    }
673
674    #[test]
675    fn keybindings_missing_from_toml_uses_defaults() {
676        let toml_str = r#"
677            theme = "Nord"
678        "#;
679        let cfg: Config = toml::from_str(toml_str).unwrap();
680        assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
681        assert_eq!(cfg.keybindings.quit, "q");
682        assert_eq!(cfg.keybindings.about, "?");
683    }
684}