ricecoder_storage/markdown_config/
watcher.rs

1//! File watcher for hot-reload of configuration files
2//!
3//! This module provides the [`FileWatcher`] which monitors configuration directories
4//! for file changes and automatically reloads configurations when files are modified.
5//!
6//! # Hot-Reload Behavior
7//!
8//! When a configuration file is modified:
9//!
10//! 1. File change is detected (within 5 seconds)
11//! 2. Configuration is re-parsed and validated
12//! 3. If valid, configuration is updated in the registry
13//! 4. If invalid, error is logged and previous configuration is retained
14//!
15//! # Debouncing
16//!
17//! Rapid file changes are debounced to avoid excessive reloads. The default
18//! debounce delay is 500ms, which can be customized with [`FileWatcher::with_debounce`].
19//!
20//! # Usage
21//!
22//! ```ignore
23//! use ricecoder_storage::markdown_config::{ConfigurationLoader, ConfigRegistry, FileWatcher};
24//! use std::sync::Arc;
25//! use std::path::PathBuf;
26//!
27//! #[tokio::main]
28//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
29//!     let registry = Arc::new(ConfigRegistry::new());
30//!     let loader = Arc::new(ConfigurationLoader::new(registry.clone()));
31//!
32//!     let paths = vec![
33//!         PathBuf::from("~/.ricecoder/agents"),
34//!         PathBuf::from("projects/ricecoder/.agent"),
35//!     ];
36//!
37//!     let mut watcher = FileWatcher::new(loader, paths);
38//!
39//!     // Start watching for changes
40//!     watcher.watch().await?;
41//!
42//!     Ok(())
43//! }
44//! ```
45
46use crate::markdown_config::error::{MarkdownConfigError, MarkdownConfigResult};
47use crate::markdown_config::loader::ConfigurationLoader;
48use notify::{RecursiveMode, Watcher};
49use std::path::PathBuf;
50use std::sync::mpsc;
51use std::sync::Arc;
52use std::time::Duration;
53use tokio::sync::RwLock;
54use tracing::{debug, error, warn};
55
56/// File watcher for monitoring configuration file changes
57///
58/// Monitors configuration directories for file modifications and triggers
59/// configuration reloads when changes are detected. Includes debouncing to
60/// avoid excessive reloads during rapid file changes.
61pub struct FileWatcher {
62    loader: Arc<ConfigurationLoader>,
63    paths: Vec<PathBuf>,
64    debounce_ms: u64,
65    is_watching: Arc<RwLock<bool>>,
66}
67
68impl FileWatcher {
69    /// Create a new file watcher
70    ///
71    /// # Arguments
72    /// * `loader` - The configuration loader to use for reloading
73    /// * `paths` - Directories to monitor for changes
74    /// * `debounce_ms` - Debounce delay in milliseconds (default: 500ms)
75    pub fn new(loader: Arc<ConfigurationLoader>, paths: Vec<PathBuf>) -> Self {
76        Self {
77            loader,
78            paths,
79            debounce_ms: 500,
80            is_watching: Arc::new(RwLock::new(false)),
81        }
82    }
83
84    /// Create a new file watcher with custom debounce delay
85    pub fn with_debounce(
86        loader: Arc<ConfigurationLoader>,
87        paths: Vec<PathBuf>,
88        debounce_ms: u64,
89    ) -> Self {
90        Self {
91            loader,
92            paths,
93            debounce_ms,
94            is_watching: Arc::new(RwLock::new(false)),
95        }
96    }
97
98    /// Start watching configuration directories for changes
99    ///
100    /// This method runs indefinitely, monitoring for file changes and
101    /// triggering reloads when detected. It should be run in a separate task.
102    pub async fn watch(&self) -> MarkdownConfigResult<()> {
103        // Create a channel for file system events
104        let (tx, rx) = mpsc::channel();
105
106        // Create a watcher using the recommended API
107        let mut watcher = notify::recommended_watcher(move |res| {
108            match res {
109                Ok(event) => {
110                    if let Err(e) = tx.send(event) {
111                        error!("Failed to send file watch event: {}", e);
112                    }
113                }
114                Err(e) => {
115                    error!("File watcher error: {}", e);
116                }
117            }
118        })
119        .map_err(|e| {
120            MarkdownConfigError::watch_error(format!("Failed to create file watcher: {}", e))
121        })?;
122
123        // Watch all configured paths
124        for path in &self.paths {
125            if path.exists() {
126                watcher
127                    .watch(path, RecursiveMode::Recursive)
128                    .map_err(|e| {
129                        MarkdownConfigError::watch_error(format!(
130                            "Failed to watch path {}: {}",
131                            path.display(),
132                            e
133                        ))
134                    })?;
135                debug!("Watching configuration directory: {}", path.display());
136            }
137        }
138
139        // Mark as watching
140        *self.is_watching.write().await = true;
141
142        // Process file system events
143        let mut last_reload = std::time::Instant::now();
144
145        loop {
146            match rx.recv_timeout(Duration::from_millis(100)) {
147                Ok(event) => {
148                    // Check if this is a modification event for a markdown config file
149                    if self.is_config_file_event(&event) {
150                        let now = std::time::Instant::now();
151                        let elapsed = now.duration_since(last_reload);
152
153                        // Debounce: only reload if enough time has passed
154                        if elapsed.as_millis() as u64 >= self.debounce_ms {
155                            debug!("Configuration file changed, reloading...");
156                            self.reload_configurations().await;
157                            last_reload = now;
158                        } else {
159                            debug!(
160                                "Debouncing configuration reload ({}ms remaining)",
161                                self.debounce_ms - elapsed.as_millis() as u64
162                            );
163                        }
164                    }
165                }
166                Err(mpsc::RecvTimeoutError::Timeout) => {
167                    // Timeout is normal, just continue
168                    continue;
169                }
170                Err(mpsc::RecvTimeoutError::Disconnected) => {
171                    // Channel disconnected, stop watching
172                    break;
173                }
174            }
175        }
176
177        *self.is_watching.write().await = false;
178        Ok(())
179    }
180
181    /// Check if an event is for a configuration file
182    fn is_config_file_event(&self, event: &notify::Event) -> bool {
183        use notify::EventKind;
184
185        // Only care about write/create events
186        match event.kind {
187            EventKind::Modify(_) | EventKind::Create(_) => {}
188            _ => return false,
189        }
190
191        // Check if any path is a configuration file
192        event.paths.iter().any(|path| {
193            if let Some(file_name) = path.file_name() {
194                if let Some(name_str) = file_name.to_str() {
195                    return name_str.ends_with(".agent.md")
196                        || name_str.ends_with(".mode.md")
197                        || name_str.ends_with(".command.md");
198                }
199            }
200            false
201        })
202    }
203
204    /// Reload all configurations from watched directories
205    async fn reload_configurations(&self) {
206        match self.loader.load_all(&self.paths).await {
207            Ok((success, errors, error_list)) => {
208                debug!(
209                    "Configuration reload complete: {} successful, {} failed",
210                    success, errors
211                );
212
213                if !error_list.is_empty() {
214                    for (path, error) in error_list {
215                        warn!(
216                            "Failed to load configuration from {}: {}",
217                            path.display(),
218                            error
219                        );
220                    }
221                }
222            }
223            Err(e) => {
224                error!("Failed to reload configurations: {}", e);
225            }
226        }
227    }
228
229    /// Check if watcher is currently watching
230    pub async fn is_watching(&self) -> bool {
231        *self.is_watching.read().await
232    }
233
234    /// Stop watching (by dropping the watcher)
235    pub async fn stop(&self) {
236        *self.is_watching.write().await = false;
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::markdown_config::registry::ConfigRegistry;
244
245    #[test]
246    fn test_file_watcher_creation() {
247        let registry = Arc::new(ConfigRegistry::new());
248        let loader = Arc::new(ConfigurationLoader::new(registry));
249        let paths = vec![PathBuf::from("/tmp")];
250
251        let watcher = FileWatcher::new(loader, paths.clone());
252        assert_eq!(watcher.paths, paths);
253        assert_eq!(watcher.debounce_ms, 500);
254    }
255
256    #[test]
257    fn test_file_watcher_custom_debounce() {
258        let registry = Arc::new(ConfigRegistry::new());
259        let loader = Arc::new(ConfigurationLoader::new(registry));
260        let paths = vec![PathBuf::from("/tmp")];
261
262        let watcher = FileWatcher::with_debounce(loader, paths, 1000);
263        assert_eq!(watcher.debounce_ms, 1000);
264    }
265
266    #[test]
267    fn test_is_config_file_event() {
268        let registry = Arc::new(ConfigRegistry::new());
269        let loader = Arc::new(ConfigurationLoader::new(registry));
270        let paths = vec![PathBuf::from("/tmp")];
271        let watcher = FileWatcher::new(loader, paths);
272
273        // Test agent file
274        let event = notify::Event {
275            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
276                notify::event::DataChange::Content,
277            )),
278            paths: vec![PathBuf::from("/tmp/test.agent.md")],
279            attrs: Default::default(),
280        };
281        assert!(watcher.is_config_file_event(&event));
282
283        // Test mode file
284        let event = notify::Event {
285            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
286                notify::event::DataChange::Content,
287            )),
288            paths: vec![PathBuf::from("/tmp/test.mode.md")],
289            attrs: Default::default(),
290        };
291        assert!(watcher.is_config_file_event(&event));
292
293        // Test command file
294        let event = notify::Event {
295            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
296                notify::event::DataChange::Content,
297            )),
298            paths: vec![PathBuf::from("/tmp/test.command.md")],
299            attrs: Default::default(),
300        };
301        assert!(watcher.is_config_file_event(&event));
302
303        // Test non-config file
304        let event = notify::Event {
305            kind: notify::EventKind::Modify(notify::event::ModifyKind::Data(
306                notify::event::DataChange::Content,
307            )),
308            paths: vec![PathBuf::from("/tmp/test.md")],
309            attrs: Default::default(),
310        };
311        assert!(!watcher.is_config_file_event(&event));
312
313        // Test non-modify event
314        let event = notify::Event {
315            kind: notify::EventKind::Access(notify::event::AccessKind::Read),
316            paths: vec![PathBuf::from("/tmp/test.agent.md")],
317            attrs: Default::default(),
318        };
319        assert!(!watcher.is_config_file_event(&event));
320    }
321
322    #[tokio::test]
323    async fn test_watcher_is_watching() {
324        let registry = Arc::new(ConfigRegistry::new());
325        let loader = Arc::new(ConfigurationLoader::new(registry));
326        let paths = vec![PathBuf::from("/tmp")];
327
328        let watcher = FileWatcher::new(loader, paths);
329        assert!(!watcher.is_watching().await);
330    }
331}