termesh_filesystem/
watch.rs1use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::sync::mpsc::{self, RecvTimeoutError};
16use std::time::{Duration, Instant};
17
18use notify::{RecursiveMode, Watcher};
19
20#[cfg(not(test))]
21type ActiveWatcher = notify::RecommendedWatcher;
22#[cfg(test)]
23type ActiveWatcher = notify::PollWatcher;
24
25use crate::ignore_rules::IgnoreRules;
26
27pub const DEFAULT_WINDOW: Duration = Duration::from_millis(100);
30
31#[derive(Debug)]
36pub struct Coalescer {
37 window: Duration,
38 pending: BTreeSet<PathBuf>,
41 opened_at: Option<Instant>,
42}
43
44impl Coalescer {
45 pub fn new(window: Duration) -> Self {
46 Self { window, pending: BTreeSet::new(), opened_at: None }
47 }
48
49 pub fn push(&mut self, path: PathBuf, now: Instant) {
51 if self.pending.is_empty() {
52 self.opened_at = Some(now);
53 }
54 self.pending.insert(path);
55 }
56
57 pub fn is_empty(&self) -> bool {
58 self.pending.is_empty()
59 }
60
61 pub fn time_remaining(&self, now: Instant) -> Option<Duration> {
63 let opened = self.opened_at?;
64 Some(self.window.saturating_sub(now.duration_since(opened)))
65 }
66
67 pub fn take_if_ready(&mut self, now: Instant) -> Option<Vec<PathBuf>> {
71 let opened = self.opened_at?;
72 if now.duration_since(opened) < self.window {
73 return None;
74 }
75 Some(self.take())
76 }
77
78 pub fn take(&mut self) -> Vec<PathBuf> {
80 self.opened_at = None;
81 std::mem::take(&mut self.pending).into_iter().collect()
82 }
83}
84
85pub fn is_editor_noise(path: &Path) -> bool {
88 let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return false };
89 name.ends_with('~')
90 || name.ends_with(".swp")
91 || name.ends_with(".swx")
92 || name.ends_with(".tmp")
93 || name == "4913"
95 || name.starts_with(".#")
97}
98
99pub fn is_relevant(path: &Path, rules: &IgnoreRules) -> bool {
101 if is_editor_noise(path) {
102 return false;
103 }
104 !(rules.is_hidden(path, false) && rules.is_hidden(path, true))
107}
108
109pub struct RootWatcher {
113 _watcher: ActiveWatcher,
114 stop: mpsc::Sender<()>,
115 handle: Option<std::thread::JoinHandle<()>>,
116}
117
118impl RootWatcher {
119 pub fn start<F>(root: &Path, window: Duration, filter: RelevanceFilter, sink: F) -> Option<Self>
124 where
125 F: Fn(Vec<PathBuf>) + Send + 'static,
126 {
127 let (raw_tx, raw_rx) = mpsc::channel::<PathBuf>();
128 let event_handler = move |res: notify::Result<notify::Event>| {
129 if let Ok(event) = res {
130 for path in event.paths {
131 let _ = raw_tx.send(path);
133 }
134 }
135 };
136 #[cfg(not(test))]
137 let mut watcher = notify::recommended_watcher(event_handler).ok()?;
138 #[cfg(test)]
143 let mut watcher = notify::PollWatcher::new(
144 event_handler,
145 notify::Config::default().with_poll_interval(window),
146 )
147 .ok()?;
148 watcher.watch(root, RecursiveMode::Recursive).ok()?;
149
150 let (stop, stop_rx) = mpsc::channel::<()>();
151 let handle = std::thread::Builder::new()
152 .name("termesh-fs-watch".into())
153 .spawn(move || {
154 let mut coalescer = Coalescer::new(window);
155 loop {
156 if stop_rx.try_recv().is_ok() {
157 return;
158 }
159 let wait = coalescer.time_remaining(Instant::now()).unwrap_or(window);
162 match raw_rx.recv_timeout(wait) {
163 Ok(path) => {
164 if filter.accepts(&path) {
165 coalescer.push(path, Instant::now());
166 }
167 }
168 Err(RecvTimeoutError::Timeout) => {}
169 Err(RecvTimeoutError::Disconnected) => {
171 if !coalescer.is_empty() {
172 sink(coalescer.take());
173 }
174 return;
175 }
176 }
177 if let Some(batch) = coalescer.take_if_ready(Instant::now()) {
178 if !batch.is_empty() {
179 sink(batch);
180 }
181 }
182 }
183 })
184 .ok()?;
185
186 Some(Self { _watcher: watcher, stop, handle: Some(handle) })
187 }
188}
189
190impl Drop for RootWatcher {
191 fn drop(&mut self) {
192 let _ = self.stop.send(());
193 if let Some(h) = self.handle.take() {
194 let _ = h.join();
195 }
196 }
197}
198
199pub struct RelevanceFilter(Box<dyn Fn(&Path) -> bool + Send>);
204
205impl RelevanceFilter {
206 pub fn new<F: Fn(&Path) -> bool + Send + 'static>(f: F) -> Self {
207 Self(Box::new(f))
208 }
209
210 pub fn noise_only() -> Self {
212 Self::new(|p| !is_editor_noise(p))
213 }
214
215 pub fn accepts(&self, path: &Path) -> bool {
216 (self.0)(path)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn window() -> Duration {
225 Duration::from_millis(100)
226 }
227
228 #[test]
229 fn nothing_is_released_before_the_window_elapses() {
230 let t0 = Instant::now();
231 let mut c = Coalescer::new(window());
232 c.push("/r/a.rs".into(), t0);
233 assert_eq!(c.take_if_ready(t0 + Duration::from_millis(50)), None);
234 }
235
236 #[test]
237 fn the_batch_is_released_once_the_window_elapses() {
238 let t0 = Instant::now();
239 let mut c = Coalescer::new(window());
240 c.push("/r/a.rs".into(), t0);
241 assert_eq!(
242 c.take_if_ready(t0 + Duration::from_millis(120)),
243 Some(vec![PathBuf::from("/r/a.rs")])
244 );
245 }
246
247 #[test]
248 fn repeated_events_for_one_path_collapse_to_a_single_entry() {
249 let t0 = Instant::now();
250 let mut c = Coalescer::new(window());
251 for _ in 0..50 {
252 c.push("/r/a.rs".into(), t0);
253 }
254 let batch = c.take_if_ready(t0 + Duration::from_millis(120)).unwrap();
255 assert_eq!(batch, vec![PathBuf::from("/r/a.rs")], "one save, one entry");
256 }
257
258 #[test]
259 fn a_save_storm_across_files_becomes_one_batch() {
260 let t0 = Instant::now();
261 let mut c = Coalescer::new(window());
262 for (i, p) in ["/r/a.rs", "/r/b.rs", "/r/c.rs", "/r/a.rs"].iter().enumerate() {
264 c.push(PathBuf::from(p), t0 + Duration::from_millis(i as u64 * 10));
265 }
266 let batch = c.take_if_ready(t0 + Duration::from_millis(120)).unwrap();
267 assert_eq!(batch.len(), 3, "three distinct paths, one batch");
268 }
269
270 #[test]
271 fn a_steady_trickle_is_not_deferred_forever() {
272 let t0 = Instant::now();
275 let mut c = Coalescer::new(window());
276 c.push("/r/a.rs".into(), t0);
277 for i in 1..20 {
278 c.push(format!("/r/f{i}.rs").into(), t0 + Duration::from_millis(i * 10));
279 }
280 assert!(c.take_if_ready(t0 + Duration::from_millis(101)).is_some());
281 }
282
283 #[test]
284 fn the_window_restarts_for_the_next_batch() {
285 let t0 = Instant::now();
286 let mut c = Coalescer::new(window());
287 c.push("/r/a.rs".into(), t0);
288 assert!(c.take_if_ready(t0 + Duration::from_millis(120)).is_some());
289 assert!(c.is_empty());
290
291 let t1 = t0 + Duration::from_millis(500);
292 c.push("/r/b.rs".into(), t1);
293 assert_eq!(c.take_if_ready(t1 + Duration::from_millis(50)), None, "fresh window");
294 assert!(c.take_if_ready(t1 + Duration::from_millis(120)).is_some());
295 }
296
297 #[test]
298 fn an_empty_coalescer_never_reports_ready() {
299 let mut c = Coalescer::new(window());
300 assert_eq!(c.take_if_ready(Instant::now()), None);
301 assert_eq!(c.time_remaining(Instant::now()), None);
302 }
303
304 #[test]
305 fn batches_are_deterministically_ordered() {
306 let t0 = Instant::now();
307 let mut a = Coalescer::new(window());
308 let mut b = Coalescer::new(window());
309 for p in ["/r/c", "/r/a", "/r/b"] {
310 a.push(PathBuf::from(p), t0);
311 }
312 for p in ["/r/b", "/r/c", "/r/a"] {
313 b.push(PathBuf::from(p), t0);
314 }
315 let ready = t0 + Duration::from_millis(120);
316 assert_eq!(a.take_if_ready(ready), b.take_if_ready(ready), "arrival order must not matter");
317 }
318
319 #[test]
320 fn editor_swap_and_backup_files_are_noise() {
321 for p in ["/r/.main.rs.swp", "/r/main.rs~", "/r/4913", "/r/.#main.rs", "/r/build.tmp"] {
322 assert!(is_editor_noise(Path::new(p)), "{p} should be filtered out");
323 }
324 }
325
326 #[test]
327 fn real_source_files_are_not_noise() {
328 for p in ["/r/main.rs", "/r/Cargo.toml", "/r/src/model.rs"] {
329 assert!(!is_editor_noise(Path::new(p)), "{p} must reach the tree");
330 }
331 }
332
333 #[test]
334 fn the_noise_only_filter_passes_real_files() {
335 let f = RelevanceFilter::noise_only();
336 assert!(f.accepts(Path::new("/r/main.rs")));
337 assert!(!f.accepts(Path::new("/r/main.rs~")));
338 }
339}