Sitemap
Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

Rust: Watch For File/Directory Changes

7 min readJul 11, 2025

--

Maybe you need to create your own nodemon? Maybe you are making a prettier/formatter that needs to format files on edit?

There are a lot of scenarios where we will want to be notified when user add/delete/modify a specific file or directory!

In this article, let’s check out how we can achieve that with Rust and the notify crate, a Cross-platform filesystem notification library used by cargo watch, deno, rust-analyzer, and etc! (Yes, all those big-name crates use it!)

  • Basic File/directory watching
  • Blocking vs non-blocking
  • Customize the configuration

and more!

Set Up

Add the crate to the dependencies section in Cargo.toml or run cargo add notify.

[dependencies]
notify = "8.0.0"

Basic (File or Directory)

To start watching a file or a directory, 3 lines of code!

Create an EventHandler, create a Watcher, and start watching!


fn main() -> anyhow:: Result<()>{
let path = PathBuf::from_str("./.../sampleDirectory")?;
watch_with_channel(path)?;
Ok(())
}

fn watch_with_channel(path: PathBuf) -> anyhow:: Result<()> {
let (tx, rx) = mpsc::channel::<Result<Event>>();
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::Recursive)?;

for result in rx {
match result {
Ok(event) => {
println!()
},
Err(e) => println!("watch error: {:?}", e),
}
}

Ok(())
}

Let’s break it down a little bit!

First of all, the recommended_watcher function!

It is a convenience method that creates the RecommendedWatcher for the current platform using the default configuration.

What we pass in here to the constructor is an EventHandler.

Here, I have used the channel provided by rust standard library mpsc. (We will see some other ways of creating this EventHandler in the next section.)

A channel is basically just a concept by which data is sent from one thread to another, and the channel implementation consist of two parts, a Sender and a Receiver. If you want a little more details on the this, please check out my previous Rust: Transfer Data Between Threads!

We can then start watching a specific path by calling the watch method and receive those Events by looping on the receiver object rx.

This way of receiving the events will block the main thread forever. We will see some of the approaches for getting those in a non-blocking manner.

Before that, I would like to add a little note on this watch method.

It takes in two parameters, a Path to watch on, and a RecursiveMode that indicates whether only the provided directory or its sub-directories as well should be watched.

If the path is a directory, recursive_mode will be evaluated. If recursive_mode is RecursiveMode::Recursive events will be delivered for all files in that tree. Otherwise only the directory and its immediate children will be watched.

If the path is a file, recursive_mode will be ignored and events will be delivered only for the file.

Also, to stop watching a path, we can use the unwatch method. This method will return an error if the path passed in is not watched or if removing the watch fails.

Non Blocking

As I have mentioned above, receiving events by polling on the receiver will block the thread.

So!

What if we want to do some other stuff on our main thread as the watcher watching for those file/directory changes?

There are couple different ways of approaching.

Receive On Another Thread

First of all, we can spawn the watching process onto another thread using std::thread::spawn. This will free up our main thread so that we can perform some other tasks.

fn watch_with_channel_spawn(path: PathBuf) -> anyhow:: Result<()> {
let (tx, rx) = mpsc::channel::<Result<Event>>();

let _ = std::thread::spawn(move || -> Result<()> {
let mut watcher = notify::recommended_watcher(tx)?;
watcher.watch(&path, RecursiveMode::Recursive)?;

for result in rx {
match result {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
}

Ok(())
});

for i in 0..5 {
println!("main thread: {}", i);
std::thread::sleep(Duration::from_secs(1));
}

Ok(())
}

Try to give the program a run and add some files to the path you are watching! You should see something similar to the following print out.

main thread: 0
main thread: 1
main thread: 2
main thread: 3
event: Event { kind: Create(File), paths: ["/.../sampleDirectory/new"], attr:tracker: None, attr:flag: None, attr:info: None, attr:source: None }
main thread: 4

The loop and the sleep function are just here so that our program will not exit immediately! Since we are not blocking!

Closure

Passing a closure as the EventHandler will also allow us to receive the event in a non-blocking fashion.

fn watch_with_closure(path: PathBuf) -> anyhow:: Result<()> {
let mut watcher = notify::recommended_watcher(|res| {
match res {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
})?;
watcher.watch(&path, RecursiveMode::Recursive)?;
for i in 0..5 {
println!("main thread: {}", i);
std::thread::sleep(Duration::from_secs(1));
}
Ok(())
}

Custom Event Handler

We can also create a custom struct that implement the EventHandler trait.


struct CustomEventHandler;

impl EventHandler for CustomEventHandler {
fn handle_event(&mut self, event: Result<Event>) {
if let Ok(event) = event {
println!("Event: {:?}", event);
}
}
}

fn watch_with_custom_event_handler(path: PathBuf) -> anyhow:: Result<()> {
let mut watcher = notify::recommended_watcher(CustomEventHandler{})?;
watcher.watch(&path, RecursiveMode::Recursive)?;
for i in 0..5 {
println!("main thread: {}", i);
std::thread::sleep(Duration::from_secs(1));
}
Ok(())
}

Custom Configuration

Above, we have used the recommended_watcher function to create a RecommendedWatcher with the default configuration.

Get Itsuki’s stories in your inbox

Join Medium for free to get updates from this writer.

Let’s see how we can customize this a little bit!

Create Configuration

First of all, let’s create the Config object.

let config = Config::default()
.with_poll_interval(Duration::from_secs(2))
.with_compare_contents(true)
.with_follow_symlinks(true);

Here I have set the interval between each re-scan attempt with with_poll_interval, with_compare_contents to have the program evaluates the contents of changed files to determine if they have indeed changed using a hashing algorithm, and with_follow_symlinks so that symbolic links ca be followed when recursively watching a directory.

There are also couple other ones available.

For example, we have with_manual_polling to enable/disable automatic polling, and etc.

Now, not ALL configurations apply to ALL WatcherKind , and not ALL configurations can be configured at runtime!

Set Configuration (RunTime)

If we want to configure the watch at runtime, ie: after creation, we can call the configure method on the watcher instance. This method will return

  • Ok(true) on success.
  • Ok(false) if the watcher does not support or implement the option.
  • Err(notify::Error) on failure.
let mut watcher = notify::recommended_watcher(tx)?;

let config = Config::default()
.with_poll_interval(Duration::from_secs(2))
.with_compare_contents(true)
.with_follow_symlinks(true);

let result = watcher.configure(config)?;
if !result {
println!("Configure contain options not supported!")
}

(The result will actually return false here, because with_poll_interval and with_compare_contents are only for PollWatcher backend, and with_follow_symlinks is only for INotifyWatcher, KqueueWatcher, and PollWatcher and can only be configured at creation! I am running on mac which means I have an FsEventWatcher!)

Set Configuration (Creation)

We can also pass in the config while creating the watcher using the RecommendedWatcher::new function.

fn watch_with_config(path: PathBuf) -> anyhow:: Result<()> {
let (tx, rx) = mpsc::channel::<Result<Event>>();

let config = Config::default()
.with_poll_interval(Duration::from_secs(2))
.with_compare_contents(true)
.with_follow_symlinks(true);

let mut watcher = notify::RecommendedWatcher::new(tx, config)?;

watcher.watch(&path, RecursiveMode::Recursive)?;

// Block forever, printing out events as they come in
for result in rx {
match result {
Ok(event) => println!("event: {:?}", event),
Err(e) => println!("watch error: {:?}", e),
}
}

Ok(())
}

Different from above, If the type of watcher(WatcherKind) does not support the configuration, it will simply just be ignored.

Event

Last but not least, let’s take a look e amore detailed look at this Event object we receive!

Think it is straightforward? Not really on Mac!

An Event structure consist of three properties, an EventKind, a list of PathBuf that triggers the event, and an EventAttributes.

pub struct Event {
pub kind: EventKind,
pub paths: Vec<PathBuf>,
pub attrs: EventAttributes,
}

Why did I say the Event might not be as straightforward as it looks?

This EventKind is the reason!

pub enum EventKind {
Any,
Access(AccessKind),
Create(CreateKind),
Modify(ModifyKind),
Remove(RemoveKind),
Other,
}

So!

Thinking that we will get a Remove when deleting, or an Access on opening/closing/executing a file?

Nope! Not on Mac!

Here is a simple table for some of the main operations and their corresponding events!

At least for Mac!

+----------------------+----------------------+-----------------------------------------------+
| **Type** | **Operation** | **Event** |
+----------------------+----------------------+-----------------------------------------------+
| File | Create | Create(File) |
+----------------------+----------------------+-----------------------------------------------+
| | Modify | 1. Modify(Metadata(Any)) |
| | | 2. Modify(Data(Content)) |
+----------------------+----------------------+-----------------------------------------------+
| | Delete | Modify(Name(Any)) |
+----------------------+----------------------+-----------------------------------------------+
| | Paste (With Content) | 1. Create(File) |
| | | 2. Modify(Metadata(Any)) |
| | | 3. Modify(Metadata(Ownership)) |
| | | 4. Modify(Data(Content)) |
+----------------------+----------------------+-----------------------------------------------+
| | Open/Close/Execute | (N/A) |
+----------------------+----------------------+-----------------------------------------------+
| Directory | Create | Create(Folder) |
| (Empty) | | |
+----------------------+----------------------+-----------------------------------------------+
| | Modify Name | 1. Modify(Name(Any)) with old path |
| | | 2. Modify(Name(Any)) with new path |
+----------------------+----------------------+-----------------------------------------------+
| | Delete | Modify(Name(Any)) |
+----------------------+----------------------+-----------------------------------------------+
| | Paste | 1. Create(Folder) |
+----------------------+----------------------+-----------------------------------------------+
| | Open/Close | (N/A) |
+----------------------+----------------------+-----------------------------------------------+
| Directory(Non Empty) | Modify Name | 1. Modify(Name(Any)) with old path |
| | | 2. Modify(Name(Any)) with new path |
+----------------------+----------------------+-----------------------------------------------+
| | Delete | Modify(Name(Any)) |
+----------------------+----------------------+-----------------------------------------------+
| | Paste | 1. Create(Folder) |
| | | 2. Create(File), |
| | | Modify(Metadata(Any)), |
| | | Modify(Metadata(Ownership)), |
| | | Modify(Data(Content)) on File in Directory |
+----------------------+----------------------+-----------------------------------------------+

Lastly, we have this EventAttributes that contains the attributes of the event.

For example, we can get the tracker ID (if present) with the tracker function, the Notify flag with flag, the source for the event with source, process ID of the originator of the event with process_id, and any additional info with info.

A little note!

You might want to check Event::need_rescan to make sure no event was missed before the current received one.

This is especially true if you have a PollWatcher backend with the poll interval set to a fairly large value.

Also Events are serializable by enabling the serde feature.

notify = { version = "8.0.0", features = ["serde"] }

Limitations

There are some limitations and know problems with this crate, so make sure to check those out before you throw the code into production!

Thank you for reading!

That’s it for this article!

Happy watching!

--