gestura_core_config/
watcher.rs

1//! Configuration file watcher for runtime configuration reloading
2//!
3//! Provides file watching capabilities for hot-reload of non-critical settings.
4
5use 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/// Events emitted when configuration changes
13#[derive(Debug, Clone)]
14pub enum ConfigChangeEvent {
15    /// Configuration was successfully updated
16    Updated(Box<AppConfig>),
17    /// Error occurred while watching or loading configuration
18    Error(String),
19    /// Configuration file was deleted
20    Deleted,
21}
22
23/// Configuration file watcher
24pub struct ConfigWatcher {
25    _watcher: RecommendedWatcher,
26    config_path: PathBuf,
27}
28
29struct DebounceState {
30    last_event: Option<Instant>,
31}
32
33impl ConfigWatcher {
34    /// Create a new configuration watcher
35    pub fn new() -> Result<(Self, mpsc::Receiver<ConfigChangeEvent>), String> {
36        Self::with_path(AppConfig::default_path())
37    }
38
39    /// Create a configuration watcher for a specific path
40    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    /// Get the path being watched
150    pub fn config_path(&self) -> &PathBuf {
151        &self.config_path
152    }
153}
154
155/// Settings that can be hot-reloaded without restart
156#[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    /// Check if settings differ from another config
185    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}