testx/watcher/
file_watcher.rs1use 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
9pub struct FileWatcher {
11 rx: Receiver<PathBuf>,
13 debouncer: Debouncer,
15 ignore_patterns: Vec<GlobPattern>,
17 root: PathBuf,
19 _watcher_handle: std::thread::JoinHandle<()>,
21}
22
23impl FileWatcher {
24 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 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(¤t_files);
49
50 for (path, mtime) in ¤t_mtimes {
52 let changed = match known_mtimes.get(path) {
53 Some(old_mtime) => mtime != old_mtime,
54 None => true, };
56
57 if changed && tx.send(path.clone()).is_err() {
58 return; }
60 }
61
62 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 pub fn wait_for_changes(&mut self) -> Vec<PathBuf> {
89 loop {
90 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 if self.debouncer.has_pending() {
102 return self.debouncer.flush();
103 }
104 return Vec::new();
105 }
106 }
107 }
108
109 if self.debouncer.should_flush() {
111 return self.debouncer.flush();
112 }
113
114 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 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 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 pub fn root(&self) -> &Path {
149 &self.root
150 }
151}
152
153fn 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 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
188fn 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}