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: 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 #[serde(default = "KeyBindings::default_open_palette")]
49 pub open_palette: String,
50
51 #[serde(default = "KeyBindings::default_quit")]
53 pub quit: String,
54
55 #[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 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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
110pub struct PluginConfig {}
111
112impl Config {
113 pub fn load_from(dir: &std::path::Path) -> Self {
116 Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
117 }
118
119 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 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
140pub struct ConfigManager {
147 dir: PathBuf,
148 config: Config,
149 last_modified: Option<SystemTime>,
150 pub dirty: bool,
152 error: Option<String>,
154 tick_rate: Duration,
156 poll_skip: u32,
158 _watcher: Option<RecommendedWatcher>,
160 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 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 #[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 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 pub fn poll(&mut self) {
270 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 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 fn event_matches_config(event: ¬ify::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 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 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 pub fn ack(&mut self) {
340 self.dirty = false;
341 }
342
343 pub fn config(&self) -> &Config {
344 &self.config
345 }
346
347 pub fn error(&self) -> Option<&str> {
349 self.error.as_deref()
350 }
351
352 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 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 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 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 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 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 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 mgr.save_theme("Dracula");
608 assert!(!mgr.dirty, "own write should not set dirty");
609
610 let loaded = Config::load_from(tmp.path());
612 assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
613 }
614
615 #[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}