1use 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
29const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
31const REESTABLISH_INTERVAL: Duration = Duration::from_secs(30);
33
34#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum WatcherEvent {
39 Catalog(PathBuf),
41 Registry,
43 Workspace(PathBuf),
45}
46
47#[derive(Clone)]
49pub struct WatchConfig {
50 pub catalog_dir: PathBuf,
51 pub registry_path: Option<PathBuf>,
52 pub workspace_paths: Vec<PathBuf>,
53}
54
55pub struct WatcherHandle {
58 debouncer:
61 Arc<Mutex<Debouncer<notify_debouncer_full::notify::RecommendedWatcher, RecommendedCache>>>,
62 config: WatchConfig,
63}
64
65impl WatcherHandle {
66 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 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 if let Some(reg) = &cfg.registry_path {
95 if reg.exists() {
96 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 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 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 debug!("watcher shutdown deferred – re-establish task still active");
137 }
138 }
139 }
140}
141
142pub fn classify_path(
149 changed: &Path,
150 catalog_dir: &Path,
151 registry_path: Option<&Path>,
152 workspace_paths: &[PathBuf],
153) -> Option<WatcherEvent> {
154 if let Some(reg) = registry_path {
156 if changed == reg {
157 return Some(WatcherEvent::Registry);
158 }
159 if changed.parent() == reg.parent() && changed.file_name() == reg.file_name() {
162 return Some(WatcherEvent::Registry);
163 }
164 }
165
166 if changed.starts_with(catalog_dir) {
168 return Some(WatcherEvent::Catalog(changed.to_path_buf()));
169 }
170
171 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
181pub fn spawn_watcher(
194 catalog_dir: &Path,
195 registry_path: Option<&Path>,
196 workspace_paths: &[PathBuf],
197) -> Result<(WatcherHandle, mpsc::Receiver<WatcherEvent>), TuiError> {
198 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 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, 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 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 {
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 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 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#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::time::Duration;
324 use tempfile::TempDir;
325 use tokio::time::timeout;
326
327 #[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(®, &cat, Some(®), &[]);
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 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(®, &cat, Some(®), &[ws]);
370 assert_eq!(result, Some(WatcherEvent::Registry));
371 }
372
373 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 tokio::time::sleep(Duration::from_millis(100)).await;
399
400 std::fs::write(&file, b"[{}]").unwrap();
401
402 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 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 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 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(®_dir).unwrap();
448 let reg_file = reg_dir.join("workspaces.toml");
449 std::fs::write(®_file, b"").unwrap();
450
451 let (_handle, mut rx) = spawn_watcher(&catalog_dir, Some(®_file), &[]).unwrap();
452 tokio::time::sleep(Duration::from_millis(100)).await;
453
454 std::fs::write(®_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 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 for i in 0u8..10 {
479 std::fs::write(&file, [i]).unwrap();
480 }
481
482 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 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}