Skip to main content

testx/watcher/
file_watcher.rs

1use std::path::{Path, PathBuf};
2use std::sync::mpsc::{self, Receiver, TryRecvError};
3use std::time::Duration;
4
5use crate::config::WatchConfig;
6use crate::watcher::debouncer::Debouncer;
7use crate::watcher::glob::{GlobPattern, should_ignore};
8
9/// File system watcher that detects changes in a project directory.
10pub struct FileWatcher {
11    /// Receiver for file change events from the watcher thread.
12    rx: Receiver<PathBuf>,
13    /// Debouncer to coalesce rapid changes.
14    debouncer: Debouncer,
15    /// Glob patterns for paths to ignore.
16    ignore_patterns: Vec<GlobPattern>,
17    /// Root directory being watched.
18    root: PathBuf,
19    /// Handle to the watcher thread.
20    _watcher_handle: std::thread::JoinHandle<()>,
21}
22
23impl FileWatcher {
24    /// Create a new file watcher for the given directory.
25    pub fn new(root: &Path, config: &WatchConfig) -> std::io::Result<Self> {
26        let ignore_patterns: Vec<GlobPattern> =
27            config.ignore.iter().map(|p| GlobPattern::new(p)).collect();
28
29        let debouncer = Debouncer::new(config.debounce_ms);
30        let (tx, rx) = mpsc::channel();
31
32        let poll_interval = config
33            .poll_ms
34            .map(Duration::from_millis)
35            .unwrap_or(Duration::from_millis(500));
36
37        let watch_root = root.to_path_buf();
38
39        // Spawn a polling watcher thread
40        let watcher_handle = std::thread::spawn(move || {
41            let mut known_files = collect_files(&watch_root);
42            let mut known_mtimes = get_mtimes(&known_files);
43
44            loop {
45                std::thread::sleep(poll_interval);
46
47                let current_files = collect_files(&watch_root);
48                let current_mtimes = get_mtimes(&current_files);
49
50                // Find new or modified files
51                for (path, mtime) in &current_mtimes {
52                    let changed = match known_mtimes.get(path) {
53                        Some(old_mtime) => mtime != old_mtime,
54                        None => true, // new file
55                    };
56
57                    if changed && tx.send(path.clone()).is_err() {
58                        return; // receiver dropped, stop watching
59                    }
60                }
61
62                // Find deleted files (send parent dir)
63                for path in &known_files {
64                    if !current_files.contains(path)
65                        && let Some(parent) = path.parent()
66                        && tx.send(parent.to_path_buf()).is_err()
67                    {
68                        return;
69                    }
70                }
71
72                known_files = current_files;
73                known_mtimes = current_mtimes;
74            }
75        });
76
77        Ok(Self {
78            rx,
79            debouncer,
80            ignore_patterns,
81            root: root.to_path_buf(),
82            _watcher_handle: watcher_handle,
83        })
84    }
85
86    /// Wait for file changes and return the changed paths (debounced).
87    /// This blocks until changes are detected.
88    pub fn wait_for_changes(&mut self) -> Vec<PathBuf> {
89        loop {
90            // Drain pending events
91            loop {
92                match self.rx.try_recv() {
93                    Ok(path) => {
94                        if !self.should_ignore(&path) {
95                            self.debouncer.add(path);
96                        }
97                    }
98                    Err(TryRecvError::Empty) => break,
99                    Err(TryRecvError::Disconnected) => {
100                        // Watcher thread died, return pending
101                        if self.debouncer.has_pending() {
102                            return self.debouncer.flush();
103                        }
104                        return Vec::new();
105                    }
106                }
107            }
108
109            // Check if we should flush
110            if self.debouncer.should_flush() {
111                return self.debouncer.flush();
112            }
113
114            // Wait a bit for more events
115            if self.debouncer.has_pending() {
116                let remaining = self.debouncer.time_remaining();
117                if remaining > Duration::ZERO {
118                    std::thread::sleep(remaining.min(Duration::from_millis(50)));
119                    continue;
120                }
121                return self.debouncer.flush();
122            }
123
124            // Block waiting for next event
125            match self.rx.recv_timeout(Duration::from_millis(200)) {
126                Ok(path) => {
127                    if !self.should_ignore(&path) {
128                        self.debouncer.add(path);
129                    }
130                }
131                Err(mpsc::RecvTimeoutError::Timeout) => continue,
132                Err(mpsc::RecvTimeoutError::Disconnected) => return Vec::new(),
133            }
134        }
135    }
136
137    /// Check if a path should be ignored.
138    fn should_ignore(&self, path: &Path) -> bool {
139        let relative = path
140            .strip_prefix(&self.root)
141            .unwrap_or(path)
142            .to_string_lossy();
143
144        should_ignore(&relative, &self.ignore_patterns)
145    }
146
147    /// Get the root directory being watched.
148    pub fn root(&self) -> &Path {
149        &self.root
150    }
151}
152
153/// Recursively collect all files in a directory.
154fn collect_files(dir: &Path) -> Vec<PathBuf> {
155    let mut files = Vec::new();
156    collect_files_recursive(dir, &mut files);
157    files
158}
159
160fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
161    let entries = match std::fs::read_dir(dir) {
162        Ok(entries) => entries,
163        Err(_) => return,
164    };
165
166    for entry in entries.flatten() {
167        let path = entry.path();
168
169        // Skip hidden directories and common ignores for performance
170        if let Some(name) = path.file_name().and_then(|n| n.to_str())
171            && (name.starts_with('.')
172                || name == "node_modules"
173                || name == "target"
174                || name == "__pycache__"
175                || name == ".testx")
176        {
177            continue;
178        }
179
180        if path.is_dir() {
181            collect_files_recursive(&path, files);
182        } else {
183            files.push(path);
184        }
185    }
186}
187
188/// Get modification times for a list of files.
189fn get_mtimes(files: &[PathBuf]) -> std::collections::HashMap<PathBuf, std::time::SystemTime> {
190    let mut mtimes = std::collections::HashMap::new();
191    for file in files {
192        if let Ok(meta) = std::fs::metadata(file)
193            && let Ok(mtime) = meta.modified()
194        {
195            mtimes.insert(file.clone(), mtime);
196        }
197    }
198    mtimes
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn collect_files_in_temp_dir() {
207        let dir = tempfile::tempdir().unwrap();
208        std::fs::write(dir.path().join("a.rs"), "fn main() {}").unwrap();
209        std::fs::write(dir.path().join("b.rs"), "fn test() {}").unwrap();
210
211        let files = collect_files(dir.path());
212        assert_eq!(files.len(), 2);
213    }
214
215    #[test]
216    fn collect_files_skips_hidden() {
217        let dir = tempfile::tempdir().unwrap();
218        std::fs::create_dir(dir.path().join(".git")).unwrap();
219        std::fs::write(dir.path().join(".git/config"), "cfg").unwrap();
220        std::fs::write(dir.path().join("main.rs"), "main").unwrap();
221
222        let files = collect_files(dir.path());
223        assert_eq!(files.len(), 1);
224        assert!(files[0].file_name().unwrap() == "main.rs");
225    }
226
227    #[test]
228    fn collect_files_recursive_dirs() {
229        let dir = tempfile::tempdir().unwrap();
230        std::fs::create_dir(dir.path().join("src")).unwrap();
231        std::fs::write(dir.path().join("src/lib.rs"), "lib").unwrap();
232        std::fs::write(dir.path().join("src/main.rs"), "main").unwrap();
233
234        let files = collect_files(dir.path());
235        assert_eq!(files.len(), 2);
236    }
237
238    #[test]
239    fn collect_files_empty_dir() {
240        let dir = tempfile::tempdir().unwrap();
241        let files = collect_files(dir.path());
242        assert!(files.is_empty());
243    }
244
245    #[test]
246    fn get_mtimes_for_files() {
247        let dir = tempfile::tempdir().unwrap();
248        std::fs::write(dir.path().join("a.rs"), "a").unwrap();
249        std::fs::write(dir.path().join("b.rs"), "b").unwrap();
250
251        let files = collect_files(dir.path());
252        let mtimes = get_mtimes(&files);
253        assert_eq!(mtimes.len(), 2);
254    }
255
256    #[test]
257    fn get_mtimes_nonexistent_files() {
258        let files = vec![PathBuf::from("/nonexistent/file.rs")];
259        let mtimes = get_mtimes(&files);
260        assert!(mtimes.is_empty());
261    }
262
263    #[test]
264    fn file_watcher_construction() {
265        let dir = tempfile::tempdir().unwrap();
266        let config = WatchConfig::default();
267        let watcher = FileWatcher::new(dir.path(), &config);
268        assert!(watcher.is_ok());
269        let watcher = watcher.unwrap();
270        assert_eq!(watcher.root(), dir.path());
271    }
272}