Skip to main content

teamctl_ui/
watch.rs

1//! `notify`-based file-watch on the broker SQLite at `state/mailbox.db`.
2//!
3//! Replaces (or augments) the 1s long-poll established in PR-UI-2:
4//! when the platform supports `notify` (Linux inotify / macOS
5//! FSEvents / Windows ReadDirectoryChangesW), the run loop refreshes
6//! immediately on a `mailbox.db`-WAL/SHM write event. Platforms
7//! without `notify` support fall back to the 1s poll — same shape
8//! as the truecolor-detection capability fallback in PR-UI-1.
9//!
10//! The watcher writes to a shared `AtomicBool` "dirty" flag rather
11//! than emitting events through a channel, because the run loop
12//! already has its own poll cadence and we just need the watcher to
13//! say "something changed, refresh sooner than the deadline."
14
15use std::path::Path;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::Arc;
18
19use notify::{Event, EventKind, RecursiveMode, Watcher};
20
21/// Spawn a `notify` watcher rooted at the broker's `state/`
22/// directory. Returns `None` on platforms (or filesystems) where
23/// `notify` can't bring up a recommended watcher — the run loop
24/// then falls back to the 1s poll. The returned `Watch` keeps the
25/// underlying watcher alive; drop it to stop watching.
26pub struct Watch {
27    /// Shared dirty flag — flipped to `true` on every relevant
28    /// filesystem event. The run loop reads + clears it via
29    /// `take_dirty`.
30    pub dirty: Arc<AtomicBool>,
31    /// Holds the platform watcher so it doesn't drop and stop
32    /// emitting events. The field is read in
33    /// [`Watch::take_dirty`]'s impl boundary only, never directly.
34    _watcher: Option<Box<dyn Watcher + Send>>,
35}
36
37impl Watch {
38    /// Construct a watch with no underlying watcher. Used by tests
39    /// and as the fallback when `notify::recommended_watcher`
40    /// fails to initialise.
41    pub fn idle() -> Self {
42        Self {
43            dirty: Arc::new(AtomicBool::new(false)),
44            _watcher: None,
45        }
46    }
47
48    /// Try to build a recommended watcher rooted at `state_dir`.
49    /// Returns an idle (no-watcher) Watch on any failure so the
50    /// caller can still rely on the dirty-flag interface even when
51    /// the platform doesn't support filesystem notifications.
52    pub fn try_new(state_dir: &Path) -> Self {
53        let dirty = Arc::new(AtomicBool::new(false));
54        let dirty_for_cb = dirty.clone();
55        let cb = move |res: notify::Result<Event>| {
56            if let Ok(ev) = res {
57                if relevant(&ev.kind) {
58                    dirty_for_cb.store(true, Ordering::SeqCst);
59                }
60            }
61        };
62        let watcher = notify::recommended_watcher(cb).ok();
63        let mut watcher: Option<Box<dyn Watcher + Send>> =
64            watcher.map(|w| Box::new(w) as Box<dyn Watcher + Send>);
65        if let Some(w) = watcher.as_mut() {
66            // `mailbox.db` lives at `<state_dir>/mailbox.db`; SQLite
67            // also writes WAL + SHM siblings (`mailbox.db-wal`,
68            // `mailbox.db-shm`) on every commit. Watching the
69            // parent dir non-recursively catches all three with one
70            // subscription.
71            if w.watch(state_dir, RecursiveMode::NonRecursive).is_err() {
72                // Watcher started but couldn't subscribe (permissions,
73                // missing dir) — fall back to idle.
74                return Self::idle();
75            }
76        }
77        Self {
78            dirty,
79            _watcher: watcher,
80        }
81    }
82
83    /// Read + clear the dirty flag. Returns `true` exactly once per
84    /// batch of events the watcher saw since the previous call.
85    pub fn take_dirty(&self) -> bool {
86        self.dirty.swap(false, Ordering::SeqCst)
87    }
88}
89
90/// Filter `notify` event kinds down to the ones SQLite actually
91/// triggers on commit. Create / Modify / Remove cover every shape
92/// we care about; `Access` events (read-only opens) would refresh
93/// uselessly on every UI tick and are dropped here.
94fn relevant(kind: &EventKind) -> bool {
95    matches!(
96        kind,
97        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
98    )
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use notify::event::{CreateKind, ModifyKind, RemoveKind};
105
106    #[test]
107    fn idle_watch_is_never_dirty() {
108        let w = Watch::idle();
109        assert!(!w.take_dirty());
110        assert!(!w.take_dirty());
111    }
112
113    #[test]
114    fn dirty_flag_clears_on_take() {
115        let w = Watch::idle();
116        w.dirty.store(true, Ordering::SeqCst);
117        assert!(w.take_dirty());
118        assert!(!w.take_dirty(), "second call sees the cleared flag");
119    }
120
121    #[test]
122    fn relevant_kinds_match_sqlite_commit_shape() {
123        assert!(relevant(&EventKind::Create(CreateKind::File)));
124        assert!(relevant(&EventKind::Modify(ModifyKind::Any)));
125        assert!(relevant(&EventKind::Remove(RemoveKind::File)));
126        // Access events (e.g. an `inbox_peek` reader open) must
127        // not trigger a refresh — the UI doesn't care about reads.
128        assert!(!relevant(&EventKind::Access(
129            notify::event::AccessKind::Open(notify::event::AccessMode::Read)
130        )));
131    }
132
133    #[test]
134    fn try_new_on_missing_dir_returns_idle() {
135        // Non-existent path → watcher subscribe fails → fallback.
136        let w = Watch::try_new(Path::new("/definitely/does/not/exist/teamctl-ui-test"));
137        assert!(!w.take_dirty(), "idle fallback never goes dirty");
138    }
139}