use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use crate::knowledge::{FileChange, KnowledgeBase};
enum Cmd {
Event(PathBuf),
Shutdown,
}
pub struct WatchGuard {
tx: mpsc::Sender<Cmd>,
handle: Option<JoinHandle<()>>,
}
impl Drop for WatchGuard {
fn drop(&mut self) {
let _ = self.tx.send(Cmd::Shutdown);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl KnowledgeBase {
pub fn watch(self: &Arc<Self>, settle: Duration) -> Result<WatchGuard> {
if settle.is_zero() {
anyhow::bail!("watch settle window must be non-zero");
}
let root = self.root();
let (tx, rx) = mpsc::channel::<Cmd>();
let event_tx = tx.clone();
let mut watcher: RecommendedWatcher =
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
match res {
Ok(ev) => {
for path in ev.paths {
let _ = event_tx.send(Cmd::Event(path));
}
}
Err(e) => tracing::debug!(error = %e, "watch: fs event error"),
}
})
.context("create fs watcher")?;
watcher
.watch(&root, RecursiveMode::Recursive)
.with_context(|| format!("watch vault root {}", root.display()))?;
let kb = Arc::clone(self);
let handle = std::thread::Builder::new()
.name("kb-watch".into())
.spawn(move || debounce_loop(kb, root, watcher, rx, settle))
.context("spawn watcher thread")?;
Ok(WatchGuard {
tx,
handle: Some(handle),
})
}
}
fn debounce_loop(
kb: Arc<KnowledgeBase>,
root: PathBuf,
_watcher: RecommendedWatcher,
rx: mpsc::Receiver<Cmd>,
settle: Duration,
) {
let poll = (settle / 4).max(Duration::from_millis(1));
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
loop {
match rx.recv_timeout(poll) {
Ok(Cmd::Event(path)) => {
pending.insert(path, Instant::now());
while let Ok(cmd) = rx.try_recv() {
match cmd {
Cmd::Event(path) => {
pending.insert(path, Instant::now());
}
Cmd::Shutdown => return,
}
}
}
Ok(Cmd::Shutdown) => return,
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return,
}
let now = Instant::now();
let due: Vec<PathBuf> = pending
.iter()
.filter(|(_, seen)| now.duration_since(**seen) >= settle)
.map(|(path, _)| path.clone())
.collect();
for path in due {
pending.remove(&path);
handle_settled(&kb, &root, &path);
}
}
}
fn handle_settled(kb: &KnowledgeBase, root: &Path, path: &Path) {
let Some(rel) = rel_path(root, path) else {
tracing::debug!(path = ?path, "watch: event outside vault root; ignored");
return;
};
if path.extension().is_none_or(|ext| ext != "md") {
tracing::debug!(path = %rel, "watch: non-markdown path; ignored");
return;
}
if path.exists() {
tracing::debug!(path = %rel, "watch: reindexing externally changed note");
match kb.reindex_one(&rel) {
Ok(()) => kb.notify_change(&rel, FileChange::Updated(rel.clone())),
Err(e) => tracing::warn!(path = %rel, error = %e, "watch: reindex failed; skipped"),
}
} else {
tracing::debug!(path = %rel, "watch: externally deleted note");
kb.forget_file(&rel);
kb.notify_change(&rel, FileChange::Deleted(rel.clone()));
}
}
fn rel_path(root: &Path, path: &Path) -> Option<String> {
if let Ok(rel) = path.strip_prefix(root) {
return Some(to_posix(rel));
}
let parent = path.parent()?.canonicalize().ok()?;
let name = path.file_name()?;
let canon_root = root.canonicalize().ok()?;
parent
.join(name)
.strip_prefix(canon_root)
.ok()
.map(to_posix)
}
fn to_posix(rel: &Path) -> String {
rel.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use crate::knowledge::{FileChange, KnowledgeBase};
fn wait_until<F: Fn() -> bool>(cond: F) -> bool {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
false
}
#[test]
fn external_write_refreshes_index_and_fires_callbacks() {
let dir = std::env::temp_dir().join(format!("test-watch-{}", uuid::Uuid::new_v4()));
let kb = Arc::new(KnowledgeBase::new(dir.clone()).unwrap());
kb.note_write("Target.md", "# Target").unwrap();
kb.index_all().unwrap();
let deleted = Arc::new(AtomicUsize::new(0));
let d = deleted.clone();
kb.on_file_change(move |_path, change| {
if matches!(change, FileChange::Deleted(_)) {
d.fetch_add(1, Ordering::SeqCst);
}
});
let guard = kb.watch(Duration::from_millis(50)).unwrap();
std::fs::write(
dir.join("ext.md"),
"---\nid: e\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n[[Target]]",
)
.unwrap();
assert!(
wait_until(|| !kb.backlinks_for("Target.md").is_empty()),
"external write never reindexed"
);
std::fs::remove_file(dir.join("ext.md")).unwrap();
assert!(
wait_until(|| deleted.load(Ordering::SeqCst) > 0),
"external delete never notified"
);
assert!(
wait_until(|| kb.backlinks_for("Target.md").is_empty()),
"deleted note never dropped from the index"
);
drop(guard); }
}