use std::path::PathBuf;
use std::time::{Duration, Instant};
#[derive(Debug)]
pub enum WatchError {
Unsupported(String),
Io(std::io::Error),
}
impl std::fmt::Display for WatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WatchError::Unsupported(s) => write!(f, "inotify watch unavailable: {s}"),
WatchError::Io(e) => write!(f, "inotify watch io error: {e}"),
}
}
}
impl std::error::Error for WatchError {}
pub const fn is_supported() -> bool {
cfg!(all(feature = "inotify", target_os = "linux"))
}
pub type ChangedBatch = Vec<PathBuf>;
#[derive(Debug)]
pub struct Debouncer {
quiet: Duration,
pending: std::collections::BTreeSet<PathBuf>,
last_event: Option<Instant>,
}
impl Debouncer {
pub fn new(quiet: Duration) -> Self {
Self { quiet, pending: Default::default(), last_event: None }
}
pub fn record(&mut self, path: PathBuf, now: Instant) {
if path.extension().and_then(|e| e.to_str()) == Some("rs") {
self.pending.insert(path);
self.last_event = Some(now);
}
}
pub fn take_ready(&mut self, now: Instant) -> Option<ChangedBatch> {
let last = self.last_event?;
if self.pending.is_empty() || now.duration_since(last) < self.quiet {
return None;
}
self.last_event = None;
Some(std::mem::take(&mut self.pending).into_iter().collect())
}
pub fn time_to_ready(&self, now: Instant) -> Option<Duration> {
let last = self.last_event?;
if self.pending.is_empty() {
return None;
}
Some(self.quiet.saturating_sub(now.duration_since(last)))
}
pub fn has_pending(&self) -> bool {
!self.pending.is_empty()
}
}
#[cfg(all(feature = "inotify", target_os = "linux"))]
pub(crate) fn is_skipped_dir(name: &str) -> bool {
matches!(name, "target" | ".git" | "node_modules" | ".nornir" | ".claude")
}
#[cfg(all(feature = "inotify", target_os = "linux"))]
mod imp {
use super::*;
use std::collections::HashMap;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
const WATCH_MASK: u32 = libc::IN_CLOSE_WRITE
| libc::IN_CREATE
| libc::IN_DELETE
| libc::IN_MOVED_FROM
| libc::IN_MOVED_TO;
fn add_watches_recursive(fd: i32, dir: &Path, wds: &mut HashMap<i32, PathBuf>) {
add_one(fd, dir, wds);
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_entry(|e| !super::is_skipped_dir(&e.file_name().to_string_lossy()))
{
let Ok(entry) = entry else { continue };
if entry.file_type().is_dir() && entry.path() != dir {
add_one(fd, entry.path(), wds);
}
}
}
fn add_one(fd: i32, dir: &Path, wds: &mut HashMap<i32, PathBuf>) {
let Ok(c) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else { return };
let wd = unsafe { libc::inotify_add_watch(fd, c.as_ptr(), WATCH_MASK) };
if wd >= 0 {
wds.insert(wd, dir.to_path_buf());
}
}
pub fn watch(
repo_root: &Path,
debounce: Duration,
stop: &AtomicBool,
mut on_delta: impl FnMut(ChangedBatch),
) -> Result<(), WatchError> {
let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
if fd < 0 {
return Err(WatchError::Io(std::io::Error::last_os_error()));
}
let _guard = FdGuard(fd);
let mut wds: HashMap<i32, PathBuf> = HashMap::new();
add_watches_recursive(fd, repo_root, &mut wds);
let mut deb = Debouncer::new(debounce);
let mut buf = [0u8; 64 * 1024];
while !stop.load(Ordering::Relaxed) {
let now = Instant::now();
let timeout_ms = deb
.time_to_ready(now)
.map(|d| d.as_millis().min(1_000) as i32)
.unwrap_or(250);
let mut pfd = libc::pollfd { fd, events: libc::POLLIN, revents: 0 };
let pr = unsafe { libc::poll(&mut pfd, 1, timeout_ms.max(1)) };
if pr < 0 {
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(WatchError::Io(e));
}
if pr > 0 && (pfd.revents & libc::POLLIN) != 0 {
let saw_dir_create = drain_events(fd, &mut buf, &wds, &mut deb);
if saw_dir_create {
add_watches_recursive(fd, repo_root, &mut wds);
}
}
if let Some(batch) = deb.take_ready(Instant::now()) {
if !batch.is_empty() {
on_delta(batch);
}
}
}
Ok(())
}
fn drain_events(
fd: i32,
buf: &mut [u8],
wds: &HashMap<i32, PathBuf>,
deb: &mut Debouncer,
) -> bool {
let mut saw_dir_create = false;
loop {
let n = unsafe {
libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
};
if n <= 0 {
break; }
let n = n as usize;
let hdr = std::mem::size_of::<libc::inotify_event>();
let mut off = 0usize;
while off + hdr <= n {
let ev = unsafe { &*(buf.as_ptr().add(off) as *const libc::inotify_event) };
let name_len = ev.len as usize;
let name_start = off + hdr;
let name_end = name_start + name_len;
if name_end > n {
break;
}
let is_dir = ev.mask & libc::IN_ISDIR != 0;
if is_dir && ev.mask & libc::IN_CREATE != 0 {
saw_dir_create = true;
}
if !is_dir {
if let Some(dir) = wds.get(&ev.wd) {
let raw = &buf[name_start..name_end];
let name_bytes = raw.split(|&b| b == 0).next().unwrap_or(&[]);
if !name_bytes.is_empty() {
let name = std::ffi::OsStr::from_bytes(name_bytes);
deb.record(dir.join(name), Instant::now());
}
}
}
off = name_end;
}
}
saw_dir_create
}
struct FdGuard(i32);
impl Drop for FdGuard {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
}
}
}
#[cfg(not(all(feature = "inotify", target_os = "linux")))]
mod imp {
use super::*;
use std::path::Path;
use std::sync::atomic::AtomicBool;
pub fn watch(
_repo_root: &Path,
_debounce: Duration,
_stop: &AtomicBool,
_on_delta: impl FnMut(ChangedBatch),
) -> Result<(), WatchError> {
Err(WatchError::Unsupported(if cfg!(target_os = "linux") {
"build without the `inotify` feature".to_string()
} else {
"inotify is Linux-only".to_string()
}))
}
}
pub fn watch(
repo_root: &std::path::Path,
debounce: Duration,
stop: &std::sync::atomic::AtomicBool,
on_delta: impl FnMut(ChangedBatch),
) -> Result<(), WatchError> {
imp::watch(repo_root, debounce, stop, on_delta)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debouncer_coalesces_a_burst_into_one_batch() {
let quiet = Duration::from_millis(50);
let mut d = Debouncer::new(quiet);
let t0 = Instant::now();
d.record(PathBuf::from("/w/a.rs"), t0);
d.record(PathBuf::from("/w/a.rs"), t0 + Duration::from_millis(5));
d.record(PathBuf::from("/w/b.rs"), t0 + Duration::from_millis(10));
assert!(d.take_ready(t0 + Duration::from_millis(30)).is_none());
let batch = d.take_ready(t0 + Duration::from_millis(200)).expect("ready");
assert_eq!(batch.len(), 2);
assert!(batch.contains(&PathBuf::from("/w/a.rs")));
assert!(batch.contains(&PathBuf::from("/w/b.rs")));
assert!(d.take_ready(t0 + Duration::from_millis(300)).is_none());
}
#[test]
fn debouncer_ignores_non_rs_files() {
let mut d = Debouncer::new(Duration::from_millis(10));
let t0 = Instant::now();
d.record(PathBuf::from("/w/Cargo.toml"), t0);
d.record(PathBuf::from("/w/notes.md"), t0);
assert!(!d.has_pending(), "non-.rs events must not arm the debouncer");
assert!(d.take_ready(t0 + Duration::from_secs(1)).is_none());
}
#[test]
fn debouncer_resets_timer_on_each_event() {
let quiet = Duration::from_millis(50);
let mut d = Debouncer::new(quiet);
let t0 = Instant::now();
d.record(PathBuf::from("/w/a.rs"), t0);
d.record(PathBuf::from("/w/a.rs"), t0 + Duration::from_millis(40));
assert!(d.take_ready(t0 + Duration::from_millis(60)).is_none(), "timer reset by 2nd event");
assert!(d.take_ready(t0 + Duration::from_millis(100)).is_some());
}
#[test]
fn unsupported_is_reported_not_panicked_when_feature_off() {
assert_eq!(is_supported(), cfg!(all(feature = "inotify", target_os = "linux")));
}
}