Skip to main content

santui_core/
config.rs

1use std::path::PathBuf;
2use std::time::{Duration, SystemTime};
3
4/// Top-level Santui configuration, deserialized from `config.toml`.
5#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
6pub struct Config {
7    /// Default theme name (must match a built-in theme or a custom theme name).
8    pub theme: Option<String>,
9    /// Custom theme color overrides.
10    pub custom_theme: Option<CustomThemeColors>,
11    /// Key-binding overrides (reserved — schema defined for future use).
12    #[serde(default)]
13    pub keybindings: Option<KeyBindings>,
14    /// Plugin-specific settings (reserved — schema defined for future use).
15    #[serde(default)]
16    pub plugins: Option<PluginConfig>,
17}
18
19/// Per-color-field overrides for a custom theme.
20///
21/// Each field is an optional hex colour string like `"#ff8800"` or `"ff8800"`.
22#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
23pub struct CustomThemeColors {
24    pub name: Option<String>,
25    pub accent: Option<String>,
26    pub highlight: Option<String>,
27    pub logo: Option<String>,
28    pub text: Option<String>,
29    pub text_muted: Option<String>,
30    pub background: Option<String>,
31    pub background_panel: Option<String>,
32    pub background_overlay: Option<String>,
33    pub border: Option<String>,
34    pub success: Option<String>,
35    pub error: Option<String>,
36    pub inverted_text: Option<String>,
37}
38
39/// Key-binding overrides (reserved for future use).
40#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
41pub struct KeyBindings {}
42
43/// Plugin-specific configuration (reserved for future use).
44#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct PluginConfig {}
46
47impl Config {
48    /// Load `config.toml` from `dir` or return a default config if the file
49    /// doesn't exist.
50    pub fn load_from(dir: &std::path::Path) -> Self {
51        Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
52    }
53
54    /// Like `load_from`, but returns an error message instead of silently
55    /// falling back to defaults.
56    pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
57        let path = dir.join("config.toml");
58        if !path.exists() {
59            return Err("config.toml not found".into());
60        }
61        let content = std::fs::read_to_string(&path)
62            .map_err(|e| format!("Failed to read config.toml: {e}"))?;
63        toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
64    }
65
66    /// Write the config to `dir/config.toml`.
67    pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
68        let path = dir.join("config.toml");
69        let content = toml::to_string_pretty(self)?;
70        std::fs::write(&path, content)?;
71        Ok(())
72    }
73}
74
75/// Watches `config.toml` for changes via periodic timestamp polling.
76///
77/// Call [`ConfigManager::poll`] once per frame in the main loop.  When the
78/// file has been modified externally `dirty` is set to `true` and the new
79/// config is available via [`ConfigManager::config`].
80#[derive(Debug)]
81pub struct ConfigManager {
82    dir: PathBuf,
83    config: Config,
84    last_modified: Option<SystemTime>,
85    /// Set to `true` by [`poll`](ConfigManager::poll) when the file changed.
86    pub dirty: bool,
87    /// Error message from the last load/parse attempt, cleared on ack.
88    error: Option<String>,
89    /// Main loop tick rate (how often the UI refreshes and polls for input).
90    tick_rate: Duration,
91    /// Throttle: only poll the filesystem every N frames.
92    poll_skip: u32,
93}
94
95impl ConfigManager {
96    /// Create a new manager, immediately loading the config from `dir`.
97    pub fn new(dir: PathBuf) -> Self {
98        let last_modified = dir
99            .join("config.toml")
100            .metadata()
101            .ok()
102            .and_then(|m| m.modified().ok());
103        let (config, error) = match Config::try_load_from(&dir) {
104            Ok(cfg) => (cfg, None),
105            Err(e) => (Config::default(), Some(e)),
106        };
107        ConfigManager {
108            dir,
109            config,
110            last_modified,
111            dirty: false,
112            error,
113            tick_rate: Duration::from_millis(100),
114            poll_skip: 0,
115        }
116    }
117
118    /// Re-read config from disk.  Call this once per frame.
119    pub fn poll(&mut self) {
120        self.poll_skip = self.poll_skip.saturating_sub(1);
121        if self.poll_skip > 0 {
122            return;
123        }
124        self.poll_skip = 30;
125        let path = self.dir.join("config.toml");
126        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
127            Some(t) => t,
128            None => return,
129        };
130        let changed = match self.last_modified {
131            Some(last) => modified != last,
132            None => true,
133        };
134        if !changed {
135            return;
136        }
137        self.last_modified = Some(modified);
138        match Config::try_load_from(&self.dir) {
139            Ok(cfg) => {
140                self.config = cfg;
141                self.error = None;
142                self.dirty = true;
143            }
144            Err(e) => {
145                self.error = Some(e);
146            }
147        }
148    }
149
150    /// Acknowledge the dirty flag (call after applying changes).
151    pub fn ack(&mut self) {
152        self.dirty = false;
153    }
154
155    pub fn config(&self) -> &Config {
156        &self.config
157    }
158
159    /// Error message from the last failed config load/parse, if any.
160    pub fn error(&self) -> Option<&str> {
161        self.error.as_deref()
162    }
163
164    /// Update the `theme` field and immediately persist.
165    /// When selecting a built-in theme, custom overrides are cleared so they
166    /// don't leak into the newly chosen theme.
167    pub fn save_theme(&mut self, theme_name: &str) {
168        self.config.theme = Some(theme_name.to_string());
169        self.config.custom_theme = None;
170        self.persist();
171    }
172
173    /// Set custom theme colour overrides in config and persist.
174    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
175        self.config.custom_theme = Some(colors);
176        self.persist();
177    }
178
179    pub fn tick_rate(&self) -> Duration {
180        self.tick_rate
181    }
182
183    pub fn set_tick_rate(&mut self, duration: Duration) {
184        self.tick_rate = duration;
185    }
186
187    /// Remove custom theme colour overrides from config and persist.
188    pub fn clear_custom_theme(&mut self) {
189        if self.config.custom_theme.is_some() {
190            self.config.custom_theme = None;
191            self.persist();
192        }
193    }
194
195    /// Write the in-memory config to disk and sync the modification timestamp
196    /// so the next `poll()` doesn't re-detect our own write.
197    fn persist(&mut self) {
198        if let Err(e) = self.config.save_to(&self.dir) {
199            log::error!("[santui] Failed to save config: {e}");
200            return;
201        }
202        self.last_modified = self
203            .dir
204            .join("config.toml")
205            .metadata()
206            .ok()
207            .and_then(|m| m.modified().ok());
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use std::sync::atomic::{AtomicUsize, Ordering};
215
216    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
217
218    struct TempDir {
219        path: PathBuf,
220    }
221
222    impl TempDir {
223        fn new() -> Self {
224            let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
225            let mut p = std::env::temp_dir();
226            p.push(format!("santui_cfg_test_{id}"));
227            let _ = std::fs::remove_dir_all(&p);
228            std::fs::create_dir_all(&p).unwrap();
229            TempDir { path: p }
230        }
231
232        fn path(&self) -> &std::path::Path {
233            &self.path
234        }
235    }
236
237    impl Drop for TempDir {
238        fn drop(&mut self) {
239            let _ = std::fs::remove_dir_all(&self.path);
240        }
241    }
242
243    #[test]
244    fn config_default_all_none() {
245        let cfg = Config::default();
246        assert!(cfg.theme.is_none());
247        assert!(cfg.custom_theme.is_none());
248        assert!(cfg.keybindings.is_none());
249        assert!(cfg.plugins.is_none());
250    }
251
252    #[test]
253    fn try_load_from_missing_dir_returns_err() {
254        let tmp = TempDir::new();
255        let result = Config::try_load_from(tmp.path());
256        assert!(result.is_err());
257        assert!(result.unwrap_err().contains("not found"));
258    }
259
260    #[test]
261    fn try_load_from_invalid_toml_returns_err() {
262        let tmp = TempDir::new();
263        std::fs::write(tmp.path().join("config.toml"), "not toml {{{").unwrap();
264        let result = Config::try_load_from(tmp.path());
265        assert!(result.is_err());
266        assert!(result.unwrap_err().contains("parse"));
267    }
268
269    #[test]
270    fn load_from_missing_dir_returns_default() {
271        let tmp = TempDir::new();
272        let cfg = Config::load_from(tmp.path());
273        assert!(cfg.theme.is_none());
274    }
275
276    #[test]
277    fn save_to_and_load_from_roundtrip() {
278        let tmp = TempDir::new();
279        let cfg = Config {
280            theme: Some("Nord".into()),
281            custom_theme: Some(CustomThemeColors {
282                name: None,
283                accent: Some("#ff8800".into()),
284                highlight: None,
285                logo: None,
286                text: None,
287                text_muted: None,
288                background: None,
289                background_panel: None,
290                background_overlay: None,
291                border: None,
292                success: None,
293                error: None,
294                inverted_text: None,
295            }),
296            keybindings: None,
297            plugins: None,
298        };
299        cfg.save_to(tmp.path()).unwrap();
300        let loaded = Config::load_from(tmp.path());
301        assert_eq!(loaded.theme.as_deref(), Some("Nord"));
302    }
303
304    #[test]
305    fn config_manager_new_sets_last_modified() {
306        let tmp = TempDir::new();
307        let p = tmp.path().join("config.toml");
308        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
309        let mgr = ConfigManager::new(tmp.path().to_path_buf());
310        assert!(mgr.last_modified.is_some());
311        assert!(!mgr.dirty);
312    }
313
314    #[test]
315    fn config_manager_error_on_missing_file() {
316        let tmp = TempDir::new();
317        let mgr = ConfigManager::new(tmp.path().to_path_buf());
318        assert!(mgr.error().is_some());
319    }
320
321    #[test]
322    fn config_manager_error_cleared_on_successful_load() {
323        let tmp = TempDir::new();
324        let p = tmp.path().join("config.toml");
325        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
326        let mgr = ConfigManager::new(tmp.path().to_path_buf());
327        assert!(mgr.error().is_none());
328    }
329
330    #[test]
331    fn config_manager_clear_noop_when_no_custom() {
332        let tmp = TempDir::new();
333        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
334        mgr.clear_custom_theme();
335        assert!(mgr.config().custom_theme.is_none());
336    }
337
338    #[test]
339    fn config_manager_tick_rate_default_and_set() {
340        let tmp = TempDir::new();
341        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
342        assert_eq!(mgr.tick_rate(), Duration::from_millis(100));
343        mgr.set_tick_rate(Duration::from_millis(200));
344        assert_eq!(mgr.tick_rate(), Duration::from_millis(200));
345    }
346
347    #[test]
348    fn config_manager_poll_throttle_skips() {
349        let tmp = TempDir::new();
350        let p = tmp.path().join("config.toml");
351        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
352        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
353        mgr.poll_skip = 5;
354        mgr.ack();
355        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
356        mgr.poll();
357        assert!(!mgr.dirty, "should skip due to poll_skip");
358    }
359
360    #[test]
361    fn config_manager_poll_no_file_returns_early() {
362        let tmp = TempDir::new();
363        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
364        mgr.poll_skip = 0;
365        // File doesn't exist, metadata returns None → poll returns early.
366        mgr.poll();
367        assert!(!mgr.dirty);
368    }
369}