1use std::path::PathBuf;
2use std::time::{Duration, SystemTime};
3
4#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
6pub struct Config {
7 pub theme: Option<String>,
9 pub custom_theme: Option<CustomThemeColors>,
11 #[serde(default)]
13 pub keybindings: Option<KeyBindings>,
14 #[serde(default)]
16 pub plugins: Option<PluginConfig>,
17}
18
19#[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#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
41pub struct KeyBindings {}
42
43#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
45pub struct PluginConfig {}
46
47impl Config {
48 pub fn load_from(dir: &std::path::Path) -> Self {
51 Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
52 }
53
54 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 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#[derive(Debug)]
81pub struct ConfigManager {
82 dir: PathBuf,
83 config: Config,
84 last_modified: Option<SystemTime>,
85 pub dirty: bool,
87 error: Option<String>,
89 tick_rate: Duration,
91 poll_skip: u32,
93}
94
95impl ConfigManager {
96 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 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 pub fn ack(&mut self) {
152 self.dirty = false;
153 }
154
155 pub fn config(&self) -> &Config {
156 &self.config
157 }
158
159 pub fn error(&self) -> Option<&str> {
161 self.error.as_deref()
162 }
163
164 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 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 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 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 mgr.poll();
367 assert!(!mgr.dirty);
368 }
369}