use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use notify::event::EventKind;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use super::Debouncer;
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub struct WatchedChange {
pub path: PathBuf,
pub contents: String,
}
#[derive(Debug, Clone)]
pub enum WatchedEvent {
Change(WatchedChange),
Removed { path: PathBuf },
ReadError { path: PathBuf, error: String },
}
pub trait FileWatcher: Send {
fn watch(&mut self, path: &Path) -> Result<()>;
fn unwatch(&mut self) -> Result<()>;
fn force_reconcile(&self) -> Result<()>;
}
enum WorkerCommand {
Event(notify::Event),
SetPath(PathBuf),
Clear,
Reconcile,
Shutdown,
}
pub struct NotifyWatcher {
inner: RecommendedWatcher,
watched_dir: Option<PathBuf>,
current_path: Option<PathBuf>,
cmd_tx: mpsc::Sender<WorkerCommand>,
worker: Option<JoinHandle<()>>,
}
impl NotifyWatcher {
pub fn new(event_tx: mpsc::Sender<WatchedEvent>) -> Result<Self> {
let (cmd_tx, cmd_rx) = mpsc::channel::<WorkerCommand>();
let worker = std::thread::Builder::new()
.name("edamame-watcher".to_owned())
.spawn(move || worker_loop(cmd_rx, event_tx, DEBOUNCE_WINDOW))
.context("failed to spawn watcher worker")?;
let cb_tx = cmd_tx.clone();
let inner =
notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
Ok(ev) => {
if matches!(
ev.kind,
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
) {
let _ = cb_tx.send(WorkerCommand::Event(ev));
}
}
Err(err) => {
tracing::warn!(target: "watcher", error = %err, "notify error");
}
})
.context("failed to construct notify watcher")?;
Ok(Self {
inner,
watched_dir: None,
current_path: None,
cmd_tx,
worker: Some(worker),
})
}
}
impl FileWatcher for NotifyWatcher {
fn watch(&mut self, path: &Path) -> Result<()> {
if self.current_path.as_deref() == Some(path) {
return Ok(());
}
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let parent_owned: PathBuf = parent
.map(Path::to_owned)
.unwrap_or_else(|| PathBuf::from("."));
if let Some(prev) = self.watched_dir.take() {
let _ = self.inner.unwatch(&prev);
}
self.inner
.watch(&parent_owned, RecursiveMode::NonRecursive)
.with_context(|| {
format!(
"failed to watch {} (for file {})",
parent_owned.display(),
path.display(),
)
})?;
self.watched_dir = Some(parent_owned);
self.current_path = Some(path.to_owned());
let _ = self.cmd_tx.send(WorkerCommand::SetPath(path.to_owned()));
Ok(())
}
fn unwatch(&mut self) -> Result<()> {
self.current_path = None;
let _ = self.cmd_tx.send(WorkerCommand::Clear);
if let Some(prev) = self.watched_dir.take() {
let _ = self.inner.unwatch(&prev);
}
Ok(())
}
fn force_reconcile(&self) -> Result<()> {
let _ = self.cmd_tx.send(WorkerCommand::Reconcile);
Ok(())
}
}
impl Drop for NotifyWatcher {
fn drop(&mut self) {
let _ = self.cmd_tx.send(WorkerCommand::Shutdown);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
fn worker_loop(
cmd_rx: mpsc::Receiver<WorkerCommand>,
event_tx: mpsc::Sender<WatchedEvent>,
window: Duration,
) {
let mut current_path: Option<PathBuf> = None;
let mut debouncer = Debouncer::new(window);
loop {
let recv_result = match debouncer.deadline() {
Some(deadline) => {
let now = Instant::now();
let remaining = deadline.saturating_duration_since(now);
cmd_rx.recv_timeout(remaining)
}
None => cmd_rx
.recv()
.map_err(|_| mpsc::RecvTimeoutError::Disconnected),
};
match recv_result {
Ok(WorkerCommand::SetPath(p)) => {
current_path = Some(p);
debouncer.clear();
}
Ok(WorkerCommand::Clear) => {
current_path = None;
debouncer.clear();
}
Ok(WorkerCommand::Event(ev)) => {
let Some(path) = current_path.as_ref() else {
continue;
};
if event_matches_path(&ev, path) {
debouncer.record(Instant::now());
}
}
Ok(WorkerCommand::Reconcile) => {
debouncer.clear();
if let Some(path) = current_path.clone() {
do_read_and_send(&path, &event_tx);
}
}
Ok(WorkerCommand::Shutdown) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {
if debouncer.fire_if_due(Instant::now()) {
if let Some(path) = current_path.clone() {
do_read_and_send(&path, &event_tx);
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
fn event_matches_path(event: ¬ify::Event, target: &Path) -> bool {
if event.paths.iter().any(|p| p == target) {
return true;
}
let Some(target_name) = target.file_name() else {
return false;
};
event
.paths
.iter()
.any(|p| p.file_name() == Some(target_name))
}
fn do_read_and_send(path: &Path, event_tx: &mpsc::Sender<WatchedEvent>) {
match std::fs::read_to_string(path) {
Ok(contents) => {
let _ = event_tx.send(WatchedEvent::Change(WatchedChange {
path: path.to_owned(),
contents,
}));
}
Err(err) if err.kind() == ErrorKind::NotFound => {
tracing::info!(
target: "watcher",
path = %path.display(),
"watched file no longer exists",
);
let _ = event_tx.send(WatchedEvent::Removed {
path: path.to_owned(),
});
}
Err(err) => {
tracing::warn!(
target: "watcher",
path = %path.display(),
error = %err,
"failed to read watched file",
);
let _ = event_tx.send(WatchedEvent::ReadError {
path: path.to_owned(),
error: err.to_string(),
});
}
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::sync::mpsc;
use std::time::Duration;
use super::*;
fn expect_change(ev: WatchedEvent) -> WatchedChange {
match ev {
WatchedEvent::Change(c) => c,
WatchedEvent::Removed { path } => {
panic!("expected Change, got Removed on {}", path.display())
}
WatchedEvent::ReadError { path, error } => {
panic!(
"expected Change, got ReadError on {}: {error}",
path.display()
)
}
}
}
#[test]
fn watcher_emits_change_on_external_write() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, "initial").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
std::fs::write(&path, "updated").expect("rewrite");
let change = expect_change(
rx.recv_timeout(Duration::from_millis(1500))
.expect("expected a debounced change"),
);
assert_eq!(change.path, path);
assert_eq!(change.contents, "updated");
}
#[test]
fn force_reconcile_emits_without_filesystem_event() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, "alpha").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
while rx.try_recv().is_ok() {}
std::fs::write(&path, "beta").expect("rewrite");
w.force_reconcile().expect("reconcile");
let change = expect_change(
rx.recv_timeout(Duration::from_millis(500))
.expect("forced reconcile must deliver synchronously"),
);
assert_eq!(change.contents, "beta");
}
#[test]
fn unwatch_stops_event_delivery() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, "x").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
while rx.try_recv().is_ok() {}
w.unwatch().expect("unwatch");
std::thread::sleep(Duration::from_millis(20));
std::fs::write(&path, "y").expect("rewrite");
std::thread::sleep(Duration::from_millis(400));
assert!(
rx.try_recv().is_err(),
"no change should be delivered after unwatch"
);
}
#[test]
fn rapid_writes_coalesce_into_a_single_change() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, "0").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
while rx.try_recv().is_ok() {}
for i in 1..=5 {
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.expect("open");
write!(f, "{i}").expect("write");
std::thread::sleep(Duration::from_millis(20));
}
let first = expect_change(
rx.recv_timeout(Duration::from_millis(1500))
.expect("at least one event after the burst"),
);
assert_eq!(first.contents, "5", "final contents must win");
}
#[test]
fn read_error_is_surfaced_on_invalid_utf8() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, b"\xff\xfe not utf-8").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
while rx.try_recv().is_ok() {}
w.force_reconcile().expect("reconcile");
let ev = rx
.recv_timeout(Duration::from_millis(500))
.expect("read error must be delivered");
match ev {
WatchedEvent::ReadError { path: p, .. } => assert_eq!(p, path),
WatchedEvent::Removed { path: p } => {
panic!("expected ReadError, got Removed on {}", p.display())
}
WatchedEvent::Change(c) => {
panic!(
"expected ReadError, got Change with {} bytes",
c.contents.len()
)
}
}
}
#[test]
fn watcher_emits_removed_on_deletion() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("file.md");
std::fs::write(&path, "doomed").expect("seed");
let (tx, rx) = mpsc::channel::<WatchedEvent>();
let mut w = NotifyWatcher::new(tx).expect("build watcher");
w.watch(&path).expect("watch");
std::thread::sleep(Duration::from_millis(80));
while rx.try_recv().is_ok() {}
std::fs::remove_file(&path).expect("delete");
w.force_reconcile().expect("reconcile");
let ev = rx
.recv_timeout(Duration::from_millis(500))
.expect("removal must be delivered");
match ev {
WatchedEvent::Removed { path: p } => assert_eq!(p, path),
other => panic!("expected Removed, got {other:?}"),
}
}
}