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#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
10pub struct Config {
11 pub theme: Option<String>,
13 pub custom_theme: Option<CustomThemeColors>,
15 #[serde(default)]
17 pub keybindings: Option<KeyBindings>,
18 #[serde(default)]
20 pub plugins: Option<PluginConfig>,
21}
22
23#[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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct KeyBindings {}
46
47#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
49pub struct PluginConfig {}
50
51impl Config {
52 pub fn load_from(dir: &std::path::Path) -> Self {
55 Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
56 }
57
58 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 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
79pub struct ConfigManager {
86 dir: PathBuf,
87 config: Config,
88 last_modified: Option<SystemTime>,
89 pub dirty: bool,
91 error: Option<String>,
93 tick_rate: Duration,
95 poll_skip: u32,
97 _watcher: Option<RecommendedWatcher>,
99 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 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 #[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 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 pub fn poll(&mut self) {
209 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 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 fn event_matches_config(event: ¬ify::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 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 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 pub fn ack(&mut self) {
279 self.dirty = false;
280 }
281
282 pub fn config(&self) -> &Config {
283 &self.config
284 }
285
286 pub fn error(&self) -> Option<&str> {
288 self.error.as_deref()
289 }
290
291 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 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 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 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 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 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 mgr.save_theme("Dracula");
526 assert!(!mgr.dirty, "own write should not set dirty");
527
528 let loaded = Config::load_from(tmp.path());
530 assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
531 }
532}