Skip to main content

kintsugi_daemon/
watch.rs

1//! Filesystem-watcher backstop.
2//!
3//! Records FS changes even from actions that dodged the hook/shim/MCP, so the
4//! timeline and `kintsugi undo` stay complete — the honest guarantee is "nothing is
5//! unrecoverable", not "nothing runs un-warned". Observations are sent to the
6//! daemon over IPC so its single writer keeps the hash chain intact (never a
7//! second concurrent writer racing on `prev_hash`).
8//!
9//! On by default for the work tree (via `kintsugi init`); also `kintsugi watch <path>`.
10//!
11//! Scope is deliberately narrow so the append-only log stays signal, not noise:
12//! the backstop records the *destructive* filesystem changes it exists to catch —
13//! deletions and renames/moves — and **not** every file create or save (which a
14//! normal edit/build storm produces by the thousand, and which interception +
15//! snapshots already cover for agent writes). It also skips well-known build /
16//! VCS / editor-scratch paths entirely.
17
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21use notify::{EventKind, RecursiveMode, Watcher};
22
23use crate::ipc::{Client, Observation};
24
25/// Directory names whose contents are never interesting to the backstop: VCS
26/// internals, dependency/build trees, and tool caches. A change anywhere beneath
27/// one of these is ignored.
28const IGNORED_DIRS: &[&str] = &[
29    ".git",
30    ".hg",
31    ".svn",
32    "node_modules",
33    "target",
34    "dist",
35    "build",
36    ".next",
37    ".nuxt",
38    "__pycache__",
39    ".venv",
40    "venv",
41    ".cache",
42    ".idea",
43    ".vscode",
44    ".mypy_cache",
45    ".pytest_cache",
46    ".gradle",
47    ".terraform",
48    ".DS_Store",
49    // macOS ~/Library + Unity/Xcode churn: renames/removes here are pure OS noise,
50    // not user activity, and otherwise bury real events in the timeline.
51    "Library",
52    "Caches",
53    "DerivedData",
54];
55
56/// Map a notify event kind to a stable label, or `None` to ignore it.
57///
58/// Only deletions and renames are recorded: those are the destructive,
59/// recoverable-or-auditable signals a bypassing actor leaves. Creates and
60/// content modifications are intentionally dropped — they are the bulk of a
61/// working tree's churn (every save, every compiler temp) and would bloat the
62/// append-only log without telling you anything you can act on.
63pub fn kind_label(kind: &EventKind) -> Option<&'static str> {
64    match kind {
65        EventKind::Modify(notify::event::ModifyKind::Name(_)) => Some("renamed"),
66        EventKind::Remove(_) => Some("removed"),
67        _ => None,
68    }
69}
70
71/// Whether a path lives under a build/VCS/cache dir or is an editor scratch file,
72/// so the backstop can skip it. Keeps the log to changes a human would care about.
73pub fn is_ignored(path: &Path) -> bool {
74    use std::path::Component;
75    for c in path.components() {
76        if let Component::Normal(os) = c {
77            if let Some(s) = os.to_str() {
78                if IGNORED_DIRS.contains(&s) {
79                    return true;
80                }
81            }
82        }
83    }
84    // Editor / tool scratch files: vim swap & probe, backups, temp, lockfiles.
85    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
86        if name == ".DS_Store"
87            || name == "4913" // vim's writability probe
88            || name.starts_with(".#") // emacs lock
89            || name.ends_with('~')
90            || name.ends_with(".swp")
91            || name.ends_with(".swx")
92            || name.ends_with(".tmp")
93        {
94            return true;
95        }
96    }
97    false
98}
99
100/// Watch `roots` recursively, forwarding each change to the daemon. Long-running.
101pub fn run(roots: &[PathBuf]) -> Result<()> {
102    if roots.is_empty() {
103        anyhow::bail!("nothing to watch (pass one or more paths)");
104    }
105    let (tx, rx) = std::sync::mpsc::channel();
106    let mut watcher = notify::recommended_watcher(move |res| {
107        let _ = tx.send(res);
108    })
109    .context("create filesystem watcher")?;
110
111    let mut registered = 0usize;
112    for root in roots {
113        match watcher.watch(root, RecursiveMode::Recursive) {
114            Ok(()) => {
115                registered += 1;
116                eprintln!("kintsugi-watch: watching {}", root.display());
117            }
118            // A single root we can't watch is a partial blind spot, not a reason
119            // to abandon the others — record the gap and carry on.
120            Err(e) => record_marker(&format!("cannot watch {}: {e}", root.display())),
121        }
122    }
123    if registered == 0 {
124        anyhow::bail!("could not watch any of the requested paths");
125    }
126
127    for res in rx {
128        match res {
129            Ok(event) => {
130                // The OS dropped events (queue overflow): the backstop missed
131                // changes in this window. Surface it instead of silently losing
132                // coverage — the honest guarantee depends on knowing the gap.
133                if event.need_rescan() {
134                    record_marker("event queue overflow — some changes were not recorded");
135                }
136                forward(&event);
137            }
138            Err(e) => record_marker(&format!("watch error: {e}")),
139        }
140    }
141    Ok(())
142}
143
144/// Surface a backstop degradation: log it and record a `backstop-degraded`
145/// observation so the timeline shows the watcher's coverage was reduced rather
146/// than failing silently.
147fn record_marker(reason: &str) {
148    eprintln!("kintsugi-watch: backstop degraded: {reason}");
149    let obs = Observation {
150        kind: "backstop-degraded".into(),
151        path: reason.into(),
152    };
153    if let Err(e) = Client::observe(&obs) {
154        eprintln!("kintsugi-watch: could not record degradation marker: {e}");
155    }
156}
157
158/// Forward one notify event's interesting paths to the daemon.
159fn forward(event: &notify::Event) {
160    let Some(kind) = kind_label(&event.kind) else {
161        return;
162    };
163    for path in &event.paths {
164        if is_ignored(path) {
165            continue;
166        }
167        let obs = Observation {
168            kind: kind.to_string(),
169            path: path.display().to_string(),
170        };
171        if let Err(e) = Client::observe(&obs) {
172            eprintln!("kintsugi-watch: could not record {}: {e}", path.display());
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use notify::event::{CreateKind, ModifyKind, RemoveKind};
181
182    #[test]
183    fn records_only_destructive_kinds() {
184        // Deletions and renames are the backstop's signal.
185        assert_eq!(
186            kind_label(&EventKind::Remove(RemoveKind::File)),
187            Some("removed")
188        );
189        assert_eq!(
190            kind_label(&EventKind::Modify(ModifyKind::Name(
191                notify::event::RenameMode::Any
192            ))),
193            Some("renamed")
194        );
195        // Creates and content edits are the bulk of churn — intentionally dropped.
196        assert_eq!(kind_label(&EventKind::Create(CreateKind::File)), None);
197        assert_eq!(
198            kind_label(&EventKind::Modify(ModifyKind::Data(
199                notify::event::DataChange::Any
200            ))),
201            None
202        );
203        assert_eq!(
204            kind_label(&EventKind::Access(notify::event::AccessKind::Any)),
205            None
206        );
207    }
208
209    #[test]
210    fn ignores_build_vcs_and_scratch_paths() {
211        assert!(is_ignored(Path::new("/home/u/proj/.git/index")));
212        assert!(is_ignored(Path::new("/home/u/proj/node_modules/x/y.js")));
213        assert!(is_ignored(Path::new("/home/u/proj/target/debug/foo")));
214        assert!(is_ignored(Path::new("/home/u/proj/src/.main.rs.swp")));
215        assert!(is_ignored(Path::new("/home/u/proj/.DS_Store")));
216        assert!(is_ignored(Path::new("/home/u/proj/src/main.rs~")));
217        // macOS OS churn under ~/Library is ignored (pure noise, not user activity).
218        assert!(is_ignored(Path::new(
219            "/Users/x/Library/Preferences/foo.plist"
220        )));
221        assert!(is_ignored(Path::new("/Users/x/Library/Caches/bar")));
222        // A real source file is not ignored.
223        assert!(!is_ignored(Path::new("/home/u/proj/src/main.rs")));
224        assert!(!is_ignored(Path::new("/home/u/proj/data/users.sql")));
225    }
226
227    #[test]
228    fn empty_roots_is_an_error() {
229        assert!(run(&[]).is_err());
230    }
231
232    /// Point the IPC client at a socket that can't exist, so the degradation
233    /// marker's `Client::observe` fails fast and is never written to a real daemon
234    /// (these tests assert the fail-soft path, not delivery).
235    fn isolate_socket() {
236        std::env::set_var(
237            "KINTSUGI_SOCKET",
238            "/kintsugi-nonexistent-test-socket-xyzzy.sock",
239        );
240    }
241
242    #[test]
243    fn unwatchable_root_records_a_marker_and_bails() {
244        // A path that can't be watched (it doesn't exist) is a partial blind spot:
245        // the per-root `.watch()` Err arm records a degradation marker, and with no
246        // root successfully registered `run` bails rather than watching nothing.
247        isolate_socket();
248        let bogus = PathBuf::from("/kintsugi-nonexistent-watch-root-xyzzy");
249        assert!(
250            run(&[bogus]).is_err(),
251            "no watchable root must be an error, not a silent no-op"
252        );
253    }
254
255    #[test]
256    fn record_marker_is_resilient_without_a_daemon() {
257        // The degradation marker is best-effort: with no daemon listening, the
258        // Client::observe send fails and is logged, but record_marker must not
259        // panic (the watcher keeps running).
260        isolate_socket();
261        record_marker("test degradation reason");
262    }
263
264    #[test]
265    fn forward_skips_ignored_and_non_destructive_events_without_panic() {
266        isolate_socket();
267        // A non-destructive kind (create) is dropped before any path work.
268        let create = notify::Event::new(EventKind::Create(notify::event::CreateKind::File))
269            .add_path(PathBuf::from("/work/tree/new.rs"));
270        forward(&create);
271        // A destructive event under an ignored dir is skipped per-path.
272        let in_ignored = notify::Event::new(EventKind::Remove(notify::event::RemoveKind::File))
273            .add_path(PathBuf::from("/work/tree/node_modules/x.js"));
274        forward(&in_ignored);
275        // A destructive event on a real path is forwarded (observe fails soft with
276        // no daemon — the point is it walks the happy path without panicking).
277        let real = notify::Event::new(EventKind::Remove(notify::event::RemoveKind::File))
278            .add_path(PathBuf::from("/work/tree/src/main.rs"));
279        forward(&real);
280    }
281}