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 = "default_mouse_capture")]
22 pub mouse_capture: bool,
23 #[serde(default)]
25 pub plugins: Option<PluginConfig>,
26 #[serde(default)]
28 pub server: Option<ServerConfig>,
29}
30
31fn default_mouse_capture() -> bool {
32 false
33}
34
35#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
40pub struct ServerConfig {
41 pub url: String,
43}
44
45#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
49pub struct CustomThemeColors {
50 pub name: Option<String>,
51 pub accent: Option<String>,
52 pub highlight: Option<String>,
53 pub logo: Option<String>,
54 pub text: Option<String>,
55 pub text_muted: Option<String>,
56 pub background: Option<String>,
57 pub background_panel: Option<String>,
58 pub background_overlay: Option<String>,
59 pub border: Option<String>,
60 pub success: Option<String>,
61 pub error: Option<String>,
62 pub inverted_text: Option<String>,
63}
64
65#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
67pub struct KeyBindings {
68 #[serde(default = "KeyBindings::default_open_palette")]
71 pub open_palette: String,
72
73 #[serde(default = "KeyBindings::default_quit")]
75 pub quit: String,
76
77 #[serde(default = "KeyBindings::default_about")]
79 pub about: String,
80}
81
82impl Default for KeyBindings {
83 fn default() -> Self {
84 Self {
85 open_palette: Self::default_open_palette(),
86 quit: Self::default_quit(),
87 about: Self::default_about(),
88 }
89 }
90}
91
92impl KeyBindings {
93 fn default_open_palette() -> String {
94 "ctrl+p".into()
95 }
96
97 fn default_quit() -> String {
98 "q".into()
99 }
100
101 fn default_about() -> String {
102 "?".into()
103 }
104
105 pub fn parse_key(
107 s: &str,
108 ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
109 let s = s.to_lowercase();
110 let parts: Vec<&str> = s.split('+').collect();
111 let key_str = *parts.last()?;
112 let mut mods = crossterm::event::KeyModifiers::NONE;
113 for part in &parts[..parts.len().saturating_sub(1)] {
114 match *part {
115 "ctrl" => mods |= crossterm::event::KeyModifiers::CONTROL,
116 "alt" => mods |= crossterm::event::KeyModifiers::ALT,
117 "shift" => mods |= crossterm::event::KeyModifiers::SHIFT,
118 _ => return None,
119 }
120 }
121 let code = match key_str {
122 "space" => crossterm::event::KeyCode::Char(' '),
123 s if s.len() == 1 => crossterm::event::KeyCode::Char(s.chars().next()?),
124 _ => return None,
125 };
126 Some((code, mods))
127 }
128}
129
130#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
132pub struct PluginConfig {}
133
134impl Config {
135 pub fn load_from(dir: &std::path::Path) -> Self {
138 Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
139 }
140
141 pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
144 let path = dir.join("config.toml");
145 if !path.exists() {
146 return Err("config.toml not found".into());
147 }
148 let content = std::fs::read_to_string(&path)
149 .map_err(|e| format!("Failed to read config.toml: {e}"))?;
150 toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
151 }
152
153 pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
155 let path = dir.join("config.toml");
156 let content = toml::to_string_pretty(self)?;
157 std::fs::write(&path, content)?;
158 Ok(())
159 }
160}
161
162pub struct ConfigManager {
169 dir: PathBuf,
170 config: Config,
171 last_modified: Option<SystemTime>,
172 pub dirty: bool,
174 error: Option<String>,
176 tick_rate: Duration,
178 poll_skip: u32,
180 _watcher: Option<RecommendedWatcher>,
182 event_rx: Option<mpsc::Receiver<notify::Result<notify::Event>>>,
184}
185
186impl fmt::Debug for ConfigManager {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 f.debug_struct("ConfigManager")
189 .field("dir", &self.dir)
190 .field("config", &self.config)
191 .field("last_modified", &self.last_modified)
192 .field("dirty", &self.dirty)
193 .field("error", &self.error)
194 .field("tick_rate", &self.tick_rate)
195 .field("poll_skip", &self.poll_skip)
196 .finish()
197 }
198}
199
200impl ConfigManager {
201 pub fn new(dir: PathBuf) -> Self {
204 let last_modified = dir
205 .join("config.toml")
206 .metadata()
207 .ok()
208 .and_then(|m| m.modified().ok());
209 let (config, error) = match Config::try_load_from(&dir) {
210 Ok(cfg) => (cfg, None),
211 Err(e) => (Config::default(), Some(e)),
212 };
213
214 let (watcher, event_rx) = Self::try_create_watcher(&dir);
215
216 ConfigManager {
217 dir,
218 config,
219 last_modified,
220 dirty: false,
221 error,
222 tick_rate: Duration::from_millis(100),
223 poll_skip: 0,
224 _watcher: watcher,
225 event_rx,
226 }
227 }
228
229 #[cfg(test)]
234 pub fn new_polling_only(dir: PathBuf) -> Self {
235 let last_modified = dir
236 .join("config.toml")
237 .metadata()
238 .ok()
239 .and_then(|m| m.modified().ok());
240 let (config, error) = match Config::try_load_from(&dir) {
241 Ok(cfg) => (cfg, None),
242 Err(e) => (Config::default(), Some(e)),
243 };
244 ConfigManager {
245 dir,
246 config,
247 last_modified,
248 dirty: false,
249 error,
250 tick_rate: Duration::from_millis(100),
251 poll_skip: 0,
252 _watcher: None,
253 event_rx: None,
254 }
255 }
256
257 fn try_create_watcher(
260 dir: &std::path::Path,
261 ) -> (
262 Option<RecommendedWatcher>,
263 Option<mpsc::Receiver<notify::Result<notify::Event>>>,
264 ) {
265 let (tx, rx) = mpsc::channel();
266 match RecommendedWatcher::new(tx, notify::Config::default()) {
267 Ok(mut w) => match w.watch(dir, RecursiveMode::NonRecursive) {
268 Ok(()) => {
269 log::info!("[santui] Filesystem watcher active on {:?}", dir);
270 (Some(w), Some(rx))
271 }
272 Err(e) => {
273 log::warn!("[santui] Failed to watch config dir: {e}; falling back to polling");
274 (None, None)
275 }
276 },
277 Err(e) => {
278 log::warn!(
279 "[santui] Failed to create filesystem watcher: {e}; falling back to polling"
280 );
281 (None, None)
282 }
283 }
284 }
285
286 pub fn poll(&mut self) {
292 if let Some(ref rx) = self.event_rx {
294 let mut changed = false;
295 while let Ok(Ok(event)) = rx.try_recv() {
296 if Self::event_matches_config(&event) {
297 changed = true;
298 }
299 }
300 if changed {
301 return self.reload();
302 }
303 }
304
305 self.poll_skip = self.poll_skip.saturating_sub(1);
307 if self.poll_skip > 0 {
308 return;
309 }
310 self.poll_skip = 30;
311 self.reload_if_modified();
312 }
313
314 fn event_matches_config(event: ¬ify::Event) -> bool {
316 matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_))
317 && event
318 .paths
319 .iter()
320 .any(|p| p.file_name().is_some_and(|n| n == "config.toml"))
321 }
322
323 fn reload_if_modified(&mut self) {
326 let path = self.dir.join("config.toml");
327 let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
328 Some(t) => t,
329 None => return,
330 };
331 let changed = match self.last_modified {
332 Some(last) => modified != last,
333 None => true,
334 };
335 if !changed {
336 return;
337 }
338 self.last_modified = Some(modified);
339 self.reload_inner();
340 }
341
342 fn reload(&mut self) {
344 self.reload_if_modified();
345 }
346
347 fn reload_inner(&mut self) {
348 match Config::try_load_from(&self.dir) {
349 Ok(cfg) => {
350 self.config = cfg;
351 self.error = None;
352 self.dirty = true;
353 }
354 Err(e) => {
355 self.error = Some(e);
356 }
357 }
358 }
359
360 pub fn ack(&mut self) {
362 self.dirty = false;
363 }
364
365 pub fn config(&self) -> &Config {
366 &self.config
367 }
368
369 pub fn error(&self) -> Option<&str> {
371 self.error.as_deref()
372 }
373
374 pub fn save_theme(&mut self, theme_name: &str) {
378 self.config.theme = Some(theme_name.to_string());
379 self.config.custom_theme = None;
380 self.persist();
381 }
382
383 pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
385 self.config.custom_theme = Some(colors);
386 self.persist();
387 }
388
389 pub fn tick_rate(&self) -> Duration {
390 self.tick_rate
391 }
392
393 pub fn set_tick_rate(&mut self, duration: Duration) {
394 self.tick_rate = duration;
395 }
396
397 pub fn clear_custom_theme(&mut self) {
399 if self.config.custom_theme.is_some() {
400 self.config.custom_theme = None;
401 self.persist();
402 }
403 }
404
405 fn persist(&mut self) {
411 let path = self.dir.join("config.toml");
412 let tmp_path = self.dir.join("config.toml.tmp");
413
414 let content = match toml::to_string_pretty(&self.config) {
415 Ok(c) => c,
416 Err(e) => {
417 log::error!("[santui] Failed to serialize config: {e}");
418 return;
419 }
420 };
421
422 if let Err(e) = std::fs::write(&tmp_path, &content) {
423 log::error!("[santui] Failed to write config: {e}");
424 let _ = std::fs::remove_file(&tmp_path);
425 return;
426 }
427
428 self.last_modified = tmp_path.metadata().ok().and_then(|m| m.modified().ok());
431
432 if let Err(e) = std::fs::rename(&tmp_path, &path) {
433 log::error!("[santui] Failed to rename config: {e}");
434 let _ = std::fs::remove_file(&tmp_path);
435 }
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crossterm::event::{KeyCode, KeyModifiers};
443 use std::sync::atomic::{AtomicUsize, Ordering};
444
445 static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
446
447 struct TempDir {
448 path: PathBuf,
449 }
450
451 impl TempDir {
452 fn new() -> Self {
453 let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
454 let mut p = std::env::temp_dir();
455 p.push(format!("santui_cfg_test_{id}"));
456 let _ = std::fs::remove_dir_all(&p);
457 std::fs::create_dir_all(&p).unwrap();
458 TempDir { path: p }
459 }
460
461 fn path(&self) -> &std::path::Path {
462 &self.path
463 }
464 }
465
466 impl Drop for TempDir {
467 fn drop(&mut self) {
468 let _ = std::fs::remove_dir_all(&self.path);
469 }
470 }
471
472 #[test]
473 fn config_default_all_none() {
474 let cfg = Config::default();
475 assert!(cfg.theme.is_none());
476 assert!(cfg.custom_theme.is_none());
477 assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
478 assert_eq!(cfg.keybindings.quit, "q");
479 assert_eq!(cfg.keybindings.about, "?");
480 assert!(cfg.plugins.is_none());
481 assert!(cfg.server.is_none());
482 }
483
484 #[test]
485 fn try_load_from_missing_dir_returns_err() {
486 let tmp = TempDir::new();
487 let result = Config::try_load_from(tmp.path());
488 assert!(result.is_err());
489 assert!(result.unwrap_err().contains("not found"));
490 }
491
492 #[test]
493 fn try_load_from_invalid_toml_returns_err() {
494 let tmp = TempDir::new();
495 std::fs::write(tmp.path().join("config.toml"), "not toml {{{").unwrap();
496 let result = Config::try_load_from(tmp.path());
497 assert!(result.is_err());
498 assert!(result.unwrap_err().contains("parse"));
499 }
500
501 #[test]
502 fn load_from_missing_dir_returns_default() {
503 let tmp = TempDir::new();
504 let cfg = Config::load_from(tmp.path());
505 assert!(cfg.theme.is_none());
506 }
507
508 #[test]
509 fn save_to_and_load_from_roundtrip() {
510 let tmp = TempDir::new();
511 let cfg = Config {
512 mouse_capture: false,
513 theme: Some("Nord".into()),
514 custom_theme: Some(CustomThemeColors {
515 name: None,
516 accent: Some("#ff8800".into()),
517 highlight: None,
518 logo: None,
519 text: None,
520 text_muted: None,
521 background: None,
522 background_panel: None,
523 background_overlay: None,
524 border: None,
525 success: None,
526 error: None,
527 inverted_text: None,
528 }),
529 keybindings: KeyBindings::default(),
530 plugins: None,
531 server: None,
532 };
533 cfg.save_to(tmp.path()).unwrap();
534 let loaded = Config::load_from(tmp.path());
535 assert_eq!(loaded.theme.as_deref(), Some("Nord"));
536 }
537
538 #[test]
539 fn config_manager_new_sets_last_modified() {
540 let tmp = TempDir::new();
541 let p = tmp.path().join("config.toml");
542 std::fs::write(&p, r#"theme = "Nord""#).unwrap();
543 let mgr = ConfigManager::new(tmp.path().to_path_buf());
544 assert!(mgr.last_modified.is_some());
545 assert!(!mgr.dirty);
546 }
547
548 #[test]
549 fn config_manager_error_on_missing_file() {
550 let tmp = TempDir::new();
551 let mgr = ConfigManager::new(tmp.path().to_path_buf());
552 assert!(mgr.error().is_some());
553 }
554
555 #[test]
556 fn config_manager_error_cleared_on_successful_load() {
557 let tmp = TempDir::new();
558 let p = tmp.path().join("config.toml");
559 std::fs::write(&p, r#"theme = "Nord""#).unwrap();
560 let mgr = ConfigManager::new(tmp.path().to_path_buf());
561 assert!(mgr.error().is_none());
562 }
563
564 #[test]
565 fn config_manager_clear_noop_when_no_custom() {
566 let tmp = TempDir::new();
567 let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
568 mgr.clear_custom_theme();
569 assert!(mgr.config().custom_theme.is_none());
570 }
571
572 #[test]
573 fn config_manager_tick_rate_default_and_set() {
574 let tmp = TempDir::new();
575 let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
576 assert_eq!(mgr.tick_rate(), Duration::from_millis(100));
577 mgr.set_tick_rate(Duration::from_millis(200));
578 assert_eq!(mgr.tick_rate(), Duration::from_millis(200));
579 }
580
581 #[test]
582 fn config_manager_poll_throttle_skips() {
583 let tmp = TempDir::new();
584 let p = tmp.path().join("config.toml");
585 std::fs::write(&p, r#"theme = "Nord""#).unwrap();
586 let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
587 mgr.poll_skip = 5;
588 mgr.ack();
589 std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
590 mgr.poll();
591 assert!(!mgr.dirty, "should skip due to poll_skip");
592 }
593
594 #[test]
595 fn config_manager_poll_no_file_returns_early() {
596 let tmp = TempDir::new();
597 let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
598 mgr.poll_skip = 0;
599 mgr.poll();
601 assert!(!mgr.dirty);
602 }
603
604 #[test]
605 fn config_manager_watcher_detects_external_change() {
606 let tmp = TempDir::new();
607 let p = tmp.path().join("config.toml");
608 std::fs::write(&p, r#"theme = "Nord""#).unwrap();
609 let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
610 mgr.ack();
611 assert!(!mgr.dirty);
612
613 std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
616 mgr.poll();
617 assert!(mgr.dirty, "watcher should detect external change");
618 assert_eq!(mgr.config().theme.as_deref(), Some("Dracula"));
619 }
620
621 #[test]
622 fn config_manager_watcher_ignores_own_write() {
623 let tmp = TempDir::new();
624 let p = tmp.path().join("config.toml");
625 std::fs::write(&p, r#"theme = "Nord""#).unwrap();
626 let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
627 mgr.ack();
628 assert!(!mgr.dirty);
629
630 mgr.save_theme("Dracula");
633 assert!(!mgr.dirty, "own write should not set dirty");
634
635 let loaded = Config::load_from(tmp.path());
637 assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
638 }
639
640 #[test]
645 fn keybindings_default_values() {
646 let kb = KeyBindings::default();
647 assert_eq!(kb.open_palette, "ctrl+p");
648 assert_eq!(kb.quit, "q");
649 assert_eq!(kb.about, "?");
650 }
651
652 #[test]
653 fn parse_key_ctrl_p() {
654 let (code, mods) = KeyBindings::parse_key("ctrl+p").unwrap();
655 assert_eq!(code, KeyCode::Char('p'));
656 assert!(mods.contains(KeyModifiers::CONTROL));
657 }
658
659 #[test]
660 fn parse_key_plain_char() {
661 let (code, mods) = KeyBindings::parse_key("q").unwrap();
662 assert_eq!(code, KeyCode::Char('q'));
663 assert!(mods.is_empty());
664 }
665
666 #[test]
667 fn parse_key_question_mark() {
668 let (code, mods) = KeyBindings::parse_key("?").unwrap();
669 assert_eq!(code, KeyCode::Char('?'));
670 assert!(mods.is_empty());
671 }
672
673 #[test]
674 fn parse_key_ctrl_space() {
675 let (code, mods) = KeyBindings::parse_key("ctrl+space").unwrap();
676 assert_eq!(code, KeyCode::Char(' '));
677 assert!(mods.contains(KeyModifiers::CONTROL));
678 }
679
680 #[test]
681 fn parse_key_invalid_returns_none() {
682 assert!(KeyBindings::parse_key("xyz+abc").is_none());
683 }
684
685 #[test]
686 fn keybindings_deserialize_from_toml() {
687 let toml_str = r#"
688 [keybindings]
689 open_palette = "alt+p"
690 quit = "ctrl+q"
691 about = "shift+/"
692 "#;
693 let cfg: Config = toml::from_str(toml_str).unwrap();
694 assert_eq!(cfg.keybindings.open_palette, "alt+p");
695 assert_eq!(cfg.keybindings.quit, "ctrl+q");
696 assert_eq!(cfg.keybindings.about, "shift+/");
697 }
698
699 #[test]
700 fn keybindings_missing_from_toml_uses_defaults() {
701 let toml_str = r#"
702 theme = "Nord"
703 "#;
704 let cfg: Config = toml::from_str(toml_str).unwrap();
705 assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
706 assert_eq!(cfg.keybindings.quit, "q");
707 assert_eq!(cfg.keybindings.about, "?");
708 }
709}