use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
};
use notify_debouncer_full::{
new_debouncer, notify::RecursiveMode, DebounceEventResult, Debouncer, RecommendedCache,
};
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::error::TuiError;
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(500);
const REESTABLISH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatcherEvent {
Catalog(PathBuf),
Registry,
Workspace(PathBuf),
}
#[derive(Clone)]
pub struct WatchConfig {
pub catalog_dir: PathBuf,
pub registry_path: Option<PathBuf>,
pub workspace_paths: Vec<PathBuf>,
}
pub struct WatcherHandle {
debouncer:
Arc<Mutex<Debouncer<notify_debouncer_full::notify::RecommendedWatcher, RecommendedCache>>>,
config: WatchConfig,
}
impl WatcherHandle {
pub fn reestablish(&self) {
let mut guard = match self.debouncer.lock() {
Ok(g) => g,
Err(e) => {
warn!("watcher mutex poisoned during reestablish: {e}");
return;
}
};
let cfg = &self.config;
if cfg.catalog_dir.exists() {
if let Err(e) = guard.watch(&cfg.catalog_dir, RecursiveMode::Recursive) {
warn!(
path = %cfg.catalog_dir.display(),
"failed to re-watch catalog dir: {e}"
);
} else {
debug!(path = %cfg.catalog_dir.display(), "re-established catalog watch");
}
}
if let Some(reg) = &cfg.registry_path {
if reg.exists() {
if let Some(parent) = reg.parent() {
if let Err(e) = guard.watch(parent, RecursiveMode::NonRecursive) {
warn!(
path = %parent.display(),
"failed to re-watch registry parent: {e}"
);
} else {
debug!(path = %parent.display(), "re-established registry watch");
}
}
}
}
for ws in &cfg.workspace_paths {
if ws.exists() {
if let Err(e) = guard.watch(ws, RecursiveMode::Recursive) {
warn!(
path = %ws.display(),
"failed to re-watch workspace path: {e}"
);
} else {
debug!(path = %ws.display(), "re-established workspace watch");
}
}
}
}
pub fn shutdown(self) {
match Arc::try_unwrap(self.debouncer) {
Ok(mutex) => {
if let Ok(debouncer) = mutex.into_inner() {
debouncer.stop_nonblocking();
}
}
Err(_arc) => {
debug!("watcher shutdown deferred – re-establish task still active");
}
}
}
}
pub fn classify_path(
changed: &Path,
catalog_dir: &Path,
registry_path: Option<&Path>,
workspace_paths: &[PathBuf],
) -> Option<WatcherEvent> {
if let Some(reg) = registry_path {
if changed == reg {
return Some(WatcherEvent::Registry);
}
if changed.parent() == reg.parent() && changed.file_name() == reg.file_name() {
return Some(WatcherEvent::Registry);
}
}
if changed.starts_with(catalog_dir) {
return Some(WatcherEvent::Catalog(changed.to_path_buf()));
}
for ws in workspace_paths {
if changed.starts_with(ws) {
return Some(WatcherEvent::Workspace(changed.to_path_buf()));
}
}
None
}
pub fn spawn_watcher(
catalog_dir: &Path,
registry_path: Option<&Path>,
workspace_paths: &[PathBuf],
) -> Result<(WatcherHandle, mpsc::Receiver<WatcherEvent>), TuiError> {
let (tx, rx) = mpsc::channel::<WatcherEvent>(256);
let config = WatchConfig {
catalog_dir: catalog_dir.to_path_buf(),
registry_path: registry_path.map(|p| p.to_path_buf()),
workspace_paths: workspace_paths.to_vec(),
};
let cb_catalog_dir = config.catalog_dir.clone();
let cb_registry = config.registry_path.clone();
let cb_workspace_paths = config.workspace_paths.clone();
let cb_tx = tx.clone();
let debouncer = new_debouncer(
DEBOUNCE_TIMEOUT,
None, move |result: DebounceEventResult| {
match result {
Ok(events) => {
for event in events {
for path in &event.paths {
if let Some(watcher_event) = classify_path(
path,
&cb_catalog_dir,
cb_registry.as_deref(),
&cb_workspace_paths,
) {
if cb_tx.try_send(watcher_event).is_err() {
debug!("watcher channel full or closed, dropping event");
}
}
}
}
}
Err(errors) => {
for e in errors {
warn!("filesystem watcher error: {e}");
}
}
}
},
)
.map_err(|e| TuiError::LogDestination {
path: catalog_dir.display().to_string(),
reason: e.to_string(),
})?;
let debouncer_arc = Arc::new(Mutex::new(debouncer));
{
let mut guard = debouncer_arc.lock().map_err(|e| TuiError::LogDestination {
path: "watcher-mutex".to_string(),
reason: e.to_string(),
})?;
if catalog_dir.exists() {
guard
.watch(catalog_dir, RecursiveMode::Recursive)
.map_err(|e| TuiError::LogDestination {
path: catalog_dir.display().to_string(),
reason: e.to_string(),
})?;
} else {
warn!(
path = %catalog_dir.display(),
"catalog dir does not exist at watcher startup; will retry"
);
}
if let Some(reg) = registry_path {
if let Some(parent) = reg.parent() {
if parent.exists() {
if let Err(e) = guard.watch(parent, RecursiveMode::NonRecursive) {
warn!(
path = %parent.display(),
"could not watch registry parent dir: {e}"
);
}
}
}
}
for ws in workspace_paths {
if ws.exists() {
if let Err(e) = guard.watch(ws, RecursiveMode::Recursive) {
warn!(path = %ws.display(), "could not watch workspace path: {e}");
}
} else {
warn!(
path = %ws.display(),
"workspace path does not exist at watcher startup; will retry"
);
}
}
}
let handle = WatcherHandle {
debouncer: debouncer_arc.clone(),
config: config.clone(),
};
let reestablish_handle = WatcherHandle {
debouncer: debouncer_arc,
config,
};
tokio::spawn(async move {
loop {
tokio::time::sleep(REESTABLISH_INTERVAL).await;
reestablish_handle.reestablish();
}
});
Ok((handle, rx))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
#[test]
fn classify_catalog_file() {
let cat = PathBuf::from("/tmp/catalog");
let changed = cat.join("agents.json");
let result = classify_path(&changed, &cat, None, &[]);
assert_eq!(result, Some(WatcherEvent::Catalog(changed)));
}
#[test]
fn classify_registry_path_exact() {
let cat = PathBuf::from("/tmp/catalog");
let reg = PathBuf::from("/home/user/.config/vfa/workspaces.toml");
let result = classify_path(®, &cat, Some(®), &[]);
assert_eq!(result, Some(WatcherEvent::Registry));
}
#[test]
fn classify_workspace_path() {
let cat = PathBuf::from("/tmp/catalog");
let ws = PathBuf::from("/home/user/repos/my-app");
let changed = ws.join(".claude").join("agents.md");
let result = classify_path(&changed, &cat, None, &[ws]);
assert_eq!(result, Some(WatcherEvent::Workspace(changed)));
}
#[test]
fn classify_unrelated_path_is_none() {
let cat = PathBuf::from("/tmp/catalog");
let unrelated = PathBuf::from("/etc/hosts");
let result = classify_path(&unrelated, &cat, None, &[]);
assert!(result.is_none());
}
#[test]
fn classify_registry_wins_over_workspace() {
let cat = PathBuf::from("/tmp/catalog");
let ws = PathBuf::from("/home/user");
let reg = ws.join(".config/vfa/workspaces.toml");
let result = classify_path(®, &cat, Some(®), &[ws]);
assert_eq!(result, Some(WatcherEvent::Registry));
}
async fn recv_event(
rx: &mut mpsc::Receiver<WatcherEvent>,
timeout_dur: Duration,
) -> Option<WatcherEvent> {
timeout(timeout_dur, rx.recv()).await.ok().flatten()
}
#[tokio::test]
async fn e2e_catalog_file_modify_emits_event() {
let dir = TempDir::new().unwrap();
let catalog_dir = dir.path().join("catalog");
std::fs::create_dir_all(&catalog_dir).unwrap();
let file = catalog_dir.join("agents.json");
std::fs::write(&file, b"[]").unwrap();
let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
std::fs::write(&file, b"[{}]").unwrap();
let event = recv_event(&mut rx, Duration::from_secs(3)).await;
assert!(
matches!(event, Some(WatcherEvent::Catalog(_))),
"expected Catalog event, got: {event:?}"
);
}
#[tokio::test]
async fn e2e_write_to_temp_then_rename_emits_event() {
let dir = TempDir::new().unwrap();
let catalog_dir = dir.path().join("catalog");
std::fs::create_dir_all(&catalog_dir).unwrap();
let target = catalog_dir.join("agents.json");
std::fs::write(&target, b"[]").unwrap();
let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let tmp = catalog_dir.join("agents.json.tmp");
std::fs::write(&tmp, b"[{}]").unwrap();
std::fs::rename(&tmp, &target).unwrap();
let event = recv_event(&mut rx, Duration::from_secs(3)).await;
assert!(
matches!(event, Some(WatcherEvent::Catalog(_))),
"expected Catalog event after rename, got: {event:?}"
);
}
#[tokio::test]
async fn e2e_registry_file_change_emits_registry_event() {
let dir = TempDir::new().unwrap();
let catalog_dir = dir.path().join("catalog");
std::fs::create_dir_all(&catalog_dir).unwrap();
let reg_dir = dir.path().join("config");
std::fs::create_dir_all(®_dir).unwrap();
let reg_file = reg_dir.join("workspaces.toml");
std::fs::write(®_file, b"").unwrap();
let (_handle, mut rx) = spawn_watcher(&catalog_dir, Some(®_file), &[]).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
std::fs::write(®_file, b"# updated").unwrap();
let event = recv_event(&mut rx, Duration::from_secs(3)).await;
assert!(
matches!(event, Some(WatcherEvent::Registry)),
"expected Registry event, got: {event:?}"
);
}
#[tokio::test]
async fn e2e_debounce_rapid_writes_do_not_flood() {
let dir = TempDir::new().unwrap();
let catalog_dir = dir.path().join("catalog");
std::fs::create_dir_all(&catalog_dir).unwrap();
let file = catalog_dir.join("agents.json");
std::fs::write(&file, b"[]").unwrap();
let (_handle, mut rx) = spawn_watcher(&catalog_dir, None, &[]).unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
for i in 0u8..10 {
std::fs::write(&file, [i]).unwrap();
}
let mut count = 0usize;
let drain_deadline = Duration::from_secs(2);
let per_poll = Duration::from_millis(100);
let polls = drain_deadline.as_millis() / per_poll.as_millis();
for _ in 0..polls {
if let Ok(Some(_)) = timeout(per_poll, rx.recv()).await {
count += 1;
}
}
assert!(count >= 1, "expected at least 1 debounced event, got 0");
assert!(
count < 10,
"expected debouncing (coalesced < 10): got {count} events for 10 rapid writes"
);
}
}