Skip to main content

vfa_tui/catalog/
watcher.rs

1//! Filesystem watcher integration for catalog, registry, and workspace paths.
2//!
3//! Uses `notify-debouncer-full` for intelligent rename/modify coalescing and
4//! 500 ms debouncing (Req 1.6).  Debounced events are forwarded to a tokio
5//! mpsc channel so the TUI event loop never blocks on notify callbacks (Req 1.7).
6//!
7//! # Re-establish logic (Req 1.4 / 25.5)
8//!
9//! When the underlying notify watcher signals an error (watch path deleted or
10//! permission error) a warning is logged.  A background tokio task also checks
11//! every 30 s whether any of the registered watch-roots still exist and, if so,
12//! re-adds them to the watcher.  Callers can also invoke
13//! [`WatcherHandle::reestablish`] explicitly.
14
15use std::{
16    path::{Path, PathBuf},
17    sync::{Arc, Mutex},
18    time::Duration,
19};
20
21use notify_debouncer_full::{
22    new_debouncer, notify::RecursiveMode, DebounceEventResult, Debouncer, RecommendedCache,
23};
24use tokio::sync::mpsc;
25use tracing::{debug, warn};
26
27use crate::error::TuiError;
28
29// ── Debounce window (Req 1.6) ─────────────────────────────────────────────────
30const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
31/// How often the re-establish task retries failed watch paths (Req 1.4 / 25.5).
32const REESTABLISH_INTERVAL: Duration = Duration::from_secs(30);
33
34// ── Public types ──────────────────────────────────────────────────────────────
35
36/// A filesystem change that needs to be acted upon by the catalog subsystem.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WatcherEvent {
39    /// A file under the catalog directory changed.
40    Catalog(PathBuf),
41    /// The workspace registry file changed.
42    Registry,
43    /// A file under one of the watched workspace paths changed.
44    Workspace(PathBuf),
45}
46
47/// Configuration describing what to watch.
48#[derive(Clone)]
49pub struct WatchConfig {
50    pub catalog_dir: PathBuf,
51    pub registry_path: Option<PathBuf>,
52    pub workspace_paths: Vec<PathBuf>,
53}
54
55/// Opaque handle that keeps the debouncer alive and exposes shutdown /
56/// re-establish operations.
57pub struct WatcherHandle {
58    /// The debouncer **must** stay alive for the duration of watching.
59    /// Wrapped in `Arc<Mutex<…>>` so the re-establish task can borrow it.
60    debouncer:
61        Arc<Mutex<Debouncer<notify_debouncer_full::notify::RecommendedWatcher, RecommendedCache>>>,
62    config: WatchConfig,
63}
64
65impl WatcherHandle {
66    /// Attempt to re-add all configured watch roots to the underlying watcher.
67    ///
68    /// Silently ignores paths that do not exist yet (they will be retried on
69    /// the next periodic call).  Logs a warning for any path that exists but
70    /// cannot be watched.
71    pub fn reestablish(&self) {
72        let mut guard = match self.debouncer.lock() {
73            Ok(g) => g,
74            Err(e) => {
75                warn!("watcher mutex poisoned during reestablish: {e}");
76                return;
77            }
78        };
79        let cfg = &self.config;
80
81        // Catalog dir
82        if cfg.catalog_dir.exists() {
83            if let Err(e) = guard.watch(&cfg.catalog_dir, RecursiveMode::Recursive) {
84                warn!(
85                    path = %cfg.catalog_dir.display(),
86                    "failed to re-watch catalog dir: {e}"
87                );
88            } else {
89                debug!(path = %cfg.catalog_dir.display(), "re-established catalog watch");
90            }
91        }
92
93        // Registry file
94        if let Some(reg) = &cfg.registry_path {
95            if reg.exists() {
96                // Watch the parent directory so we catch temp-then-rename writes
97                if let Some(parent) = reg.parent() {
98                    if let Err(e) = guard.watch(parent, RecursiveMode::NonRecursive) {
99                        warn!(
100                            path = %parent.display(),
101                            "failed to re-watch registry parent: {e}"
102                        );
103                    } else {
104                        debug!(path = %parent.display(), "re-established registry watch");
105                    }
106                }
107            }
108        }
109
110        // Workspace paths
111        for ws in &cfg.workspace_paths {
112            if ws.exists() {
113                if let Err(e) = guard.watch(ws, RecursiveMode::Recursive) {
114                    warn!(
115                        path = %ws.display(),
116                        "failed to re-watch workspace path: {e}"
117                    );
118                } else {
119                    debug!(path = %ws.display(), "re-established workspace watch");
120                }
121            }
122        }
123    }
124
125    /// Consume the handle and stop the debouncer gracefully.
126    pub fn shutdown(self) {
127        match Arc::try_unwrap(self.debouncer) {
128            Ok(mutex) => {
129                if let Ok(debouncer) = mutex.into_inner() {
130                    debouncer.stop_nonblocking();
131                }
132            }
133            Err(_arc) => {
134                // Re-establish task still holds a reference; the debouncer will
135                // be stopped when all Arc clones are dropped.
136                debug!("watcher shutdown deferred – re-establish task still active");
137            }
138        }
139    }
140}
141
142// ── Path classification (pure, tested independently) ─────────────────────────
143
144/// Classify a changed path into a [`WatcherEvent`].
145///
146/// This is a **pure function** with no filesystem I/O, making it easy to unit-
147/// test independently of the notify backend (which can be timing-flaky in CI).
148pub fn classify_path(
149    changed: &Path,
150    catalog_dir: &Path,
151    registry_path: Option<&Path>,
152    workspace_paths: &[PathBuf],
153) -> Option<WatcherEvent> {
154    // Registry file check (exact match or parent-dir match for temp-rename)
155    if let Some(reg) = registry_path {
156        if changed == reg {
157            return Some(WatcherEvent::Registry);
158        }
159        // Handle parent-dir watch: notify may report the *final* path after a
160        // temp-rename, which equals the registry path.
161        if changed.parent() == reg.parent() && changed.file_name() == reg.file_name() {
162            return Some(WatcherEvent::Registry);
163        }
164    }
165
166    // Catalog directory
167    if changed.starts_with(catalog_dir) {
168        return Some(WatcherEvent::Catalog(changed.to_path_buf()));
169    }
170
171    // Workspace paths
172    for ws in workspace_paths {
173        if changed.starts_with(ws) {
174            return Some(WatcherEvent::Workspace(changed.to_path_buf()));
175        }
176    }
177
178    None
179}
180
181// ── Spawn function ────────────────────────────────────────────────────────────
182
183/// Spawn a filesystem watcher and return a `(WatcherHandle, Receiver<WatcherEvent>)`.
184///
185/// The debouncer fires at most once per file per [`DEBOUNCE_TIMEOUT`] (500 ms).
186/// All paths may be watched recursively; the registry parent directory is
187/// watched non-recursively so that only the registry file's rename/modify events
188/// are forwarded (avoiding noise from sibling files).
189///
190/// A background tokio task periodically calls [`WatcherHandle::reestablish`]
191/// every [`REESTABLISH_INTERVAL`] (30 s) to recover from transient watch
192/// failures such as watched-path deletion (Req 1.4 / 25.5).
193pub fn spawn_watcher(
194    catalog_dir: &Path,
195    registry_path: Option<&Path>,
196    workspace_paths: &[PathBuf],
197) -> Result<(WatcherHandle, mpsc::Receiver<WatcherEvent>), TuiError> {
198    // Tokio channel: the notify callback sends here; the TUI event loop reads.
199    let (tx, rx) = mpsc::channel::<WatcherEvent>(256);
200
201    let config = WatchConfig {
202        catalog_dir: catalog_dir.to_path_buf(),
203        registry_path: registry_path.map(|p| p.to_path_buf()),
204        workspace_paths: workspace_paths.to_vec(),
205    };
206
207    // Clone config fields for use inside the notify callback closure.
208    let cb_catalog_dir = config.catalog_dir.clone();
209    let cb_registry = config.registry_path.clone();
210    let cb_workspace_paths = config.workspace_paths.clone();
211    let cb_tx = tx.clone();
212
213    let debouncer = new_debouncer(
214        DEBOUNCE_TIMEOUT,
215        None, // tick_rate = None → 1/4 of timeout = 125 ms
216        move |result: DebounceEventResult| {
217            match result {
218                Ok(events) => {
219                    for event in events {
220                        for path in &event.paths {
221                            if let Some(watcher_event) = classify_path(
222                                path,
223                                &cb_catalog_dir,
224                                cb_registry.as_deref(),
225                                &cb_workspace_paths,
226                            ) {
227                                // Use try_send to avoid blocking the notify
228                                // callback thread (Req 1.7).
229                                if cb_tx.try_send(watcher_event).is_err() {
230                                    debug!("watcher channel full or closed, dropping event");
231                                }
232                            }
233                        }
234                    }
235                }
236                Err(errors) => {
237                    for e in errors {
238                        warn!("filesystem watcher error: {e}");
239                    }
240                }
241            }
242        },
243    )
244    .map_err(|e| TuiError::LogDestination {
245        path: catalog_dir.display().to_string(),
246        reason: e.to_string(),
247    })?;
248
249    let debouncer_arc = Arc::new(Mutex::new(debouncer));
250
251    // Establish initial watches.
252    {
253        let mut guard = debouncer_arc.lock().map_err(|e| TuiError::LogDestination {
254            path: "watcher-mutex".to_string(),
255            reason: e.to_string(),
256        })?;
257        if catalog_dir.exists() {
258            guard
259                .watch(catalog_dir, RecursiveMode::Recursive)
260                .map_err(|e| TuiError::LogDestination {
261                    path: catalog_dir.display().to_string(),
262                    reason: e.to_string(),
263                })?;
264        } else {
265            warn!(
266                path = %catalog_dir.display(),
267                "catalog dir does not exist at watcher startup; will retry"
268            );
269        }
270
271        if let Some(reg) = registry_path {
272            // Watch parent dir (non-recursive) to catch atomic/rename writes.
273            if let Some(parent) = reg.parent() {
274                if parent.exists() {
275                    if let Err(e) = guard.watch(parent, RecursiveMode::NonRecursive) {
276                        warn!(
277                            path = %parent.display(),
278                            "could not watch registry parent dir: {e}"
279                        );
280                    }
281                }
282            }
283        }
284
285        for ws in workspace_paths {
286            if ws.exists() {
287                if let Err(e) = guard.watch(ws, RecursiveMode::Recursive) {
288                    warn!(path = %ws.display(), "could not watch workspace path: {e}");
289                }
290            } else {
291                warn!(
292                    path = %ws.display(),
293                    "workspace path does not exist at watcher startup; will retry"
294                );
295            }
296        }
297    }
298
299    let handle = WatcherHandle {
300        debouncer: debouncer_arc.clone(),
301        config: config.clone(),
302    };
303
304    // ── Background re-establish task (Req 1.4 / 25.5) ────────────────────────
305    let reestablish_handle = WatcherHandle {
306        debouncer: debouncer_arc,
307        config,
308    };
309    tokio::spawn(async move {
310        loop {
311            tokio::time::sleep(REESTABLISH_INTERVAL).await;
312            reestablish_handle.reestablish();
313        }
314    });
315
316    Ok((handle, rx))
317}
318
319// ── Tests ─────────────────────────────────────────────────────────────────────
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::time::Duration;
324    use tempfile::TempDir;
325    use tokio::time::timeout;
326
327    // ── Pure classification tests (no filesystem, no timing) ─────────────────
328
329    #[test]
330    fn classify_catalog_file() {
331        let cat = PathBuf::from("/tmp/catalog");
332        let changed = cat.join("agents.json");
333        let result = classify_path(&changed, &cat, None, &[]);
334        assert_eq!(result, Some(WatcherEvent::Catalog(changed)));
335    }
336
337    #[test]
338    fn classify_registry_path_exact() {
339        let cat = PathBuf::from("/tmp/catalog");
340        let reg = PathBuf::from("/home/user/.config/vfa/workspaces.toml");
341        let result = classify_path(&reg, &cat, Some(&reg), &[]);
342        assert_eq!(result, Some(WatcherEvent::Registry));
343    }
344
345    #[test]
346    fn classify_workspace_path() {
347        let cat = PathBuf::from("/tmp/catalog");
348        let ws = PathBuf::from("/home/user/repos/my-app");
349        let changed = ws.join(".claude").join("agents.md");
350        let result = classify_path(&changed, &cat, None, &[ws]);
351        assert_eq!(result, Some(WatcherEvent::Workspace(changed)));
352    }
353
354    #[test]
355    fn classify_unrelated_path_is_none() {
356        let cat = PathBuf::from("/tmp/catalog");
357        let unrelated = PathBuf::from("/etc/hosts");
358        let result = classify_path(&unrelated, &cat, None, &[]);
359        assert!(result.is_none());
360    }
361
362    #[test]
363    fn classify_registry_wins_over_workspace() {
364        // If the registry file happens to live under a workspace path,
365        // Registry classification should win.
366        let cat = PathBuf::from("/tmp/catalog");
367        let ws = PathBuf::from("/home/user");
368        let reg = ws.join(".config/vfa/workspaces.toml");
369        let result = classify_path(&reg, &cat, Some(&reg), &[ws]);
370        assert_eq!(result, Some(WatcherEvent::Registry));
371    }
372
373    // ── End-to-end smoke tests (filesystem + tokio, timing-tolerant) ─────────
374    //
375    // NOTE: inotify on Linux should be reliable for these basic operations.
376    // We use generous 3-second timeouts and test only the most fundamental
377    // behavior to avoid CI flakiness from scheduling jitter.
378
379    /// Helper: receive the next event with a timeout, draining duplicates.
380    async fn recv_event(
381        rx: &mut mpsc::Receiver<WatcherEvent>,
382        timeout_dur: Duration,
383    ) -> Option<WatcherEvent> {
384        timeout(timeout_dur, rx.recv()).await.ok().flatten()
385    }
386
387    #[tokio::test]
388    async fn e2e_catalog_file_modify_emits_event() {
389        let dir = TempDir::new().unwrap();
390        let catalog_dir = dir.path().join("catalog");
391        std::fs::create_dir_all(&catalog_dir).unwrap();
392        let file = catalog_dir.join("agents.json");
393        std::fs::write(&file, b"[]").unwrap();
394
395        let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
396
397        // Give notify time to set up the inotify watch before writing.
398        tokio::time::sleep(Duration::from_millis(100)).await;
399
400        std::fs::write(&file, b"[{}]").unwrap();
401
402        // Wait up to 3 s for the debounced event (debounce = 500 ms, so allow
403        // 500 ms + generous headroom for scheduling).
404        let event = recv_event(&mut rx, Duration::from_secs(3)).await;
405        assert!(
406            matches!(event, Some(WatcherEvent::Catalog(_))),
407            "expected Catalog event, got: {event:?}"
408        );
409    }
410
411    #[tokio::test]
412    async fn e2e_write_to_temp_then_rename_emits_event() {
413        // Req 1.2: editors that use atomic write (write tmp → rename) must
414        // still trigger an event.  notify-debouncer-full coalesces the
415        // Rename(From) + Rename(To) pair into a single event for the final
416        // path.
417        let dir = TempDir::new().unwrap();
418        let catalog_dir = dir.path().join("catalog");
419        std::fs::create_dir_all(&catalog_dir).unwrap();
420        let target = catalog_dir.join("agents.json");
421        std::fs::write(&target, b"[]").unwrap();
422
423        let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
424        tokio::time::sleep(Duration::from_millis(100)).await;
425
426        // Atomic-write pattern: write to a sibling tmp file then rename over target.
427        let tmp = catalog_dir.join("agents.json.tmp");
428        std::fs::write(&tmp, b"[{}]").unwrap();
429        std::fs::rename(&tmp, &target).unwrap();
430
431        let event = recv_event(&mut rx, Duration::from_secs(3)).await;
432        assert!(
433            matches!(event, Some(WatcherEvent::Catalog(_))),
434            "expected Catalog event after rename, got: {event:?}"
435        );
436    }
437
438    #[tokio::test]
439    async fn e2e_registry_file_change_emits_registry_event() {
440        // Req 1.7 / path classification integration: a change to the registry
441        // file must produce WatcherEvent::Registry.
442        let dir = TempDir::new().unwrap();
443        let catalog_dir = dir.path().join("catalog");
444        std::fs::create_dir_all(&catalog_dir).unwrap();
445
446        let reg_dir = dir.path().join("config");
447        std::fs::create_dir_all(&reg_dir).unwrap();
448        let reg_file = reg_dir.join("workspaces.toml");
449        std::fs::write(&reg_file, b"").unwrap();
450
451        let (_handle, mut rx) = spawn_watcher(&catalog_dir, Some(&reg_file), &[]).unwrap();
452        tokio::time::sleep(Duration::from_millis(100)).await;
453
454        std::fs::write(&reg_file, b"# updated").unwrap();
455
456        let event = recv_event(&mut rx, Duration::from_secs(3)).await;
457        assert!(
458            matches!(event, Some(WatcherEvent::Registry)),
459            "expected Registry event, got: {event:?}"
460        );
461    }
462
463    #[tokio::test]
464    async fn e2e_debounce_rapid_writes_do_not_flood() {
465        // Req 1.6: at most one event per file per 500 ms.
466        // We perform N rapid writes and assert we receive >= 1 but a
467        // reasonable bound (not N) of events within the window.
468        let dir = TempDir::new().unwrap();
469        let catalog_dir = dir.path().join("catalog");
470        std::fs::create_dir_all(&catalog_dir).unwrap();
471        let file = catalog_dir.join("agents.json");
472        std::fs::write(&file, b"[]").unwrap();
473
474        let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
475        tokio::time::sleep(Duration::from_millis(100)).await;
476
477        // Rapid-fire 10 writes well inside the 500 ms debounce window.
478        for i in 0u8..10 {
479            std::fs::write(&file, [i]).unwrap();
480        }
481
482        // Drain events for up to 2 s (one full debounce period + headroom).
483        let mut count = 0usize;
484        let drain_deadline = Duration::from_secs(2);
485        let per_poll = Duration::from_millis(100);
486        let polls = drain_deadline.as_millis() / per_poll.as_millis();
487        for _ in 0..polls {
488            if let Ok(Some(_)) = timeout(per_poll, rx.recv()).await {
489                count += 1;
490            }
491        }
492
493        // We should have received at least 1 event (debouncer did fire) and
494        // strictly fewer than one-per-write (coalescing occurred). The exact
495        // count is timing-dependent — on a loaded CI runner the write loop can
496        // straddle multiple 500 ms windows — so the robust invariant is "fewer
497        // than the 10 writes", not a tight magic number. A removed/broken
498        // debouncer would deliver ~one event per write (≈10).
499        assert!(count >= 1, "expected at least 1 debounced event, got 0");
500        assert!(
501            count < 10,
502            "expected debouncing (coalesced < 10): got {count} events for 10 rapid writes"
503        );
504    }
505}