Skip to main content

ryo_server/
watcher.rs

1//! File system watcher for automatic reloading
2//!
3//! Watches project files for changes and notifies the server to reload.
4
5use notify::RecursiveMode;
6use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::mpsc;
10use std::time::Duration;
11use tokio::sync::mpsc as tokio_mpsc;
12
13/// Events from the file watcher
14#[derive(Debug, Clone)]
15pub enum WatchEvent {
16    /// Files were modified (debounced list of paths)
17    FilesChanged(Vec<PathBuf>),
18    /// Watcher encountered an error
19    Error(String),
20}
21
22/// Configuration for the file watcher
23#[derive(Debug, Clone)]
24pub struct WatcherConfig {
25    /// Debounce duration (time to wait for more changes before processing)
26    pub debounce: Duration,
27    /// File extensions to watch (e.g., ["rs"])
28    pub extensions: Vec<String>,
29    /// Directories to ignore
30    pub ignore_dirs: Vec<String>,
31}
32
33impl Default for WatcherConfig {
34    fn default() -> Self {
35        Self {
36            debounce: Duration::from_millis(500),
37            extensions: vec!["rs".to_string()],
38            ignore_dirs: vec![
39                "target".to_string(),
40                ".git".to_string(),
41                "node_modules".to_string(),
42            ],
43        }
44    }
45}
46
47/// File system watcher that monitors project files for changes
48pub struct FileWatcher {
49    /// Channel to receive watch events
50    event_rx: tokio_mpsc::Receiver<WatchEvent>,
51    /// Handle to stop the watcher (dropped when watcher stops)
52    _handle: std::thread::JoinHandle<()>,
53}
54
55impl FileWatcher {
56    /// Create a new file watcher for the given directory
57    pub fn new(watch_path: &Path, config: WatcherConfig) -> anyhow::Result<Self> {
58        let (tx, rx) = tokio_mpsc::channel(100);
59        let watch_path = watch_path.to_path_buf();
60        let config_clone = config.clone();
61
62        // Spawn watcher in a separate thread (notify uses sync APIs)
63        let handle = std::thread::spawn(move || {
64            if let Err(e) = run_watcher(watch_path, config_clone, tx) {
65                tracing::error!("Watcher thread error: {}", e);
66            }
67        });
68
69        Ok(Self {
70            event_rx: rx,
71            _handle: handle,
72        })
73    }
74
75    /// Receive the next watch event (async)
76    pub async fn recv(&mut self) -> Option<WatchEvent> {
77        self.event_rx.recv().await
78    }
79}
80
81/// Run the file watcher (blocking, runs in separate thread)
82fn run_watcher(
83    watch_path: PathBuf,
84    config: WatcherConfig,
85    tx: tokio_mpsc::Sender<WatchEvent>,
86) -> anyhow::Result<()> {
87    let (notify_tx, notify_rx) = mpsc::channel();
88
89    // Create debounced watcher
90    let mut debouncer = new_debouncer(config.debounce, notify_tx)?;
91
92    // Start watching
93    debouncer
94        .watcher()
95        .watch(&watch_path, RecursiveMode::Recursive)?;
96
97    tracing::info!("File watcher started for {:?}", watch_path);
98
99    // Process events
100    loop {
101        match notify_rx.recv() {
102            Ok(Ok(events)) => {
103                // Collect changed paths, filtering by extension and ignoring directories
104                let mut changed: HashSet<PathBuf> = HashSet::new();
105
106                for event in events {
107                    if event.kind != DebouncedEventKind::Any {
108                        continue;
109                    }
110
111                    let path = &event.path;
112
113                    // Check if path should be ignored
114                    if should_ignore(path, &config.ignore_dirs) {
115                        continue;
116                    }
117
118                    // Check extension filter
119                    if !config.extensions.is_empty() {
120                        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
121                        if !config.extensions.iter().any(|e| e == ext) {
122                            continue;
123                        }
124                    }
125
126                    // Only include existing files (ignore deletions for now)
127                    if path.exists() && path.is_file() {
128                        changed.insert(path.clone());
129                    }
130                }
131
132                if !changed.is_empty() {
133                    let paths: Vec<_> = changed.into_iter().collect();
134                    tracing::debug!("Files changed: {:?}", paths);
135
136                    if tx.blocking_send(WatchEvent::FilesChanged(paths)).is_err() {
137                        // Channel closed, stop watcher
138                        break;
139                    }
140                }
141            }
142            Ok(Err(error)) => {
143                tracing::warn!("Watch error: {:?}", error);
144                let _ = tx.blocking_send(WatchEvent::Error(format!("{:?}", error)));
145            }
146            Err(_) => {
147                // Channel closed
148                break;
149            }
150        }
151    }
152
153    tracing::info!("File watcher stopped");
154    Ok(())
155}
156
157/// Check if a path should be ignored
158fn should_ignore(path: &Path, ignore_dirs: &[String]) -> bool {
159    for component in path.components() {
160        if let std::path::Component::Normal(name) = component {
161            let name_str = name.to_string_lossy();
162            if ignore_dirs.iter().any(|d| d == name_str.as_ref()) {
163                return true;
164            }
165        }
166    }
167    false
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_should_ignore() {
176        let ignore = vec!["target".to_string(), ".git".to_string()];
177
178        assert!(should_ignore(Path::new("target/debug/foo.rs"), &ignore));
179        assert!(should_ignore(Path::new(".git/config"), &ignore));
180        assert!(should_ignore(Path::new("src/target/mod.rs"), &ignore));
181        assert!(!should_ignore(Path::new("src/main.rs"), &ignore));
182        assert!(!should_ignore(Path::new("crates/foo/src/lib.rs"), &ignore));
183    }
184
185    #[test]
186    fn test_config_default() {
187        let config = WatcherConfig::default();
188        assert_eq!(config.debounce, Duration::from_millis(500));
189        assert!(config.extensions.contains(&"rs".to_string()));
190        assert!(config.ignore_dirs.contains(&"target".to_string()));
191    }
192}