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 (reserved — schema defined for future use).
16    #[serde(default)]
17    pub keybindings: Option<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 (reserved for future use).
44#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct KeyBindings {}
46
47/// Plugin-specific configuration (reserved for future use).
48#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
49pub struct PluginConfig {}
50
51impl Config {
52    /// Load `config.toml` from `dir` or return a default config if the file
53    /// doesn't exist.
54    pub fn load_from(dir: &std::path::Path) -> Self {
55        Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
56    }
57
58    /// Like `load_from`, but returns an error message instead of silently
59    /// falling back to defaults.
60    pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
61        let path = dir.join("config.toml");
62        if !path.exists() {
63            return Err("config.toml not found".into());
64        }
65        let content = std::fs::read_to_string(&path)
66            .map_err(|e| format!("Failed to read config.toml: {e}"))?;
67        toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
68    }
69
70    /// Write the config to `dir/config.toml`.
71    pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
72        let path = dir.join("config.toml");
73        let content = toml::to_string_pretty(self)?;
74        std::fs::write(&path, content)?;
75        Ok(())
76    }
77}
78
79/// Watches `config.toml` for changes via filesystem notifications, with
80/// periodic mtime polling as a fallback.
81///
82/// Call [`ConfigManager::poll`] once per frame in the main loop.  When the
83/// file has been modified externally `dirty` is set to `true` and the new
84/// config is available via [`ConfigManager::config`].
85pub struct ConfigManager {
86    dir: PathBuf,
87    config: Config,
88    last_modified: Option<SystemTime>,
89    /// Set to `true` by [`poll`](ConfigManager::poll) when the file changed.
90    pub dirty: bool,
91    /// Error message from the last load/parse attempt, cleared on ack.
92    error: Option<String>,
93    /// Main loop tick rate (how often the UI refreshes and polls for input).
94    tick_rate: Duration,
95    /// Throttle: only poll the filesystem every N frames (fallback path).
96    poll_skip: u32,
97    /// Filesystem watcher (kept alive for the lifetime of the manager).
98    _watcher: Option<RecommendedWatcher>,
99    /// Channel receiver for filesystem events.
100    event_rx: Option<mpsc::Receiver<notify::Result<notify::Event>>>,
101}
102
103impl fmt::Debug for ConfigManager {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.debug_struct("ConfigManager")
106            .field("dir", &self.dir)
107            .field("config", &self.config)
108            .field("last_modified", &self.last_modified)
109            .field("dirty", &self.dirty)
110            .field("error", &self.error)
111            .field("tick_rate", &self.tick_rate)
112            .field("poll_skip", &self.poll_skip)
113            .finish()
114    }
115}
116
117impl ConfigManager {
118    /// Create a new manager, immediately loading the config from `dir` and
119    /// attempting to set up a filesystem watcher.
120    pub fn new(dir: PathBuf) -> Self {
121        let last_modified = dir
122            .join("config.toml")
123            .metadata()
124            .ok()
125            .and_then(|m| m.modified().ok());
126        let (config, error) = match Config::try_load_from(&dir) {
127            Ok(cfg) => (cfg, None),
128            Err(e) => (Config::default(), Some(e)),
129        };
130
131        let (watcher, event_rx) = Self::try_create_watcher(&dir);
132
133        ConfigManager {
134            dir,
135            config,
136            last_modified,
137            dirty: false,
138            error,
139            tick_rate: Duration::from_millis(100),
140            poll_skip: 0,
141            _watcher: watcher,
142            event_rx,
143        }
144    }
145
146    /// Create a new manager without a filesystem watcher (polling only).
147    /// Behaviour is identical to [`new`](Self::new) but skips the watcher
148    /// setup entirely.  Useful in tests where deterministic poll timing is
149    /// required.
150    #[cfg(test)]
151    pub fn new_polling_only(dir: PathBuf) -> Self {
152        let last_modified = dir
153            .join("config.toml")
154            .metadata()
155            .ok()
156            .and_then(|m| m.modified().ok());
157        let (config, error) = match Config::try_load_from(&dir) {
158            Ok(cfg) => (cfg, None),
159            Err(e) => (Config::default(), Some(e)),
160        };
161        ConfigManager {
162            dir,
163            config,
164            last_modified,
165            dirty: false,
166            error,
167            tick_rate: Duration::from_millis(100),
168            poll_skip: 0,
169            _watcher: None,
170            event_rx: None,
171        }
172    }
173
174    /// Try to set up a filesystem watcher on the config directory.
175    /// Returns `None` on any failure (e.g. platform not supported).
176    fn try_create_watcher(
177        dir: &std::path::Path,
178    ) -> (
179        Option<RecommendedWatcher>,
180        Option<mpsc::Receiver<notify::Result<notify::Event>>>,
181    ) {
182        let (tx, rx) = mpsc::channel();
183        match RecommendedWatcher::new(tx, notify::Config::default()) {
184            Ok(mut w) => match w.watch(dir, RecursiveMode::NonRecursive) {
185                Ok(()) => {
186                    log::info!("[santui] Filesystem watcher active on {:?}", dir);
187                    (Some(w), Some(rx))
188                }
189                Err(e) => {
190                    log::warn!("[santui] Failed to watch config dir: {e}; falling back to polling");
191                    (None, None)
192                }
193            },
194            Err(e) => {
195                log::warn!(
196                    "[santui] Failed to create filesystem watcher: {e}; falling back to polling"
197                );
198                (None, None)
199            }
200        }
201    }
202
203    /// Re-read config from disk.  Call this once per frame.
204    ///
205    /// When a filesystem watcher is active, events are drained immediately
206    /// (no throttling).  The mtime-based polling fallback runs every 30 frames
207    /// on platforms without a working watcher.
208    pub fn poll(&mut self) {
209        // Drain filesystem events immediately when watcher is available.
210        if let Some(ref rx) = self.event_rx {
211            let mut changed = false;
212            while let Ok(Ok(event)) = rx.try_recv() {
213                if Self::event_matches_config(&event) {
214                    changed = true;
215                }
216            }
217            if changed {
218                return self.reload();
219            }
220        }
221
222        // Polling fallback throttle.
223        self.poll_skip = self.poll_skip.saturating_sub(1);
224        if self.poll_skip > 0 {
225            return;
226        }
227        self.poll_skip = 30;
228        self.reload_if_modified();
229    }
230
231    /// Check whether a notify event applies to `config.toml`.
232    fn event_matches_config(event: &notify::Event) -> bool {
233        matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_))
234            && event
235                .paths
236                .iter()
237                .any(|p| p.file_name().is_some_and(|n| n == "config.toml"))
238    }
239
240    /// Check if the file has been modified (by comparing mtime against
241    /// `last_modified`) and reload if so.  This is the polling fallback.
242    fn reload_if_modified(&mut self) {
243        let path = self.dir.join("config.toml");
244        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
245            Some(t) => t,
246            None => return,
247        };
248        let changed = match self.last_modified {
249            Some(last) => modified != last,
250            None => true,
251        };
252        if !changed {
253            return;
254        }
255        self.last_modified = Some(modified);
256        self.reload_inner();
257    }
258
259    /// Reload config from disk and update error/dirty state.
260    fn reload(&mut self) {
261        self.reload_if_modified();
262    }
263
264    fn reload_inner(&mut self) {
265        match Config::try_load_from(&self.dir) {
266            Ok(cfg) => {
267                self.config = cfg;
268                self.error = None;
269                self.dirty = true;
270            }
271            Err(e) => {
272                self.error = Some(e);
273            }
274        }
275    }
276
277    /// Acknowledge the dirty flag (call after applying changes).
278    pub fn ack(&mut self) {
279        self.dirty = false;
280    }
281
282    pub fn config(&self) -> &Config {
283        &self.config
284    }
285
286    /// Error message from the last failed config load/parse, if any.
287    pub fn error(&self) -> Option<&str> {
288        self.error.as_deref()
289    }
290
291    /// Update the `theme` field and immediately persist.
292    /// When selecting a built-in theme, custom overrides are cleared so they
293    /// don't leak into the newly chosen theme.
294    pub fn save_theme(&mut self, theme_name: &str) {
295        self.config.theme = Some(theme_name.to_string());
296        self.config.custom_theme = None;
297        self.persist();
298    }
299
300    /// Set custom theme colour overrides in config and persist.
301    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
302        self.config.custom_theme = Some(colors);
303        self.persist();
304    }
305
306    pub fn tick_rate(&self) -> Duration {
307        self.tick_rate
308    }
309
310    pub fn set_tick_rate(&mut self, duration: Duration) {
311        self.tick_rate = duration;
312    }
313
314    /// Remove custom theme colour overrides from config and persist.
315    pub fn clear_custom_theme(&mut self) {
316        if self.config.custom_theme.is_some() {
317            self.config.custom_theme = None;
318            self.persist();
319        }
320    }
321
322    /// Write the in-memory config to disk and sync the modification timestamp
323    /// so reload calls don't re-detect our own write.
324    fn persist(&mut self) {
325        if let Err(e) = self.config.save_to(&self.dir) {
326            log::error!("[santui] Failed to save config: {e}");
327            return;
328        }
329        self.last_modified = self
330            .dir
331            .join("config.toml")
332            .metadata()
333            .ok()
334            .and_then(|m| m.modified().ok());
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::sync::atomic::{AtomicUsize, Ordering};
342
343    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
344
345    struct TempDir {
346        path: PathBuf,
347    }
348
349    impl TempDir {
350        fn new() -> Self {
351            let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
352            let mut p = std::env::temp_dir();
353            p.push(format!("santui_cfg_test_{id}"));
354            let _ = std::fs::remove_dir_all(&p);
355            std::fs::create_dir_all(&p).unwrap();
356            TempDir { path: p }
357        }
358
359        fn path(&self) -> &std::path::Path {
360            &self.path
361        }
362    }
363
364    impl Drop for TempDir {
365        fn drop(&mut self) {
366            let _ = std::fs::remove_dir_all(&self.path);
367        }
368    }
369
370    #[test]
371    fn config_default_all_none() {
372        let cfg = Config::default();
373        assert!(cfg.theme.is_none());
374        assert!(cfg.custom_theme.is_none());
375        assert!(cfg.keybindings.is_none());
376        assert!(cfg.plugins.is_none());
377    }
378
379    #[test]
380    fn try_load_from_missing_dir_returns_err() {
381        let tmp = TempDir::new();
382        let result = Config::try_load_from(tmp.path());
383        assert!(result.is_err());
384        assert!(result.unwrap_err().contains("not found"));
385    }
386
387    #[test]
388    fn try_load_from_invalid_toml_returns_err() {
389        let tmp = TempDir::new();
390        std::fs::write(tmp.path().join("config.toml"), "not toml {{{").unwrap();
391        let result = Config::try_load_from(tmp.path());
392        assert!(result.is_err());
393        assert!(result.unwrap_err().contains("parse"));
394    }
395
396    #[test]
397    fn load_from_missing_dir_returns_default() {
398        let tmp = TempDir::new();
399        let cfg = Config::load_from(tmp.path());
400        assert!(cfg.theme.is_none());
401    }
402
403    #[test]
404    fn save_to_and_load_from_roundtrip() {
405        let tmp = TempDir::new();
406        let cfg = Config {
407            theme: Some("Nord".into()),
408            custom_theme: Some(CustomThemeColors {
409                name: None,
410                accent: Some("#ff8800".into()),
411                highlight: None,
412                logo: None,
413                text: None,
414                text_muted: None,
415                background: None,
416                background_panel: None,
417                background_overlay: None,
418                border: None,
419                success: None,
420                error: None,
421                inverted_text: None,
422            }),
423            keybindings: None,
424            plugins: None,
425        };
426        cfg.save_to(tmp.path()).unwrap();
427        let loaded = Config::load_from(tmp.path());
428        assert_eq!(loaded.theme.as_deref(), Some("Nord"));
429    }
430
431    #[test]
432    fn config_manager_new_sets_last_modified() {
433        let tmp = TempDir::new();
434        let p = tmp.path().join("config.toml");
435        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
436        let mgr = ConfigManager::new(tmp.path().to_path_buf());
437        assert!(mgr.last_modified.is_some());
438        assert!(!mgr.dirty);
439    }
440
441    #[test]
442    fn config_manager_error_on_missing_file() {
443        let tmp = TempDir::new();
444        let mgr = ConfigManager::new(tmp.path().to_path_buf());
445        assert!(mgr.error().is_some());
446    }
447
448    #[test]
449    fn config_manager_error_cleared_on_successful_load() {
450        let tmp = TempDir::new();
451        let p = tmp.path().join("config.toml");
452        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
453        let mgr = ConfigManager::new(tmp.path().to_path_buf());
454        assert!(mgr.error().is_none());
455    }
456
457    #[test]
458    fn config_manager_clear_noop_when_no_custom() {
459        let tmp = TempDir::new();
460        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
461        mgr.clear_custom_theme();
462        assert!(mgr.config().custom_theme.is_none());
463    }
464
465    #[test]
466    fn config_manager_tick_rate_default_and_set() {
467        let tmp = TempDir::new();
468        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
469        assert_eq!(mgr.tick_rate(), Duration::from_millis(100));
470        mgr.set_tick_rate(Duration::from_millis(200));
471        assert_eq!(mgr.tick_rate(), Duration::from_millis(200));
472    }
473
474    #[test]
475    fn config_manager_poll_throttle_skips() {
476        let tmp = TempDir::new();
477        let p = tmp.path().join("config.toml");
478        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
479        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
480        mgr.poll_skip = 5;
481        mgr.ack();
482        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
483        mgr.poll();
484        assert!(!mgr.dirty, "should skip due to poll_skip");
485    }
486
487    #[test]
488    fn config_manager_poll_no_file_returns_early() {
489        let tmp = TempDir::new();
490        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
491        mgr.poll_skip = 0;
492        // File doesn't exist, metadata returns None → poll returns early.
493        mgr.poll();
494        assert!(!mgr.dirty);
495    }
496
497    #[test]
498    fn config_manager_watcher_detects_external_change() {
499        let tmp = TempDir::new();
500        let p = tmp.path().join("config.toml");
501        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
502        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
503        mgr.ack();
504        assert!(!mgr.dirty);
505
506        // Write a different config externally; the watcher should pick it up
507        // on the next poll (regardless of poll_skip).
508        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
509        mgr.poll();
510        assert!(mgr.dirty, "watcher should detect external change");
511        assert_eq!(mgr.config().theme.as_deref(), Some("Dracula"));
512    }
513
514    #[test]
515    fn config_manager_watcher_ignores_own_write() {
516        let tmp = TempDir::new();
517        let p = tmp.path().join("config.toml");
518        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
519        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
520        mgr.ack();
521        assert!(!mgr.dirty);
522
523        // persist() sets last_modified, so the watcher event should not
524        // trigger a reload.
525        mgr.save_theme("Dracula");
526        assert!(!mgr.dirty, "own write should not set dirty");
527
528        // Reading the file back should show the saved value.
529        let loaded = Config::load_from(tmp.path());
530        assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
531    }
532}