use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
pub fn spawn_watcher(root: &Path) -> notify::Result<(RecommendedWatcher, flume::Receiver<PathBuf>)> {
let (raw_tx, raw_rx) = mpsc::channel::<notify::Result<Event>>();
let (path_tx, path_rx) = flume::unbounded::<PathBuf>();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = raw_tx.send(res);
})?;
watcher.watch(root, RecursiveMode::Recursive)?;
std::thread::spawn(move || {
for res in raw_rx {
let event = match res {
Ok(ev) => ev,
Err(err) => {
eprintln!("watch error: {err}");
continue;
}
};
for path in event.paths {
if path_tx.send(path).is_err() {
return; // consumer dropped
}
}
}
});
Ok((watcher, path_rx))
}
use flume::{Receiver, RecvTimeoutError, Sender};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
pub struct Debouncer {
quiet_window: Duration,
pending: HashMap<PathBuf, Instant>,
}
impl Debouncer {
pub fn new(quiet_window: Duration) -> Self {
Debouncer { quiet_window, pending: HashMap::new() }
}
pub fn run(mut self, incoming: Receiver<PathBuf>, settled: Sender<PathBuf>) {
loop {
let wait = self.next_wakeup();
match incoming.recv_timeout(wait) {
Ok(path) => {
let deadline = Instant::now() + self.quiet_window;
self.pending.insert(path, deadline);
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
self.flush_ready(&settled, true);
return;
}
}
self.flush_ready(&settled, false);
}
}
fn next_wakeup(&self) -> Duration {
let now = Instant::now();
self.pending
.values()
.map(|d| d.saturating_duration_since(now))
.min()
.unwrap_or(Duration::from_secs(3600))
}
fn flush_ready(&mut self, settled: &Sender<PathBuf>, force: bool) {
let now = Instant::now();
let ready: Vec<PathBuf> = self
.pending
.iter()
.filter(|(_, &deadline)| force || deadline <= now)
.map(|(path, _)| path.clone())
.collect();
for path in ready {
self.pending.remove(&path);
let _ = settled.send(path);
}
}
}
mod debouncer;
mod watcher;
use debouncer::Debouncer;
use std::path::Path;
use std::time::Duration;
fn main() -> notify::Result<()> {
let root = Path::new("./src");
let (_watcher, raw_paths) = watcher::spawn_watcher(root)?;
let (settled_tx, settled_rx) = flume::unbounded();
std::thread::spawn(move || {
let debouncer = Debouncer::new(Duration::from_millis(300));
debouncer.run(raw_paths, settled_tx);
});
println!("watching {} (settled events only)", root.display());
for path in settled_rx {
println!("reload triggered by {}", path.display());
// trigger rebuild / hot-reload here
}
Ok(())
}
A naive directory watcher fires an event for every low-level filesystem operation, and a single editor save can produce a burst of Create, Modify, and Rename events within milliseconds. Rebuilding or reloading on each raw event wastes work and can trip over half-written files. The pattern shown here collapses these bursts into one settled event per path using a trailing debounce: after activity on a path stops for a quiet window, the path is reported once.
In watcher.rs, spawn_watcher wires the notify crate to a std::sync::mpsc channel and forwards raw events into an application-level flume channel of PathBuf values. The notify recommended watcher runs on its own thread, so the raw sender is simply cloned into the closure. Extracting a PathBuf from each Event keeps the downstream stage path-oriented rather than event-oriented, which is what most reload logic actually cares about.
The heart of the technique lives in debouncer.rs. Debouncer::run keeps a HashMap<PathBuf, Instant> mapping each dirty path to the last time it changed. It uses recv_timeout so the loop wakes either when a new event arrives or when the quiet window may have elapsed. On each new path the deadline is refreshed to now + quiet_window, which is what makes this a trailing debounce: a path that keeps changing is never emitted until it goes quiet. flush_ready then drains any paths whose deadline has passed and forwards them to the settled channel.
A subtle detail is next_wakeup: rather than polling at a fixed interval, it computes the soonest pending deadline so the thread sleeps exactly as long as needed, staying responsive without busy-waiting. When no paths are pending it blocks indefinitely until the next event.
main.rs composes the two stages and consumes settled paths. The trade-off is added latency equal to the quiet window, and coalescing means intermediate states are lost — acceptable for reload/rebuild triggers but wrong if every discrete change must be observed. Choosing quiet_window balances responsiveness against how aggressively bursts are merged.
Related snips
struct Config<'a> {
name: &'a str,
value: &'a str,
}
fn parse_config(line: &str) -> Config {
Lifetime annotations for flexible borrowing in structs
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use tracing::{info, instrument};
#[instrument]
fn process_request(user_id: u64) {
info!(user_id, "Processing request");
// Work happens here
tracing for structured logging and distributed tracing
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
Share this code
Here's the card — post it anywhere.