gestura_core_config/
watcher.rs1use crate::types::AppConfig;
6use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
7use std::path::PathBuf;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use tokio::sync::{Mutex, mpsc};
11
12#[derive(Debug, Clone)]
14pub enum ConfigChangeEvent {
15 Updated(Box<AppConfig>),
17 Error(String),
19 Deleted,
21}
22
23pub struct ConfigWatcher {
25 _watcher: RecommendedWatcher,
26 config_path: PathBuf,
27}
28
29struct DebounceState {
30 last_event: Option<Instant>,
31}
32
33impl ConfigWatcher {
34 pub fn new() -> Result<(Self, mpsc::Receiver<ConfigChangeEvent>), String> {
36 Self::with_path(AppConfig::default_path())
37 }
38
39 pub fn with_path(
41 config_path: PathBuf,
42 ) -> Result<(Self, mpsc::Receiver<ConfigChangeEvent>), String> {
43 let (tx, rx) = mpsc::channel(32);
44 let debounce = Arc::new(Mutex::new(DebounceState { last_event: None }));
45
46 let config_path_clone = config_path.clone();
47 let debounce_clone = debounce.clone();
48 let tx_clone = tx.clone();
49
50 let watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
51 let tx = tx_clone.clone();
52 let config_path = config_path_clone.clone();
53 let debounce = debounce_clone.clone();
54 tokio::spawn(async move {
55 Self::handle_event(res, &config_path, &tx, &debounce).await;
56 });
57 })
58 .map_err(|e| format!("Failed to create file watcher: {}", e))?;
59
60 let watch_path = config_path
61 .parent()
62 .map(|p| p.to_path_buf())
63 .unwrap_or_else(|| config_path.clone());
64
65 let mut watcher = watcher;
66 watcher
67 .watch(&watch_path, RecursiveMode::NonRecursive)
68 .map_err(|e| format!("Failed to watch config directory: {}", e))?;
69
70 tracing::info!("Started watching config file: {:?}", config_path);
71 Ok((
72 Self {
73 _watcher: watcher,
74 config_path,
75 },
76 rx,
77 ))
78 }
79
80 async fn handle_event(
81 res: Result<Event, notify::Error>,
82 config_path: &PathBuf,
83 tx: &mpsc::Sender<ConfigChangeEvent>,
84 debounce: &Arc<Mutex<DebounceState>>,
85 ) {
86 match res {
87 Ok(event) => {
88 if !event.paths.iter().any(|p| p == config_path) {
89 return;
90 }
91 match event.kind {
92 EventKind::Create(_) | EventKind::Modify(_) => {
93 let should_reload = {
94 let mut state = debounce.lock().await;
95 let now = Instant::now();
96 if let Some(last) = state.last_event
97 && now.duration_since(last) < Duration::from_millis(100)
98 {
99 return;
100 }
101 state.last_event = Some(now);
102 true
103 };
104 if should_reload {
105 tokio::time::sleep(Duration::from_millis(50)).await;
106 Self::reload_and_emit(config_path, tx).await;
107 }
108 }
109 EventKind::Remove(_) => {
110 tracing::warn!("Config file was deleted: {:?}", config_path);
111 let _ = tx.send(ConfigChangeEvent::Deleted).await;
112 }
113 _ => {}
114 }
115 }
116 Err(e) => {
117 tracing::error!("File watch error: {}", e);
118 let _ = tx
119 .send(ConfigChangeEvent::Error(format!("Watch error: {}", e)))
120 .await;
121 }
122 }
123 }
124
125 async fn reload_and_emit(config_path: &PathBuf, tx: &mpsc::Sender<ConfigChangeEvent>) {
126 match std::fs::read_to_string(config_path) {
127 Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
128 Ok(config) => {
129 let config = config.apply_env_overrides();
130 tracing::info!("Configuration reloaded successfully");
131 let _ = tx.send(ConfigChangeEvent::Updated(Box::new(config))).await;
132 }
133 Err(e) => {
134 tracing::error!("Failed to parse config file: {}", e);
135 let _ = tx
136 .send(ConfigChangeEvent::Error(format!("Parse error: {}", e)))
137 .await;
138 }
139 },
140 Err(e) => {
141 tracing::error!("Failed to read config file: {}", e);
142 let _ = tx
143 .send(ConfigChangeEvent::Error(format!("Read error: {}", e)))
144 .await;
145 }
146 }
147 }
148
149 pub fn config_path(&self) -> &PathBuf {
151 &self.config_path
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct HotReloadableSettings {
158 pub theme_mode: String,
159 pub accent: Option<String>,
160 pub sound_enabled: bool,
161 pub haptic_enabled: bool,
162 pub sound_volume: u8,
163 pub haptic_intensity: u8,
164 pub developer_mode: bool,
165 pub verbose_ble_logging: bool,
166}
167
168impl From<&AppConfig> for HotReloadableSettings {
169 fn from(config: &AppConfig) -> Self {
170 Self {
171 theme_mode: config.ui.theme_mode.clone(),
172 accent: config.ui.accent.clone(),
173 sound_enabled: config.notifications.sound_enabled,
174 haptic_enabled: config.notifications.haptic_enabled,
175 sound_volume: config.notifications.sound_volume,
176 haptic_intensity: config.notifications.haptic_intensity,
177 developer_mode: config.developer.developer_mode,
178 verbose_ble_logging: config.developer.verbose_ble_logging,
179 }
180 }
181}
182
183impl HotReloadableSettings {
184 pub fn differs_from(&self, other: &AppConfig) -> bool {
186 *self != HotReloadableSettings::from(other)
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use tempfile::TempDir;
194
195 #[test]
196 fn test_hot_reloadable_settings_from_config() {
197 let config = AppConfig::default();
198 let settings = HotReloadableSettings::from(&config);
199 assert_eq!(settings.theme_mode, config.ui.theme_mode);
200 assert_eq!(settings.sound_enabled, config.notifications.sound_enabled);
201 }
202
203 #[test]
204 fn test_hot_reloadable_settings_differs() {
205 let config = AppConfig::default();
206 let settings = HotReloadableSettings::from(&config);
207 assert!(!settings.differs_from(&config));
208
209 let mut modified = config.clone();
210 modified.ui.theme_mode = "dark".to_string();
211 assert!(settings.differs_from(&modified));
212 }
213
214 #[tokio::test]
215 async fn test_config_watcher_creation() {
216 let temp_dir = TempDir::new().unwrap();
217 let config_path = temp_dir.path().join("config.yaml");
218 let config = AppConfig::default();
219 std::fs::write(&config_path, serde_yaml::to_string(&config).unwrap()).unwrap();
220
221 let result = ConfigWatcher::with_path(config_path.clone());
222 assert!(result.is_ok());
223 let (watcher, _rx) = result.unwrap();
224 assert_eq!(watcher.config_path(), &config_path);
225 }
226
227 #[test]
228 fn test_config_change_event_variants() {
229 let config = AppConfig::default();
230 let _updated = ConfigChangeEvent::Updated(Box::new(config));
231 let _error = ConfigChangeEvent::Error("test error".to_string());
232 let _deleted = ConfigChangeEvent::Deleted;
233 }
234}