Part 1: Embracing Simplicity in Design

6 minute read Published: 2025-10-09

"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
  1. Parse the Input: Whether from CLI or JSON, the paths are split into a list (handling commas or arrays accordingly).

  2. 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.rs where 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 criteria function mentioned above. The code is found in the check.rs file.

    pub trait ValidateWithExt {
        fn validate_with(self, validate: fn(&str) -> Result<String, ItemError>) -> ProcessingResult;
    }
    
  3. 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.