"Simplicity is the ultimate sophistication." – Leonardo da Vinci
In the world of software development, where complexity can often creep in uninvited, this timeless quote reminds us of the power in stripping things down to their essentials. Nowhere is this more evident than in our project—a component designed to keep an eye on file system changes with minimal fuss and maximum reliability. Today, let's dive into how this layer kicks off its operations, from providing inputs to validating them, all while adhering to the principle of elegant simplicity.
The diagram is tweaked a little to show the components used in this layer.
flowchart LR
subgraph B [Monitoring]
direction LR
W[Watcher]
HA[Handler]
end
G[Aggregator]
N[Notifier]
A[(Directory 1)] -->|New Event| W
C[(Directory 2)] -->|Modify Event| W
W -->|events| HA
HA -->|processed events| G
G -->|subscribed by| N
N --> I[Email]
N --> J[Push]
Let's start with Input Handling
Every system begins with a solid entry point, and this layer is no exception. The application starts by pulling the necessary inputs in a way that's both user-friendly and adaptable. Specifically, it looks for the directories to monitor either through a command-line interface (CLI) argument or a JSON value stored in a configuration file. This dual approach ensures that whether you're running a quick one-off script or integrating it into a larger automated workflow, getting started is straightforward.
Imagine launching the application from the terminal: you might pass a CLI flag like --ls "/path/to/dir1,/path/to/dir2". If no CLI argument is provided, the system gracefully falls back to checking a config file (say, config.json) for a key like "monitored_directories", which could hold an array of paths: ["/path/to/dir1", "/path/to/dir2"]. This prioritization—CLI first, then config—avoids unnecessary overrides while giving users control. It's a small design choice, but it embodies simplicity: no bloated setup wizards or mandatory environment variables; just intuitive options that work out of the box.
The sequence diagram would look like this:
sequenceDiagram
participant User as User
participant Main as Main
participant Clap as Clap
participant Args as Args
participant FileSystem as FileSystem
participant Serde as Serde
User ->> Main: Execute program with arguments
Main ->> Clap: Parse command-line arguments
Clap -->> Main: Return Args struct
Main ->> Args: Call produce_links()
alt ls is None
Args ->> FileSystem: Read configuration file (fs::read_to_string)
FileSystem -->> Args: Return content
Args ->> Serde: Deserialize JSON (serde_json::from_str)
Serde -->> Args: Return Config struct
Args ->> Args: Set ls to config.ls
end
Args -->> Main: Return Vec<&str> (links)
To make this possible, I decided to use a third party library called clap for parsing command-line arguments. This library provides a convenient way to define and parse cli arguments, making it easy to handle user input and provide a consistent interface for the program.
Just add the dependency to the project's configuration as:
[dependencies]
clap = "4.5"
Import the library to parse any inputs from the command line arguments. If no values are provided, it will use the default values defined in the configuration file. This is implemented through extending the Args struct with convenient methods to handle those cases.
Define the Args data structure with these two parameters: ls and config. For my needs, these two are mutually exclusive: it's either one or the other, but not both.
pub struct Args {
#[arg(long, value_delimiter = ',', help = "Comma-separated list to check")]
pub ls: Option<Vec<String>>,
#[arg(long, default_value = "config.json", help = "Path to the config file")]
pub config: String,
}
The convenient methods will make it more intuitive to produce the links from those sources. It will simply produce a list of paths in string form which will then be used to apply checks.
impl Args {
fn produce(&mut self) -> &Vec<String> {
if self.ls.is_none() {
self.ls = Some(load_config(&self.config).unwrap());
}
self.ls.as_ref().unwrap()
}
pub fn produce_links(&mut self) -> Vec<&str> {
self.produce().iter().map(|s| s.as_str()).collect()
}
}
These few lines of code, found in the main.rs file, are what is needed to perform the operation.
let mut args = Args::parse();
let links = args.produce_links();
Checking with Precision
Once the inputs are gathered, the real work begins: validating them to ensure the monitoring layer operates on solid ground. We don't want the system wasting cycles on invalid paths or crashing midway due to bad data. The input checking phase processes the list of directories, scrutinizing each one for existence, accessibility, and basic sanity.
Here's how it unfolds in code:
sequenceDiagram
participant Main as Main
participant Validator as Check
participant Path as Path
Main ->> Validator: Call validate_with(criteria) on links
loop For each item in links
Validator ->> Path: Check if directory exists (Path::new(item).is_dir())
Path -->> Validator: Return bool
alt Valid directory
Validator ->> Validator: Collect as Ok(String)
else Invalid
Validator ->> Validator: Collect as Err(ItemError)
end
end
Validator ->> Validator: Partition into valid and invalid using partition_map
Validator -->> Main: Return ProcessingResult
-
Parse the Input: Whether from CLI or JSON, the paths are split into a list (handling commas or arrays accordingly).
-
Validation Loop: For each path:
- Check if it's an absolute path (encouraging best practices but not enforcing if relative paths are resolvable).
For the validation criteria, a function is added in the
main.rswhere conditions are defined and checked. This function will be fed to a method that processes each path, returning a Result type indicating success or failure.fn criteria(item: &str) -> Result<String, ItemError> { if is_valid_directory(item) { Ok(item.to_string()) } else { Err(ItemError { item: item.to_string(), message: "No such directory found.".to_string(), }) } } fn is_valid_directory(path: &str) -> bool { return Path::new(path).is_dir(); }- Verify the directory exists using file system APIs.
- Ensure read permissions are available—after all, monitoring requires peeking into the directory.
An extension method is added for Vec<&str> that uses a function with the same signature as the
criteriafunction mentioned above. The code is found in thecheck.rsfile.pub trait ValidateWithExt { fn validate_with(self, validate: fn(&str) -> Result<String, ItemError>) -> ProcessingResult; } -
Result Compilation: The outcome is a structured result object containing two key collections. It would look something like this:
let result = links.validate_with(criteria); // result.valid and result.invalid- Valid Entries: A list of paths that passed all checks, ready for monitoring.
- Invalid Entries: A list of paths that failed, paired with descriptive error messages (e.g., "Path '/invalid/dir' does not exist" or "No read access to '/restricted/dir'").
This result isn't just dumped to the logs; it's returned upstream, allowing the application to decide next steps—perhaps logging warnings for invalids while proceeding with valids, or halting if critical paths are missing. By separating concerns this way, we avoid monolithic error handling and empower users to customize responses.
And for the last missing piece, we provide the paths for the watcher to start monitoring. For this case, I decided to use watchexec crate but it can be any other alternative.
let paths: Vec<PathBuf> = result
.valid
.clone()
.into_iter()
.map(PathBuf::from)
.collect();
let wx = Watchexec::new(move |mut action| {
for event in action.events.iter() {
println!("Event: {:?}", event);
}
if action.signals().any(|sig| sig == Signal::Interrupt) {
action.quit();
}
action
})?;
Try it out by running the application and follow the command below:
cargo run filimon -- --ls '/tmp,/etc'
// or
cargo run filimon // with values supplied to the default config.json
cargo run filimon -- --config 'my_links.json'
The project can be found in github at filimon.
Why Simplicity Wins Here
In a layer tasked with something as foundational as directory monitoring, overcomplicating the input phase could lead to brittle code and frustrated users. By keeping it simple—flexible ingestion followed by clear validation—we create a robust foundation that scales. Future enhancements, like adding recursive monitoring or event filtering, can build on this without reinventing the wheel.
If you're working on similar projects, take a page from da Vinci: sophistication often lies in what you leave out.