How to watch files for changes
Short answer: The notify crate watches a path and sends events over a channel. Expect a burst per save, not one event: editors that save atomically write a temp file and rename it over the target, and the kernel splits even a plain overwrite into several events. Wait for the burst to go quiet, then act once.
Watching a file
Section titled “Watching a file”Start from a fresh project — cargo new watch-demo, put the crate in Cargo.toml:
[dependencies]notify = "8.2.0"Replace the contents of src/main.rs with this and cargo run. It watches a file in your temp directory, overwrites it once, and prints every event that arrives within a second of the write:
use notify::{RecursiveMode, Watcher};use std::sync::mpsc;use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> { let path = std::env::temp_dir().join("watched-notes.txt"); let _ = std::fs::remove_file(&path); // so a rerun starts clean std::fs::write(&path, b"start")?; // the path must exist before watch() std::thread::sleep(Duration::from_millis(300)); // let the write settle
let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(tx)?; watcher.watch(&path, RecursiveMode::NonRecursive)?; std::thread::sleep(Duration::from_millis(300));
std::fs::write(&path, b"changed")?; // one write
while let Ok(res) = rx.recv_timeout(Duration::from_secs(1)) { println!("{:?}", res?.kind); } Ok(())}Create(File)Modify(Metadata(Any))Modify(Metadata(Extended))Modify(Data(Content))Two things bite everyone on the first run. watch is a method on the Watcher trait, so without use notify::Watcher; the compiler suggests unwatch instead. And the path has to exist — watch a missing file and watch() fails with Error { kind: PathNotFound, paths: ["/var/folders/…/watched-notes.txt"] }, it doesn’t wait for the file to appear.
Then the surprise the output is showing: one fs::write produced several events, and the list isn’t a contract. On this macOS machine recommended_watcher is FSEvents (Linux gets inotify, Windows ReadDirectoryChangesW), and it aggregates — run the program again and the same write can arrive as fewer events, with or without the Create. What is reliable is that a save is never just one event. If you match on event.paths, FSEvents also hands you the resolved path: /private/var/folders/… for a file you watched as /var/folders/….
Why one save is a burst
Section titled “Why one save is a burst”An editor that writes the target in place has a problem: crash mid-write and the file is gone. So many editors write a sibling temp file and rename it over the target — two filesystem operations, each of which the kernel reports. Watch it happen:
use notify::{RecursiveMode, Watcher};use std::path::Path;use std::sync::mpsc;use std::time::Duration;
fn editor_save(target: &Path) -> std::io::Result<()> { let tmp = target.with_extension("txt.tmp"); std::fs::write(&tmp, b"saved by editor")?; // write a temp file beside it... std::fs::rename(&tmp, target) // ...and rename it over the target}
fn main() -> Result<(), Box<dyn std::error::Error>> { let path = std::env::temp_dir().join("saved-notes.txt"); let _ = std::fs::remove_file(&path); std::fs::write(&path, b"start")?; std::thread::sleep(Duration::from_millis(300));
let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(tx)?; watcher.watch(&path, RecursiveMode::NonRecursive)?; std::thread::sleep(Duration::from_millis(300));
println!("-- one fs::write --"); std::fs::write(&path, b"changed")?; std::thread::sleep(Duration::from_millis(600)); while let Ok(res) = rx.try_recv() { println!("{:?}", res?.kind); }
println!("-- one editor save --"); editor_save(&path)?; std::thread::sleep(Duration::from_millis(600)); while let Ok(res) = rx.try_recv() { println!("{:?}", res?.kind); } Ok(())}-- one fs::write --Create(File)Modify(Metadata(Any))Modify(Metadata(Extended))Modify(Data(Content))-- one editor save --Modify(Name(Any))Create(File)Modify(Name(Any))Modify(Metadata(Any))Modify(Metadata(Extended))Modify(Data(Content))The watcher survives the rename. It watches the path, not the open file, so the next save arrives too — this is why the problem repeats on every save rather than going quiet after the first one.
Waiting out the burst
Section titled “Waiting out the burst”The fix is to stop treating events as commands. Collect them until the stream has been quiet for a moment, then act once — rebuild, re-upload, re-index, whatever the save was for:
use notify::{RecursiveMode, Watcher};use std::path::Path;use std::sync::mpsc::{self, Receiver};use std::time::Duration;
fn editor_save(target: &Path) -> std::io::Result<()> { let tmp = target.with_extension("txt.tmp"); std::fs::write(&tmp, b"saved by editor")?; std::fs::rename(&tmp, target)}
/// Count events until `quiet` passes with nothing new; the burst is over.fn wait_for_quiet( rx: &Receiver<Result<notify::Event, notify::Error>>, quiet: Duration,) -> Result<usize, Box<dyn std::error::Error>> { let mut count = 0; loop { match rx.recv_timeout(quiet) { Ok(_) => count += 1, Err(_) => return Ok(count), } }}
fn main() -> Result<(), Box<dyn std::error::Error>> { let path = std::env::temp_dir().join("rebuilt-notes.txt"); let _ = std::fs::remove_file(&path); std::fs::write(&path, b"start")?; std::thread::sleep(Duration::from_millis(300));
let (tx, rx) = mpsc::channel(); let mut watcher = notify::recommended_watcher(tx)?; watcher.watch(&path, RecursiveMode::NonRecursive)?; std::thread::sleep(Duration::from_millis(300));
for save in 1..=2 { editor_save(&path)?; let raw = wait_for_quiet(&rx, Duration::from_millis(500))?; println!("save {save}: {raw} raw events -> one rebuild"); std::thread::sleep(Duration::from_millis(400)); } Ok(())}save 1: 5 raw events -> one rebuildsave 2: 2 raw events -> one rebuildThe counts are whatever the kernel handed over on this run — the first save creates the temp file, the second only overwrites it, so the bursts differ. The fixed point is on the right of the arrow: one action per save, however many events it took. How long to wait is a trade-off; 500 ms suits a demo, a file sync might want seconds. If you’d rather not hand-roll it, notify-debouncer-full (checked against 0.7.0) wraps the same watcher: new_debouncer(Duration::from_millis(200), None, tx)?, then debouncer.watch(...) like any other Watcher, and each quiet window arrives as one Vec<DebouncedEvent>.
Related
Section titled “Related”- How to copy or move a file —
fs::rename, the move an atomic save is built on - How to work with temporary files — the temp file an atomic save writes first
- Reading and writing files — the rest of the chapter