1use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::mpsc::{self, RecvTimeoutError};
21use std::thread::JoinHandle;
22use std::time::{Duration, Instant};
23
24use anyhow::{Context, Result};
25use notify::{RecommendedWatcher, RecursiveMode, Watcher};
26
27use crate::knowledge::{FileChange, KnowledgeBase};
28
29enum Cmd {
31 Event(PathBuf),
33 Shutdown,
35}
36
37pub struct WatchGuard {
43 tx: mpsc::Sender<Cmd>,
44 handle: Option<JoinHandle<()>>,
45}
46
47impl Drop for WatchGuard {
48 fn drop(&mut self) {
49 let _ = self.tx.send(Cmd::Shutdown);
51 if let Some(handle) = self.handle.take() {
52 let _ = handle.join();
53 }
54 }
55}
56
57impl KnowledgeBase {
58 pub fn watch(self: &Arc<Self>, settle: Duration) -> Result<WatchGuard> {
76 if settle.is_zero() {
77 anyhow::bail!("watch settle window must be non-zero");
78 }
79 let root = self.root();
80 let (tx, rx) = mpsc::channel::<Cmd>();
81 let event_tx = tx.clone();
82 let mut watcher: RecommendedWatcher =
83 notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
84 match res {
85 Ok(ev) => {
86 for path in ev.paths {
87 let _ = event_tx.send(Cmd::Event(path));
89 }
90 }
91 Err(e) => tracing::debug!(error = %e, "watch: fs event error"),
92 }
93 })
94 .context("create fs watcher")?;
95 watcher
96 .watch(&root, RecursiveMode::Recursive)
97 .with_context(|| format!("watch vault root {}", root.display()))?;
98
99 let kb = Arc::clone(self);
100 let handle = std::thread::Builder::new()
101 .name("kb-watch".into())
102 .spawn(move || debounce_loop(kb, root, watcher, rx, settle))
103 .context("spawn watcher thread")?;
104 Ok(WatchGuard {
105 tx,
106 handle: Some(handle),
107 })
108 }
109}
110
111fn debounce_loop(
115 kb: Arc<KnowledgeBase>,
116 root: PathBuf,
117 _watcher: RecommendedWatcher,
119 rx: mpsc::Receiver<Cmd>,
120 settle: Duration,
121) {
122 let poll = (settle / 4).max(Duration::from_millis(1));
123 let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
124 loop {
125 match rx.recv_timeout(poll) {
126 Ok(Cmd::Event(path)) => {
127 pending.insert(path, Instant::now());
128 while let Ok(cmd) = rx.try_recv() {
130 match cmd {
131 Cmd::Event(path) => {
132 pending.insert(path, Instant::now());
133 }
134 Cmd::Shutdown => return,
135 }
136 }
137 }
138 Ok(Cmd::Shutdown) => return,
139 Err(RecvTimeoutError::Timeout) => {}
140 Err(RecvTimeoutError::Disconnected) => return,
141 }
142 let now = Instant::now();
143 let due: Vec<PathBuf> = pending
144 .iter()
145 .filter(|(_, seen)| now.duration_since(**seen) >= settle)
146 .map(|(path, _)| path.clone())
147 .collect();
148 for path in due {
149 pending.remove(&path);
150 handle_settled(&kb, &root, &path);
151 }
152 }
153}
154
155fn handle_settled(kb: &KnowledgeBase, root: &Path, path: &Path) {
160 let Some(rel) = rel_path(root, path) else {
161 tracing::debug!(path = ?path, "watch: event outside vault root; ignored");
162 return;
163 };
164 if path.extension().is_none_or(|ext| ext != "md") {
165 tracing::debug!(path = %rel, "watch: non-markdown path; ignored");
166 return;
167 }
168 if path.exists() {
169 tracing::debug!(path = %rel, "watch: reindexing externally changed note");
170 match kb.reindex_one(&rel) {
171 Ok(()) => kb.notify_change(&rel, FileChange::Updated(rel.clone())),
172 Err(e) => tracing::warn!(path = %rel, error = %e, "watch: reindex failed; skipped"),
173 }
174 } else {
175 tracing::debug!(path = %rel, "watch: externally deleted note");
176 kb.forget_file(&rel);
177 kb.notify_change(&rel, FileChange::Deleted(rel.clone()));
178 }
179}
180
181fn rel_path(root: &Path, path: &Path) -> Option<String> {
187 if let Ok(rel) = path.strip_prefix(root) {
188 return Some(to_posix(rel));
189 }
190 let parent = path.parent()?.canonicalize().ok()?;
191 let name = path.file_name()?;
192 let canon_root = root.canonicalize().ok()?;
193 parent
194 .join(name)
195 .strip_prefix(canon_root)
196 .ok()
197 .map(to_posix)
198}
199
200fn to_posix(rel: &Path) -> String {
202 rel.to_string_lossy().replace('\\', "/")
203}
204
205#[cfg(test)]
206mod tests {
207 use std::sync::Arc;
208 use std::sync::atomic::{AtomicUsize, Ordering};
209 use std::time::{Duration, Instant};
210
211 use crate::knowledge::{FileChange, KnowledgeBase};
212
213 fn wait_until<F: Fn() -> bool>(cond: F) -> bool {
216 let deadline = Instant::now() + Duration::from_secs(5);
217 while Instant::now() < deadline {
218 if cond() {
219 return true;
220 }
221 std::thread::sleep(Duration::from_millis(25));
222 }
223 false
224 }
225
226 #[test]
227 fn external_write_refreshes_index_and_fires_callbacks() {
228 let dir = std::env::temp_dir().join(format!("test-watch-{}", uuid::Uuid::new_v4()));
229 let kb = Arc::new(KnowledgeBase::new(dir.clone()).unwrap());
230 kb.note_write("Target.md", "# Target").unwrap();
231 kb.index_all().unwrap();
232
233 let deleted = Arc::new(AtomicUsize::new(0));
234 let d = deleted.clone();
235 kb.on_file_change(move |_path, change| {
236 if matches!(change, FileChange::Deleted(_)) {
237 d.fetch_add(1, Ordering::SeqCst);
238 }
239 });
240
241 let guard = kb.watch(Duration::from_millis(50)).unwrap();
242 std::fs::write(
243 dir.join("ext.md"),
244 "---\nid: e\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n[[Target]]",
245 )
246 .unwrap();
247 assert!(
248 wait_until(|| !kb.backlinks_for("Target.md").is_empty()),
249 "external write never reindexed"
250 );
251 std::fs::remove_file(dir.join("ext.md")).unwrap();
252 assert!(
253 wait_until(|| deleted.load(Ordering::SeqCst) > 0),
254 "external delete never notified"
255 );
256 assert!(
257 wait_until(|| kb.backlinks_for("Target.md").is_empty()),
258 "deleted note never dropped from the index"
259 );
260 drop(guard); }
262}