use std::path::Path;
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::thread;
use std::time::Duration;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
pub const DEBOUNCE: Duration = Duration::from_millis(250);
pub const SAFETY: Duration = Duration::from_secs(10);
pub struct StoreWatcher {
_watcher: Option<RecommendedWatcher>,
}
pub fn spawn(store_dir: &str, out: Sender<()>) -> StoreWatcher {
let (raw_tx, raw_rx): (Sender<()>, Receiver<()>) = channel();
let watcher = if Path::new(store_dir).is_dir() {
let tx = raw_tx.clone();
match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = tx.send(());
}
}) {
Ok(mut w) => match w.watch(Path::new(store_dir), RecursiveMode::NonRecursive) {
Ok(()) => Some(w),
Err(_) => None,
},
Err(_) => None,
}
} else {
None
};
thread::spawn(move || {
loop {
match raw_rx.recv_timeout(SAFETY) {
Ok(()) => {
while raw_rx.recv_timeout(DEBOUNCE).is_ok() {}
if out.send(()).is_err() {
return;
}
}
Err(RecvTimeoutError::Timeout) => {
if out.send(()).is_err() {
return;
}
}
Err(RecvTimeoutError::Disconnected) => return,
}
}
});
StoreWatcher { _watcher: watcher }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!("dextui-watch-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
Self(p)
}
fn path(&self) -> &str {
self.0.to_str().unwrap()
}
fn write(&self, contents: &str) {
fs::write(self.0.join("tasks.jsonl"), contents).unwrap();
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn fires_when_the_store_file_changes() {
let dir = TempDir::new("fires");
let (tx, rx) = channel();
let _w = spawn(dir.path(), tx);
dir.write(r#"{"id":"a"}"#);
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"watcher did not fire on a file write"
);
}
#[test]
fn collapses_a_burst_of_writes() {
let dir = TempDir::new("burst");
let (tx, rx) = channel();
let _w = spawn(dir.path(), tx);
for i in 0..10 {
dir.write(&format!(r#"{{"id":"a","n":{i}}}"#));
thread::sleep(Duration::from_millis(20));
}
let seen = Arc::new(AtomicUsize::new(0));
let deadline = std::time::Instant::now() + Duration::from_millis(1500);
while std::time::Instant::now() < deadline {
if rx.recv_timeout(Duration::from_millis(200)).is_ok() {
seen.fetch_add(1, Ordering::SeqCst);
}
}
let n = seen.load(Ordering::SeqCst);
assert!((1..=3).contains(&n), "expected a coalesced burst, got {n}");
}
#[test]
fn falls_back_to_the_safety_poll_when_the_store_is_missing() {
let dir = TempDir::new("missing");
let missing = dir.0.join("not-created-yet");
let (tx, rx) = channel();
let _w = spawn(missing.to_str().unwrap(), tx);
assert!(rx.recv_timeout(Duration::from_millis(300)).is_err());
}
}