Skip to main content

oxios_markdown/
watch.rs

1//! Vault watcher — keeps the knowledge index in sync with external edits.
2//!
3//! External editors (oximemo, Obsidian, vim) write directly into the
4//! vault directory, bypassing [`KnowledgeBase`] methods entirely.
5//! [`KnowledgeBase::watch`] runs a `notify` watcher on the vault root
6//! and, after a per-path debounce settle window, re-reads settled files
7//! into the backlink index and fires [`crate::knowledge::FileChange`]
8//! callbacks so channels (e.g. the semantic index) stay current.
9//!
10//! Invariants:
11//! - **Read-only with respect to the vault** — the watcher never writes,
12//!   renames, or deletes the files it watches; it only re-reads them.
13//! - **Never crashes on a bad file** — read failures (permissions,
14//!   malformed content, races with a concurrent delete) are logged at
15//!   `warn` and skipped, not propagated.
16
17use 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
29/// Commands consumed by the debounce thread.
30enum Cmd {
31    /// A filesystem event for a path under the vault root.
32    Event(PathBuf),
33    /// Stop the debounce loop; sent by [`WatchGuard::drop`].
34    Shutdown,
35}
36
37/// Handle for a running vault watcher.
38///
39/// Dropping the guard stops the fs watcher and joins the debounce
40/// thread. Events already settled may still fire callbacks before the
41/// drop returns; nothing fires after it.
42pub struct WatchGuard {
43    tx: mpsc::Sender<Cmd>,
44    handle: Option<JoinHandle<()>>,
45}
46
47impl Drop for WatchGuard {
48    fn drop(&mut self) {
49        // A send error means the thread is already gone — nothing to stop.
50        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    /// Watch the vault root for external changes and reindex settled notes.
59    ///
60    /// `settle` is the debounce window, parameterizable per caller: a
61    /// path is processed only after no further events have been
62    /// observed for it within the window (editors emit bursts of
63    /// create/write/rename events per save). A settled path that exists
64    /// is re-read into the backlink index and reported as
65    /// [`FileChange::Updated`]; a settled path that is gone is dropped
66    /// from the index and reported as [`FileChange::Deleted`].
67    ///
68    /// Self-writes by oxios itself also surface here and re-fire
69    /// callbacks; that double-fire is tolerated at debug level and
70    /// absorbed by downstream dedup (I-3).
71    ///
72    /// The returned [`WatchGuard`] keeps the watcher alive. This takes
73    /// `&Arc<Self>` because the debounce thread owns a clone for the
74    /// lifetime of the watch.
75    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                            // Send failure = guard dropped; drop the event.
88                            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
111/// Debounce loop: track the last event time per path
112/// (`HashMap<PathBuf, Instant>`), and process a path once it has been
113/// quiet for `settle`.
114fn debounce_loop(
115    kb: Arc<KnowledgeBase>,
116    root: PathBuf,
117    // Keep `watcher` alive for the life of the loop: dropping it stops fs events.
118    _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                // Drain any burst already queued behind the first event.
129                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
155/// Re-read one settled path into the index and notify callbacks.
156///
157/// Failures (unreadable file, containment rejection) are warned and
158/// skipped — the watcher never panics and never exits on a bad file.
159fn 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
181/// Convert an absolute event path to a vault-relative POSIX path.
182///
183/// FSEvents (macOS) canonicalizes paths (`/var` → `/private/var`), so a
184/// plain `strip_prefix` against the watch root can miss; retry through
185/// the canonicalized parent directory before giving up.
186fn 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
200/// Render a relative path with `/` separators (KB path convention).
201fn 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    /// Poll `cond` every 25 ms until it returns true or 5 s elapse
214    /// (generous timeout: fs event delivery is inherently timing-based).
215    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); // watcher stops, debounce thread joins
261    }
262}