A command line program always starts with the same question: what did the user actually pass in, and can I work with that?

In this tutorial we’re going to see how to handle user arguments and how to test them through unit tests and integration tests.

What we need

Project structure

.
|-- Cargo.lock
|-- Cargo.toml
|-- src
|   |-- core
|   |   `-- arg_analyzer.rs
|   |-- core.rs
|   |-- file
|   |   `-- bp.txt
|   |-- main.rs
|   |-- utils
|   |   `-- errors.rs
|   `-- utils.rs
`-- tests
    `-- integration.rs

main.rs

main()

fn main() -> ExitCode

main.rs is kept separate from the rest of the project on purpose.

It only contains the main() function, whose job is narrow: collect the arguments the user typed, hand them off to another function in another file.

Why main() returns ExitCode instead of nothing?

A plain fn main() always exits with code 0, regardless of what happened inside.

With ExitCode, a failure produces a real, non-zero exit code, which is exactly what integration tests rely on to know whether the program succeeded or failed, without reading a single line of its output.

args().collect() produces a Vec<String>, never a Vec<&str>, because std::env::args() produces String values, with full ownership, built on the fly as the iterator runs.

There is no stable data yet for a &str to borrow from, so collect() needs an explicit type to know what container to build.

core/arg_analyzer.rs

run()

pub fn run(args: &[String]) -> Result<(), ErrorArgs>

run() does not contain any validation logic itself.

It simply calls the other functions of this file, and forwards their Result back to main().

This forwarding happens through the ? operator.

If the function being called returns an error, ? immediately returns that same error to the function one level above, in this case main(), and stops executing the rest of run() right there.

Concretely, if check_number_of_args fails, check_arg_is_file is never even called, and args[1] is never read unless the first check has already guaranteed it exists.

run() takes &[String] rather than Vec<String>, since it only reads the arguments and never needs to own or modify them.

Its return type, Result<(), ErrorArgs>, has nothing useful to report on success (except ()) but needs to explain precisely what went wrong on failure with the ErrorArgs enum rather than a plain bool.

check_number_of_args()

pub fn check_number_of_args(args: &[String]) -> Result<(), ErrorArgs>

This function checks every case that can possibly happen:

  • no binary name at all, the rarest case, where args itself is empty
  • no argument passed to the binary
  • exactly one argument
  • more than one argument

All of them return a specific error, except the case of exactly one argument, which is the only one allowed to let the rest of the program continue.

saturating_sub(1) removes the binary name from the count without ever panicking, even if args were empty.

Indeed a simple args.len() - 1 will panic if args.len() would be 0 because usize cannot go negative.

check_arg_is_file()

pub fn check_arg_is_file(arg1: &str) -> Result<(), ErrorArgs>

Once check_number_of_args() has succeeded, meaning arg1 exists, this function can check whether it actually points to a file.

Path is not a class (Rust has no classes), it is a struct, a plain data type with methods attached to it.

Path::new(arg1) does not touch the file system at all, it only reinterprets the &str as a path, giving access to its full, structured representation.

.is_file() is the call that actually asks the file system whether something exists at that location and whether it is a regular file, not a directory.

If it is a file, the code continues.

Otherwise, an error is returned, carrying the exact value the user passed in, via arg1.to_string().

The PathNotFile enum variant stores an owned String rather than a &str, because a &str inside an enum would require an explicit lifetime for the entire ErrorArgs enum, just for one variant that would need it.

String avoids that entirely, at the cost of one small allocation that only ever happens once per failed run.

check_arg_is_file() takes &str rather than String, since it only reads the argument to compare it against the file system.

&str also accepts both a string literal and a reference to an existing String, making it the more flexible choice for whoever calls it.

utils/errors.rs

pub enum ErrorArgs

Whether the failure comes from check_number_of_args() or check_arg_is_file(), it is this single enum that represents every possible error.

Enum variants can carry their own data declared right next to their name.

For example, TooMany carries the actual count, PathNotFile carries the path that failed.

In contrast, NoBinary and NeedAtLeastOne need nothing extra, since their name alone already tells the whole story.

ErrorArgs derives PartialEq, which lets two values of this enum be compared with ==.

This is what makes assert_eq!(result, Err(ErrorArgs::TooMany(2))) possible in tests.

Without PartialEq, the compiler would have no idea how to decide whether two ErrorArgs values are equal, and comparing errors directly in a test would not compile at all.

impl fmt::Display for ErrorArgs

The enum also implements the Display trait, so each variant can produce a human-readable message specific to its own case.

This wouldn’t be possible with #[derive(Debug)] alone, since there is no generic rule to derive Display, the message has to be written by hand for each enum variant.

write!(f, ...) is used instead of println! because fmt() has no idea where its text will end up:

  • standard output
  • standard error
  • simply a String produced by .to_string()

write! only deposits the text into the Formatter it was handed.

It is always the caller of Display (println!, eprintln!, .to_string()) that decides where that text finally goes, not fmt() itself.

Tests

Unit tests

Unit tests live in the same file as the code they test, below the #[cfg(test)] attribute so they are never compiled into the final binary.

In our tutorial, the tests of each function are grouped inside their own mod (module) to visually separate and thus easily manage them.

#[cfg(test)]
mod ut {
    mod check_arg_is_file {
      #[test]
      fn no_binary() {...}
    ...
}

The number of tests inside each submodule follows the number of distinct branches the function can take, not a fixed count: check_number_of_args has four possible outcomes, so it has four tests.

Integration tests

Integration tests bring something unit tests cannot: they launch the actual compiled binary as a real, separate process.

It acts exactly as a user would from a terminal, and observes it from the outside, its exit code and its output, rather than calling a function directly in memory.

This is the only way to verify what main() itself does, since unit tests never go through main() at all.

fn run_binary(args: &[&str]) -> std::process::Output {
    Command::new(binary_path!())
        .args(args)
        .output()
        .expect(MESSAGE_EXPECT)
}

They live in a separate file, tests/integration.rs, since this is how Cargo recognizes and compiles them as standalone test binaries, capable of launching the real executable.

Usage

Running the program normally, exactly as a user would:

cargo run -- src/file/bp.txt

Running every test, unit and integration combined:

cargo test

Running only the unit tests:

cargo test --bin arg-analyzer

Running only the integration tests:

cargo test --test integration

CI

A short GitHub Actions workflow runs the same cargo test on every push and pull request:

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@1.96.0
      - run: cargo test

dtolnay is the GitHub handle of David Tolnay, a well known Rust contributor behind several widely used crates such as serde, syn, and anyhow.

His rust-toolchain GitHub Action is a third party, not an official one from GitHub or Rust, but it has become the standard in the Rust community for installing a specific toolchain version on a runner.

Conclusion

The most fragile part of this project was never the file system check or the argument count, it was deciding where ownership starts and stops.

Every time a String showed up instead of a &str, or &[String] instead of Vec<String>, the question behind it was the same: does this piece of code need to keep this value alive on its own, or is it enough to look at it while someone else keeps it alive?

Unit tests and integration tests follow the same rule from opposite ends: one borrows a value just long enough to check it, the other watches the whole program run without ever stepping inside it.

Good job, you did it.