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//! Off by default; opt in with `kintsugi watch <path>`.
10
11use std::path::PathBuf;
12
13use anyhow::{Context, Result};
14use notify::{EventKind, RecursiveMode, Watcher};
15
16use crate::ipc::{Client, Observation};
17
18/// Map a notify event kind to a stable label, or `None` to ignore it (access
19/// events and metadata-only noise are not interesting for the backstop).
20pub fn kind_label(kind: &EventKind) -> Option<&'static str> {
21    match kind {
22        EventKind::Create(_) => Some("created"),
23        EventKind::Modify(notify::event::ModifyKind::Name(_)) => Some("renamed"),
24        EventKind::Modify(_) => Some("modified"),
25        EventKind::Remove(_) => Some("removed"),
26        _ => None,
27    }
28}
29
30/// Watch `roots` recursively, forwarding each change to the daemon. Long-running.
31pub fn run(roots: &[PathBuf]) -> Result<()> {
32    if roots.is_empty() {
33        anyhow::bail!("nothing to watch (pass one or more paths)");
34    }
35    let (tx, rx) = std::sync::mpsc::channel();
36    let mut watcher = notify::recommended_watcher(move |res| {
37        let _ = tx.send(res);
38    })
39    .context("create filesystem watcher")?;
40
41    for root in roots {
42        watcher
43            .watch(root, RecursiveMode::Recursive)
44            .with_context(|| format!("watch {}", root.display()))?;
45        eprintln!("kintsugi-watch: watching {}", root.display());
46    }
47
48    for res in rx {
49        match res {
50            Ok(event) => forward(&event),
51            Err(e) => eprintln!("kintsugi-watch: watch error: {e}"),
52        }
53    }
54    Ok(())
55}
56
57/// Forward one notify event's interesting paths to the daemon.
58fn forward(event: &notify::Event) {
59    let Some(kind) = kind_label(&event.kind) else {
60        return;
61    };
62    for path in &event.paths {
63        let obs = Observation {
64            kind: kind.to_string(),
65            path: path.display().to_string(),
66        };
67        if let Err(e) = Client::observe(&obs) {
68            eprintln!("kintsugi-watch: could not record {}: {e}", path.display());
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use notify::event::{CreateKind, ModifyKind, RemoveKind};
77
78    #[test]
79    fn maps_interesting_kinds() {
80        assert_eq!(
81            kind_label(&EventKind::Create(CreateKind::File)),
82            Some("created")
83        );
84        assert_eq!(
85            kind_label(&EventKind::Remove(RemoveKind::File)),
86            Some("removed")
87        );
88        assert_eq!(
89            kind_label(&EventKind::Modify(ModifyKind::Data(
90                notify::event::DataChange::Any
91            ))),
92            Some("modified")
93        );
94        assert_eq!(
95            kind_label(&EventKind::Access(notify::event::AccessKind::Any)),
96            None
97        );
98    }
99
100    #[test]
101    fn empty_roots_is_an_error() {
102        assert!(run(&[]).is_err());
103    }
104}