1use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21use notify::{EventKind, RecursiveMode, Watcher};
22
23use crate::ipc::{Client, Observation};
24
25const 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 "Library",
52 "Caches",
53 "DerivedData",
54];
55
56pub 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
71pub 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 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
86 if name == ".DS_Store"
87 || name == "4913" || name.starts_with(".#") || 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
100pub 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 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 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
144fn 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
158fn forward(event: ¬ify::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 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 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 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 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 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 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 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 let create = notify::Event::new(EventKind::Create(notify::event::CreateKind::File))
269 .add_path(PathBuf::from("/work/tree/new.rs"));
270 forward(&create);
271 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 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}