Skip to main content

zccache_watcher/
notify_watcher.rs

1//! Concrete file watcher backed by the `notify` crate.
2//!
3//! Creates a `RecommendedWatcher` that converts OS filesystem events into
4//! `WatchEvent`s, filters them through an `IgnoreFilter`, and sends them
5//! over a `tokio::sync::mpsc` channel for consumption by the settle buffer.
6//!
7//! The notify callback runs on a dedicated OS thread. Using
8//! `tokio::sync::mpsc` (not crossbeam) ensures safe crossing from the OS
9//! thread into the async runtime.
10
11use crate::ignore::IgnoreFilter;
12use crate::WatchEvent;
13use notify::{Event, EventKind, RecursiveMode, Watcher};
14use std::path::Path;
15use std::sync::Arc;
16use tokio::sync::mpsc;
17
18/// File watcher backed by the `notify` crate.
19///
20/// Wraps a `RecommendedWatcher` and exposes `watch`/`unwatch` methods.
21/// Events are sent to the unbounded receiver returned by [`NotifyWatcher::new`].
22pub struct NotifyWatcher {
23    watcher: notify::RecommendedWatcher,
24}
25
26impl std::fmt::Debug for NotifyWatcher {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.debug_struct("NotifyWatcher").finish_non_exhaustive()
29    }
30}
31
32impl NotifyWatcher {
33    /// Create a new watcher with the given ignore filter.
34    ///
35    /// Returns the watcher and an unbounded receiver of `WatchEvent`s.
36    /// The receiver should be fed into a [`SettleBuffer`](crate::settle::SettleBuffer).
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the OS file watcher cannot be initialized.
41    pub fn new(
42        ignore: Arc<IgnoreFilter>,
43    ) -> zccache_core::Result<(Self, mpsc::UnboundedReceiver<WatchEvent>)> {
44        let (tx, rx) = mpsc::unbounded_channel();
45
46        let watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
47            match res {
48                Ok(event) => {
49                    for watch_event in convert_event(&ignore, &event) {
50                        if tx.send(watch_event).is_err() {
51                            // Receiver dropped — watcher is shutting down.
52                            return;
53                        }
54                    }
55                }
56                Err(e) => {
57                    tracing::warn!("watcher error: {e}");
58                    let _ = tx.send(WatchEvent::Error(e.to_string()));
59                }
60            }
61        })
62        .map_err(|e| std::io::Error::other(e.to_string()))?;
63
64        Ok((Self { watcher }, rx))
65    }
66
67    /// Start watching a single directory (non-recursive).
68    ///
69    /// Callers are responsible for enumerating subdirectories and watching
70    /// each one individually. This avoids platform-level recursive watches
71    /// that can hit OS limits or produce degenerate behaviour on large trees.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the path cannot be watched.
76    pub fn watch(&mut self, path: &Path) -> zccache_core::Result<()> {
77        self.watcher
78            .watch(path, RecursiveMode::NonRecursive)
79            .map_err(|e| std::io::Error::other(e.to_string()))?;
80        Ok(())
81    }
82
83    /// Start watching a directory recursively.
84    ///
85    /// This is intended for library consumers that want a single root watch.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if the path cannot be watched.
90    pub fn watch_recursive(&mut self, path: &Path) -> zccache_core::Result<()> {
91        self.watcher
92            .watch(path, RecursiveMode::Recursive)
93            .map_err(|e| std::io::Error::other(e.to_string()))?;
94        Ok(())
95    }
96
97    /// Stop watching a directory.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the path was not being watched.
102    pub fn unwatch(&mut self, path: &Path) -> zccache_core::Result<()> {
103        self.watcher
104            .unwatch(path)
105            .map_err(|e| std::io::Error::other(e.to_string()))?;
106        Ok(())
107    }
108}
109
110/// Convert a `notify::Event` into zero or more `WatchEvent`s.
111fn convert_event(ignore: &IgnoreFilter, event: &Event) -> Vec<WatchEvent> {
112    // Detect overflow/rescan events from inotify (Q_OVERFLOW) and
113    // FSEvents (MUST_SCAN_SUBDIRS). These have EventKind::Other with
114    // Flag::Rescan and empty paths — the path loop below would produce
115    // nothing, silently swallowing the overflow.
116    if event.need_rescan() {
117        return vec![WatchEvent::Overflow];
118    }
119
120    // Handle rename with both paths present.
121    if matches!(
122        event.kind,
123        EventKind::Modify(notify::event::ModifyKind::Name(
124            notify::event::RenameMode::Both
125        ))
126    ) && event.paths.len() >= 2
127    {
128        let from = &event.paths[0];
129        let to = &event.paths[1];
130        let from_ignored = ignore.should_ignore(from);
131        let to_ignored = ignore.should_ignore(to);
132        if from_ignored && to_ignored {
133            return vec![];
134        }
135        if from_ignored {
136            // File appeared from ignored area — treat as creation.
137            return vec![WatchEvent::Created(to.as_path().into())];
138        }
139        if to_ignored {
140            // File moved to ignored area — treat as removal.
141            return vec![WatchEvent::Removed(from.as_path().into())];
142        }
143        return vec![WatchEvent::Renamed {
144            from: from.as_path().into(),
145            to: to.as_path().into(),
146        }];
147    }
148
149    let mut result = Vec::new();
150    for path in &event.paths {
151        if ignore.should_ignore(path) {
152            continue;
153        }
154
155        let watch_event = match event.kind {
156            EventKind::Create(_) => WatchEvent::Created(path.as_path().into()),
157            EventKind::Remove(_) => WatchEvent::Removed(path.as_path().into()),
158            EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::From)) => {
159                // Half of a rename — treat as removal (conservative).
160                WatchEvent::Removed(path.as_path().into())
161            }
162            EventKind::Modify(notify::event::ModifyKind::Name(notify::event::RenameMode::To)) => {
163                // Half of a rename — treat as creation (conservative).
164                WatchEvent::Created(path.as_path().into())
165            }
166            EventKind::Modify(_) => WatchEvent::Modified(path.as_path().into()),
167            EventKind::Access(_) => continue,
168            // Any, Other — conservative: treat as modification.
169            _ => WatchEvent::Modified(path.as_path().into()),
170        };
171
172        result.push(watch_event);
173    }
174
175    result
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn test_filter() -> IgnoreFilter {
183        IgnoreFilter::new(vec![".git".to_string(), "target".to_string()])
184    }
185
186    #[test]
187    fn convert_create_event() {
188        let filter = test_filter();
189        let event = Event {
190            kind: EventKind::Create(notify::event::CreateKind::File),
191            paths: vec![Path::new("src/main.rs").to_owned()],
192            attrs: Default::default(),
193        };
194        let result = convert_event(&filter, &event);
195        assert_eq!(result.len(), 1);
196        assert!(
197            matches!(&result[0], WatchEvent::Created(p) if p.as_path() == Path::new("src/main.rs"))
198        );
199    }
200
201    #[test]
202    fn convert_modify_event() {
203        let filter = test_filter();
204        let event = Event {
205            kind: EventKind::Modify(notify::event::ModifyKind::Data(
206                notify::event::DataChange::Content,
207            )),
208            paths: vec![Path::new("src/lib.rs").to_owned()],
209            attrs: Default::default(),
210        };
211        let result = convert_event(&filter, &event);
212        assert_eq!(result.len(), 1);
213        assert!(
214            matches!(&result[0], WatchEvent::Modified(p) if p.as_path() == Path::new("src/lib.rs"))
215        );
216    }
217
218    #[test]
219    fn convert_remove_event() {
220        let filter = test_filter();
221        let event = Event {
222            kind: EventKind::Remove(notify::event::RemoveKind::File),
223            paths: vec![Path::new("old.c").to_owned()],
224            attrs: Default::default(),
225        };
226        let result = convert_event(&filter, &event);
227        assert_eq!(result.len(), 1);
228        assert!(matches!(&result[0], WatchEvent::Removed(p) if p.as_path() == Path::new("old.c")));
229    }
230
231    #[test]
232    fn convert_rename_both() {
233        let filter = test_filter();
234        let event = Event {
235            kind: EventKind::Modify(notify::event::ModifyKind::Name(
236                notify::event::RenameMode::Both,
237            )),
238            paths: vec![Path::new("old.c").to_owned(), Path::new("new.c").to_owned()],
239            attrs: Default::default(),
240        };
241        let result = convert_event(&filter, &event);
242        assert_eq!(result.len(), 1);
243        assert!(matches!(
244            &result[0],
245            WatchEvent::Renamed { from, to }
246            if from.as_path() == Path::new("old.c") && to.as_path() == Path::new("new.c")
247        ));
248    }
249
250    #[test]
251    fn convert_rename_from_becomes_removed() {
252        let filter = test_filter();
253        let event = Event {
254            kind: EventKind::Modify(notify::event::ModifyKind::Name(
255                notify::event::RenameMode::From,
256            )),
257            paths: vec![Path::new("gone.c").to_owned()],
258            attrs: Default::default(),
259        };
260        let result = convert_event(&filter, &event);
261        assert_eq!(result.len(), 1);
262        assert!(matches!(&result[0], WatchEvent::Removed(p) if p.as_path() == Path::new("gone.c")));
263    }
264
265    #[test]
266    fn convert_rename_to_becomes_created() {
267        let filter = test_filter();
268        let event = Event {
269            kind: EventKind::Modify(notify::event::ModifyKind::Name(
270                notify::event::RenameMode::To,
271            )),
272            paths: vec![Path::new("appeared.c").to_owned()],
273            attrs: Default::default(),
274        };
275        let result = convert_event(&filter, &event);
276        assert_eq!(result.len(), 1);
277        assert!(
278            matches!(&result[0], WatchEvent::Created(p) if p.as_path() == Path::new("appeared.c"))
279        );
280    }
281
282    #[test]
283    fn ignored_paths_filtered_out() {
284        let filter = test_filter();
285        let event = Event {
286            kind: EventKind::Modify(notify::event::ModifyKind::Data(
287                notify::event::DataChange::Content,
288            )),
289            paths: vec![Path::new("project/.git/index").to_owned()],
290            attrs: Default::default(),
291        };
292        let result = convert_event(&filter, &event);
293        assert!(result.is_empty());
294    }
295
296    #[test]
297    fn ignored_rename_both_filtered() {
298        let filter = test_filter();
299        let event = Event {
300            kind: EventKind::Modify(notify::event::ModifyKind::Name(
301                notify::event::RenameMode::Both,
302            )),
303            paths: vec![
304                Path::new("project/.git/old").to_owned(),
305                Path::new("project/.git/new").to_owned(),
306            ],
307            attrs: Default::default(),
308        };
309        let result = convert_event(&filter, &event);
310        assert!(result.is_empty());
311    }
312
313    #[test]
314    fn rename_from_ignored_to_visible_becomes_created() {
315        // Rename from an ignored dir to a visible dir should produce Created(to).
316        let filter = test_filter();
317        let event = Event {
318            kind: EventKind::Modify(notify::event::ModifyKind::Name(
319                notify::event::RenameMode::Both,
320            )),
321            paths: vec![
322                Path::new("project/.git/stash").to_owned(),
323                Path::new("src/recovered.c").to_owned(),
324            ],
325            attrs: Default::default(),
326        };
327        let result = convert_event(&filter, &event);
328        assert_eq!(result.len(), 1);
329        assert!(
330            matches!(&result[0], WatchEvent::Created(p) if p.as_path() == Path::new("src/recovered.c"))
331        );
332    }
333
334    #[test]
335    fn rename_from_visible_to_ignored_becomes_removed() {
336        // Rename from a visible dir to an ignored dir should produce Removed(from).
337        let filter = test_filter();
338        let event = Event {
339            kind: EventKind::Modify(notify::event::ModifyKind::Name(
340                notify::event::RenameMode::Both,
341            )),
342            paths: vec![
343                Path::new("src/main.rs").to_owned(),
344                Path::new("project/.git/stash").to_owned(),
345            ],
346            attrs: Default::default(),
347        };
348        let result = convert_event(&filter, &event);
349        assert_eq!(result.len(), 1);
350        assert!(
351            matches!(&result[0], WatchEvent::Removed(p) if p.as_path() == Path::new("src/main.rs"))
352        );
353    }
354
355    #[test]
356    fn access_events_ignored() {
357        let filter = test_filter();
358        let event = Event {
359            kind: EventKind::Access(notify::event::AccessKind::Read),
360            paths: vec![Path::new("src/main.rs").to_owned()],
361            attrs: Default::default(),
362        };
363        let result = convert_event(&filter, &event);
364        assert!(result.is_empty());
365    }
366
367    #[test]
368    fn rename_both_with_single_path_falls_through() {
369        // Rename Both with < 2 paths should not panic; falls to per-path loop.
370        let filter = test_filter();
371        let event = Event {
372            kind: EventKind::Modify(notify::event::ModifyKind::Name(
373                notify::event::RenameMode::Both,
374            )),
375            paths: vec![Path::new("only_one.c").to_owned()],
376            attrs: Default::default(),
377        };
378        let result = convert_event(&filter, &event);
379        // Falls through to per-path handling as Modify(Name(Both)), caught by wildcard → Modified.
380        assert_eq!(result.len(), 1);
381        assert!(matches!(&result[0], WatchEvent::Modified(_)));
382    }
383
384    #[test]
385    fn event_with_empty_paths() {
386        let filter = test_filter();
387        let event = Event {
388            kind: EventKind::Modify(notify::event::ModifyKind::Data(
389                notify::event::DataChange::Content,
390            )),
391            paths: vec![],
392            attrs: Default::default(),
393        };
394        let result = convert_event(&filter, &event);
395        assert!(result.is_empty());
396    }
397
398    #[test]
399    fn event_kind_other_becomes_modified() {
400        let filter = test_filter();
401        let event = Event {
402            kind: EventKind::Other,
403            paths: vec![Path::new("mystery.c").to_owned()],
404            attrs: Default::default(),
405        };
406        let result = convert_event(&filter, &event);
407        assert_eq!(result.len(), 1);
408        assert!(matches!(&result[0], WatchEvent::Modified(_)));
409    }
410
411    #[test]
412    fn event_kind_any_becomes_modified() {
413        let filter = test_filter();
414        let event = Event {
415            kind: EventKind::Any,
416            paths: vec![Path::new("any.c").to_owned()],
417            attrs: Default::default(),
418        };
419        let result = convert_event(&filter, &event);
420        assert_eq!(result.len(), 1);
421        assert!(matches!(&result[0], WatchEvent::Modified(_)));
422    }
423
424    #[test]
425    fn remove_directory_event() {
426        let filter = test_filter();
427        let event = Event {
428            kind: EventKind::Remove(notify::event::RemoveKind::Folder),
429            paths: vec![Path::new("src/old_module").to_owned()],
430            attrs: Default::default(),
431        };
432        let result = convert_event(&filter, &event);
433        assert_eq!(result.len(), 1);
434        assert!(matches!(&result[0], WatchEvent::Removed(_)));
435    }
436
437    #[test]
438    fn create_directory_event() {
439        let filter = test_filter();
440        let event = Event {
441            kind: EventKind::Create(notify::event::CreateKind::Folder),
442            paths: vec![Path::new("src/new_module").to_owned()],
443            attrs: Default::default(),
444        };
445        let result = convert_event(&filter, &event);
446        assert_eq!(result.len(), 1);
447        assert!(matches!(&result[0], WatchEvent::Created(_)));
448    }
449
450    #[test]
451    fn metadata_change_becomes_modified() {
452        let filter = test_filter();
453        let event = Event {
454            kind: EventKind::Modify(notify::event::ModifyKind::Metadata(
455                notify::event::MetadataKind::Permissions,
456            )),
457            paths: vec![Path::new("script.sh").to_owned()],
458            attrs: Default::default(),
459        };
460        let result = convert_event(&filter, &event);
461        assert_eq!(result.len(), 1);
462        assert!(matches!(&result[0], WatchEvent::Modified(_)));
463    }
464
465    #[test]
466    fn notify_watcher_can_be_created() {
467        use std::sync::Arc;
468
469        let ignore = Arc::new(IgnoreFilter::default());
470        let result = NotifyWatcher::new(ignore);
471        assert!(result.is_ok());
472
473        let (mut watcher, _rx) = result.unwrap();
474        // Watch a valid temp dir.
475        let dir = tempfile::TempDir::new().unwrap();
476        assert!(watcher.watch(dir.path()).is_ok());
477        assert!(watcher.unwatch(dir.path()).is_ok());
478    }
479
480    #[test]
481    fn notify_watcher_watch_nonexistent_fails() {
482        use std::sync::Arc;
483
484        let ignore = Arc::new(IgnoreFilter::default());
485        let (mut watcher, _rx) = NotifyWatcher::new(ignore).unwrap();
486        let result = watcher.watch(Path::new("/no/such/directory/ever"));
487        assert!(result.is_err());
488    }
489
490    #[test]
491    fn notify_watcher_debug_impl() {
492        use std::sync::Arc;
493        let ignore = Arc::new(IgnoreFilter::default());
494        let (watcher, _rx) = NotifyWatcher::new(ignore).unwrap();
495        let debug = format!("{watcher:?}");
496        assert!(debug.contains("NotifyWatcher"));
497    }
498
499    #[test]
500    fn rescan_flag_produces_overflow() {
501        use notify::event::Flag;
502
503        let filter = test_filter();
504        // inotify Q_OVERFLOW and FSEvents MUST_SCAN_SUBDIRS produce
505        // EventKind::Other with Flag::Rescan and empty paths.
506        let event = Event::new(EventKind::Other).set_flag(Flag::Rescan);
507        let result = convert_event(&filter, &event);
508        assert_eq!(result.len(), 1);
509        assert!(matches!(&result[0], WatchEvent::Overflow));
510    }
511
512    #[test]
513    fn rescan_flag_with_paths_still_produces_overflow() {
514        use notify::event::Flag;
515
516        let filter = test_filter();
517        // Even if a rescan event carries paths, we still treat it as overflow
518        // because the semantics are "everything may have changed".
519        let mut event = Event::new(EventKind::Other).set_flag(Flag::Rescan);
520        event.paths = vec![Path::new("src/main.rs").to_owned()];
521        let result = convert_event(&filter, &event);
522        assert_eq!(result.len(), 1);
523        assert!(matches!(&result[0], WatchEvent::Overflow));
524    }
525
526    #[test]
527    fn mixed_paths_filter_individually() {
528        let filter = test_filter();
529        let event = Event {
530            kind: EventKind::Modify(notify::event::ModifyKind::Data(
531                notify::event::DataChange::Content,
532            )),
533            paths: vec![
534                Path::new("src/main.rs").to_owned(),
535                Path::new("target/debug/binary").to_owned(),
536                Path::new("src/lib.rs").to_owned(),
537            ],
538            attrs: Default::default(),
539        };
540        let result = convert_event(&filter, &event);
541        assert_eq!(result.len(), 2);
542    }
543}