Walking directories is a common requirement for many applications: sometimes we need to search for a file, inspect metadata, or —as in this case— watch for file changes.
I will build a simple file watcher for preconfigured directories, the goal is track the changes and detect when a file is changed. This will support exclusions and many directories.
There are two common algorithms for walking directories, Depth-First Search (DFS), and Breadth-First Search (BFS).
For a file watcher, DFS is usually more efficient: most file changes happen deeper in subdirectories, so going deep first reduces unnecessary checks. I will implement the DFS. This is the diagram of the algorithm:
flowchart TB
A[Start] --> B[read elements in dir]
B --> C{more entries?}
C -- No --> Z[return changes]
C -- Yes --> D[entry ← next]
D --> E{entry ends with exclusion?}
E -- Yes --> C
E -- No --> F{is directory?}
F -- Yes --> G[recurse]
G --> H[append child changes]
H --> C
F -- No --> I[meta ← metadata entry]
I --> J{modified > last_check?}
J -- Yes --> K[push Change]
J -- No --> C
K --> C
First, I define a custom error type JCodeError and a
Change struct. I also introduce a type alias
JCodeResult<T> for convenience, so functions can
return Result<T, JCodeError> more concisely.
The Change struct contains the path of each element
(directory or file) and the timestamp of the last modification. A
Change element will be created when a change is
detected.
use std::error::Error;
use std::fmt::Display;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
// Optional: Custom Error type
type JCodeResult<T> = Result<T, JCodeError>;
#[derive(Debug)]
struct JCodeError(String);
impl From<std::io::Error> for JCodeError {
fn from(value: std::io::Error) -> Self {
Self(format!("{value}"))
}
}
impl Display for JCodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for JCodeError {}
/// A change made in a file, including the path of the
/// modified file and the timestamp of when it was modified.
#[derive(Debug)]
struct Change {
path: PathBuf,
timestamp: SystemTime,
}
impl Change {
fn new(path: PathBuf, timestamp: SystemTime) -> Self {
Self { path, timestamp }
}
}Then the core of the recursive algorithm:
use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
const EXCLUSIONS: &[&str] = &["target", ".venv", "node_modules", ".git", ".DS_Store"];
/// Get all changes from a directory, exploring the subdirectories.
///
/// # Return
/// A [`Vec`] of [`Change`] containing all the modified files
fn inspect_dir_for_changes(dir: &Path, last_check: SystemTime) -> JCodeResult<Vec<Change>> {
let mut changed = Vec::new();
// Verify the root is a dir
if !dir.is_dir() {
eprintln!("{} must be a dir", dir.to_string_lossy());
std::process::exit(1);
}
// Walk the directory
'parent: for element in fs::read_dir(dir)? {
let element = element?;
let element_path = element.path();
// If it is an exclusion, skip and check the next element
for exc in EXCLUSIONS {
if element_path.ends_with(exc) {
continue 'parent;
}
}
// If the element is a dir, recurse
if element_path.is_dir() {
let mut changes = inspect_dir_for_changes(&element_path, last_check)?;
if changes.is_empty() {
continue;
}
changed.append(&mut changes);
} else {
// If it is a file, verify if it has been modified
let meta = element_path.metadata()?;
let modified = meta.modified()?;
if modified > last_check {
changed.push(Change::new(element_path, modified));
}
}
}
Ok(changed)
}Now is ok! An example of the usage is this:
// Dirs to watch
const DIRS: &[&str] = &["/Users/richard/proj", "/Users/richard/dev"];
fn main() {
loop {
let last_check = SystemTime::now();
// Check interval: 10 seconds
std::thread::sleep(std::time::Duration::from_secs(10));
for dir in DIRS {
println!("Inspecting {dir}...");
let changes = inspect_dir_for_changes(&PathBuf::from(dir), last_check).unwrap();
if changes.is_empty() {
println!("No changes detected");
} else {
println!("Changes detected: {:?}", changes);
// Here we can save the data to a database, to a file
// or trigger a hook
}
}
}
}With this simple approach, we can track file changes and trigger hooks to perform actions. This is useful for many tasks such as live-reloading servers, tracking modifications, or synchronizing files.
Some improvements and next steps to make:
EXCLUSIONS, DIRS and
interval are hardcoded, those data can be retrieved
from a config file, like a toml and parsed with
serde.struct for
better abstraction.