use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use notify::{EventKind, RecursiveMode, Watcher};
use crate::ipc::{Client, Observation};
const IGNORED_DIRS: &[&str] = &[
".git",
".hg",
".svn",
"node_modules",
"target",
"dist",
"build",
".next",
".nuxt",
"__pycache__",
".venv",
"venv",
".cache",
".idea",
".vscode",
".mypy_cache",
".pytest_cache",
".gradle",
".terraform",
".DS_Store",
"Library",
"Caches",
"DerivedData",
];
pub fn kind_label(kind: &EventKind) -> Option<&'static str> {
match kind {
EventKind::Modify(notify::event::ModifyKind::Name(_)) => Some("renamed"),
EventKind::Remove(_) => Some("removed"),
_ => None,
}
}
pub fn is_ignored(path: &Path) -> bool {
use std::path::Component;
for c in path.components() {
if let Component::Normal(os) = c {
if let Some(s) = os.to_str() {
if IGNORED_DIRS.contains(&s) {
return true;
}
}
}
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name == ".DS_Store"
|| name == "4913" || name.starts_with(".#") || name.ends_with('~')
|| name.ends_with(".swp")
|| name.ends_with(".swx")
|| name.ends_with(".tmp")
{
return true;
}
}
false
}
pub fn run(roots: &[PathBuf]) -> Result<()> {
if roots.is_empty() {
anyhow::bail!("nothing to watch (pass one or more paths)");
}
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.context("create filesystem watcher")?;
let mut registered = 0usize;
for root in roots {
match watcher.watch(root, RecursiveMode::Recursive) {
Ok(()) => {
registered += 1;
eprintln!("kintsugi-watch: watching {}", root.display());
}
Err(e) => record_marker(&format!("cannot watch {}: {e}", root.display())),
}
}
if registered == 0 {
anyhow::bail!("could not watch any of the requested paths");
}
for res in rx {
match res {
Ok(event) => {
if event.need_rescan() {
record_marker("event queue overflow — some changes were not recorded");
}
forward(&event);
}
Err(e) => record_marker(&format!("watch error: {e}")),
}
}
Ok(())
}
fn record_marker(reason: &str) {
eprintln!("kintsugi-watch: backstop degraded: {reason}");
let obs = Observation {
kind: "backstop-degraded".into(),
path: reason.into(),
};
if let Err(e) = Client::observe(&obs) {
eprintln!("kintsugi-watch: could not record degradation marker: {e}");
}
}
fn forward(event: ¬ify::Event) {
let Some(kind) = kind_label(&event.kind) else {
return;
};
for path in &event.paths {
if is_ignored(path) {
continue;
}
let obs = Observation {
kind: kind.to_string(),
path: path.display().to_string(),
};
if let Err(e) = Client::observe(&obs) {
eprintln!("kintsugi-watch: could not record {}: {e}", path.display());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, ModifyKind, RemoveKind};
#[test]
fn records_only_destructive_kinds() {
assert_eq!(
kind_label(&EventKind::Remove(RemoveKind::File)),
Some("removed")
);
assert_eq!(
kind_label(&EventKind::Modify(ModifyKind::Name(
notify::event::RenameMode::Any
))),
Some("renamed")
);
assert_eq!(kind_label(&EventKind::Create(CreateKind::File)), None);
assert_eq!(
kind_label(&EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Any
))),
None
);
assert_eq!(
kind_label(&EventKind::Access(notify::event::AccessKind::Any)),
None
);
}
#[test]
fn ignores_build_vcs_and_scratch_paths() {
assert!(is_ignored(Path::new("/home/u/proj/.git/index")));
assert!(is_ignored(Path::new("/home/u/proj/node_modules/x/y.js")));
assert!(is_ignored(Path::new("/home/u/proj/target/debug/foo")));
assert!(is_ignored(Path::new("/home/u/proj/src/.main.rs.swp")));
assert!(is_ignored(Path::new("/home/u/proj/.DS_Store")));
assert!(is_ignored(Path::new("/home/u/proj/src/main.rs~")));
assert!(is_ignored(Path::new(
"/Users/x/Library/Preferences/foo.plist"
)));
assert!(is_ignored(Path::new("/Users/x/Library/Caches/bar")));
assert!(!is_ignored(Path::new("/home/u/proj/src/main.rs")));
assert!(!is_ignored(Path::new("/home/u/proj/data/users.sql")));
}
#[test]
fn empty_roots_is_an_error() {
assert!(run(&[]).is_err());
}
fn isolate_socket() {
std::env::set_var(
"KINTSUGI_SOCKET",
"/kintsugi-nonexistent-test-socket-xyzzy.sock",
);
}
#[test]
fn unwatchable_root_records_a_marker_and_bails() {
isolate_socket();
let bogus = PathBuf::from("/kintsugi-nonexistent-watch-root-xyzzy");
assert!(
run(&[bogus]).is_err(),
"no watchable root must be an error, not a silent no-op"
);
}
#[test]
fn record_marker_is_resilient_without_a_daemon() {
isolate_socket();
record_marker("test degradation reason");
}
#[test]
fn forward_skips_ignored_and_non_destructive_events_without_panic() {
isolate_socket();
let create = notify::Event::new(EventKind::Create(notify::event::CreateKind::File))
.add_path(PathBuf::from("/work/tree/new.rs"));
forward(&create);
let in_ignored = notify::Event::new(EventKind::Remove(notify::event::RemoveKind::File))
.add_path(PathBuf::from("/work/tree/node_modules/x.js"));
forward(&in_ignored);
let real = notify::Event::new(EventKind::Remove(notify::event::RemoveKind::File))
.add_path(PathBuf::from("/work/tree/src/main.rs"));
forward(&real);
}
}