Building a Simple CLI Tool

A command-line interface (CLI) tool is a program you run from a terminal that reads input from arguments, files, or standard input, and prints output as text. Rust is an excellent language for this job: it compiles down to a single, fast, dependency-free binary with no runtime or garbage collector to ship alongside it, and its ownership system catches whole classes of bugs — dangling pointers, use-after-free, unexpected mutation — at compile time instead of in production. In this lesson you will build a small CLI tool from scratch using nothing but Rust’s standard library: reading command-line arguments, reading and writing files, and reporting errors the idiomatic way.

Overview: how a Rust CLI program is put together

When you run a program from a shell — for example mytool fox animals.txt — the operating system starts your compiled binary as a new process and hands it an array of strings called argv. That array always includes the path used to invoke the program as its first element, followed by whatever the user typed after it. Rust exposes this array through std::env::args(), which returns an iterator of owned String values. Nothing is borrowed from the operating system here — each string is copied into freshly allocated, heap-owned Rust memory the moment your program starts, so you are free to store, clone, or move these strings however you like without worrying about their original source disappearing.

A well-structured CLI tool usually separates three concerns: parsing the raw arguments into a typed configuration, running the actual logic against that configuration, and reporting success or failure with an appropriate exit code. Trace it through by hand: if a user runs mytool fox animals.txt, env::args() yields the iterator ["mytool", "fox", "animals.txt"] (three owned Strings). Your code collects that into a Vec<String>, reads index 1 as the search term and index 2 as the file path, builds a small struct to hold those two values, and passes that struct — by reference — into a function that does the real work. This pattern (often called the "minigrep" shape, from the official Rust book) keeps argument parsing, business logic, and error handling each in their own place, which makes the program both easier to read and easier to test.

Error handling in a CLI tool is different from a library: instead of always propagating an error up to a caller, at some point you must decide what to print to the user and what exit code to hand back to the shell (0 for success, non-zero for failure, by Unix convention). You’ll see both styles in this lesson: functions that return Result and use the ? operator to propagate errors, and a top-level main that pattern-matches on the final result to decide what to print and whether to call std::process::exit.

Syntax

The general shape of a small CLI program looks like this:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let program_name = &args[0];
    let user_args = &args[1..];

    // program_name is the binary's own path (e.g. "./mytool")
    // user_args holds everything the caller typed after it
}
Item Signature / form What it does
env::args() fn args() -> Args Returns an iterator of owned Strings, argv[0] included. Panics if any argument is not valid UTF-8; use env::args_os() if you must accept arbitrary bytes.
args.get(n) fn get(&self, n: usize) -> Option<&String> Safe indexing — returns None instead of panicking when n is out of bounds.
args[n] indexing operator Direct indexing — panics at runtime if n is out of bounds.
fs::read_to_string(path) fn read_to_string(path) -> Result<String, io::Error> Reads an entire file into a new owned String.
fs::write(path, contents) fn write(path, contents) -> Result<(), io::Error> Creates or overwrites a file with the given contents.
process::exit(code) fn exit(code: i32) -> ! Immediately terminates the process with the given exit code; never returns.
eprintln! macro Like println!, but writes to standard error instead of standard output — use it for error messages so they don’t get mixed into piped output.

Examples

Example 1: a greeting tool with a default

The simplest possible CLI tool reads one optional argument and falls back to a default when it’s missing.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let name: &str = if args.len() > 1 {
        &args[1]
    } else {
        "stranger"
    };

    println!("Hello, {}! Welcome to the CLI.", name);
}

Output:

Hello, stranger! Welcome to the CLI.

When run with no extra arguments, args has length 1 (just the program’s own path), so the if branch is skipped and name falls back to the string literal "stranger". Notice the type of name is declared as &str: the if branch produces &args[1], which is a &String, and Rust automatically coerces that to &str to match the other branch and the declared type. If you ran this with mytool Ferris instead, args[1] would be "Ferris" and it would print Hello, Ferris! Welcome to the CLI.

Example 2: counting words in a file

Real CLI tools usually touch the filesystem. This example writes a small sample file and then reads it back — that keeps the example self-contained and its output predictable, but the same fs::read_to_string call works identically on any file path you already have on disk.

use std::env;
use std::fs;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let path = if args.len() > 1 {
        args[1].clone()
    } else {
        "sample.txt".to_string()
    };

    if let Err(e) = fs::write(&path, "the quick brown fox jumps over the lazy dog\n") {
        eprintln!("Could not create sample file: {}", e);
        process::exit(1);
    }

    let contents = match fs::read_to_string(&path) {
        Ok(text) => text,
        Err(e) => {
            eprintln!("Error reading {}: {}", path, e);
            process::exit(1);
        }
    };

    let word_count = contents.split_whitespace().count();
    println!("{} has {} words", path, word_count);
}

Output:

sample.txt has 9 words

With no arguments, path defaults to "sample.txt". The program writes a nine-word sentence to that file, then reads it straight back with fs::read_to_string, which returns Result<String, io::Error>. The match unwraps the success case into contents; the error arm prints a message to standard error and exits with status 1 — note that arm’s block has type String too, because process::exit returns the special "never" type !, which Rust lets you use anywhere a value is expected. split_whitespace().count() then counts the words.

Example 3: a tiny search tool with a Config struct

This example puts the full pattern together: a Config struct built from arguments, a separate run function that does the work and returns a Result, and a main that decides how to report failure.

use std::env;
use std::fs;
use std::process;

struct Config {
    query: String,
    file_path: String,
}

impl Config {
    fn build(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("usage: search <query> <file_path>");
        }
        let query = args[1].clone();
        let file_path = args[2].clone();
        Ok(Config { query, file_path })
    }
}

fn run(config: &Config) -> Result<(), std::io::Error> {
    let contents = fs::read_to_string(&config.file_path)?;

    for (line_number, line) in contents.lines().enumerate() {
        if line.contains(config.query.as_str()) {
            println!("{}: {}", line_number + 1, line);
        }
    }

    Ok(())
}

fn main() {
    let raw_args: Vec<String> = env::args().collect();

    let args = vec![
        raw_args[0].clone(),
        "fox".to_string(),
        "animals.txt".to_string(),
    ];

    let config = Config::build(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    fs::write(&config.file_path, "the fox ran\nthe dog slept\na fox and a hound\n")
        .expect("failed to write sample file");

    if let Err(e) = run(&config) {
        eprintln!("Application error: {}", e);
        process::exit(1);
    }
}

Output:

1: the fox ran
3: a fox and a hound

The real arguments coming from env::args() are simulated here as ["mytool", "fox", "animals.txt"] so the example is deterministic; in a real binary those three values would come straight from the command line the user typed. Config::build takes a borrowed slice &[String] (it doesn’t need to own the whole vector, just to read two entries out of it), clones the two strings it needs into its own Config, and returns Result<Config, &'static str>. run takes &Config by reference, uses ? to bail out early if the file can’t be read, and otherwise scans each line with .lines(), printing the 1-based line number for every line containing the query.

How it works step by step

Walking through Example 3 from process start to exit shows exactly where ownership moves and where it’s just borrowed:

  • The OS starts the process and hands it argv. env::args() decodes each byte sequence as UTF-8 and yields owned Strings one at a time (it panics if a value isn’t valid UTF-8 — use args_os() if that’s a real concern for your tool).
  • .collect() pulls the whole iterator into a Vec<String>. Each String in that vector owns its own heap allocation; the vector owns the Strings, and raw_args owns the vector.
  • Config::build(&args) only borrows the vector as a slice — ownership never transfers into the function. Inside, args[1].clone() and args[2].clone() allocate two brand-new Strings that the returned Config now owns independently; the original args vector is completely untouched and still valid back in main.
  • run(&config) borrows Config immutably. fs::read_to_string allocates yet another new String, moving ownership of it into the local variable contents.
  • contents.lines() does not allocate anything new — it borrows contents and yields &str slices that point directly into the memory contents already owns.
  • When run returns, contents goes out of scope and Rust automatically calls its destructor, freeing that heap allocation. When main ends, config, args, and raw_args are dropped in reverse order of declaration, and every String they (transitively) own is freed — all without a garbage collector and without you writing a single free call.

Common Mistakes

Mistake 1: indexing arguments without checking length

std::env::args() always includes the program’s own path at index 0, so the first user-supplied argument is at index 1 — and if the user didn’t pass one, that index simply doesn’t exist. Indexing with [] panics at runtime instead of failing to compile, which makes this an easy trap:

let args: Vec<String> = std::env::args().collect();
let name = &args[1];
println!("Hello, {}", name);

This compiles fine, because the compiler has no way to know how many arguments will exist at runtime — but if the tool is run with no extra arguments, args has length 1 and args[1] panics with index out of bounds, crashing before anything is printed. The fix is to use .get(), which returns an Option instead of panicking:

let args: Vec<String> = std::env::args().collect();
let name = match args.get(1) {
    Some(value) => value.as_str(),
    None => "stranger",
};
println!("Hello, {}", name);

Mistake 2: using a String after moving it into a function

If a function parameter takes ownership (String instead of &str), passing a variable into it moves the value out of the caller. Trying to use that variable again afterward is a compile error, not a runtime bug — the borrow checker catches it before the program can ever run:

fn print_file(path: String) {
    let contents = std::fs::read_to_string(path).unwrap();
    println!("{}", contents);
}

fn main() {
    let path = String::from("notes.txt");
    print_file(path);
    println!("Reading {} again", path);
}

This fails to compile with error[E0382]: borrow of moved value: \`path\`: the call print_file(path) moves path into the function, so by the time the final println! runs, path is no longer a valid binding in main. The fix is almost always to have the function borrow instead of take ownership, since it only needs to read the string:

fn print_file(path: &str) {
    let contents = std::fs::read_to_string(path).unwrap();
    println!("{}", contents.trim());
}

fn main() {
    let path = String::from("notes.txt");
    std::fs::write(&path, "meeting at 3pm").unwrap();

    print_file(&path);
    println!("Reading {} again is fine because we only borrowed it", path);
}

Output:

meeting at 3pm
Reading notes.txt again is fine because we only borrowed it

Because print_file now takes &str, calling it with &path only lends the string out temporarily; main still owns path afterward and can keep using it.

Mistake 3: reaching for .unwrap() on user-controlled input

.unwrap() is fine for a guaranteed-Ok/Some value in a small teaching snippet, but a CLI tool’s file paths and arguments come from the user, not from you. Calling .unwrap() on fs::read_to_string(user_supplied_path) means a simple typo in a file name crashes the whole program with an unhelpful panicked at 'called \`Result::unwrap()\` on an \`Err\` value...' message and a nonzero-but-arbitrary exit status. The run function in Example 3 shows the better habit: propagate the error with ? and let main decide how to present it with eprintln! and a deliberate process::exit code, so failures are readable and scriptable instead of a raw panic dump.

Best Practices

  • Separate argument parsing (build a Config) from program logic (a run function) — it makes both pieces independently testable.
  • Use args.get(n) instead of args[n] whenever a missing argument is a real possibility, and give a clear usage message when validation fails.
  • Send error messages to standard error with eprintln!, and reserve println! for the program’s real output — this keeps piped output (mytool | grep ...) clean.
  • Exit with 0 on success and a nonzero code on failure via std::process::exit, so shell scripts and CI pipelines can detect success or failure correctly.
  • Prefer &str parameters for functions that only need to read a string, and reserve owned String parameters for functions that need to store or mutate the value.
  • For anything beyond a handful of flags, reach for a well-established argument-parsing crate (such as clap) instead of hand-rolling a parser — the standard library’s env::args() is a fine foundation to learn on, but real tools quickly want flags, subcommands, and auto-generated --help text.

Practice Exercises

  • Extend the greeting tool from Example 1 so a second argument controls the greeting word (for example mytool Ferris Hi should print Hi, Ferris!), defaulting to "Hello" when it’s missing. Hint: check args.len() > 2.
  • Modify the word-counter from Example 2 to also report the number of lines (contents.lines().count()) and characters (contents.chars().count()) alongside the word count.
  • Make the search tool from Example 3 case-insensitive by lower-casing both the query and each line with .to_lowercase() before comparing them with .contains(...). Expected output for query "FOX" against the same sample file should be identical to the original example’s output.

Summary

  • std::env::args() gives you an iterator of owned Strings from the OS’s argv, with the program’s own path at index 0.
  • Prefer args.get(n) over args[n] to avoid runtime panics on missing arguments.
  • Separate argument parsing into a small Config-style struct, and put the actual work in its own function that returns a Result so errors can be propagated with ?.
  • fs::read_to_string and fs::write are the simplest ways to read and write whole files, both returning Result<_, io::Error>.
  • Borrow (&str, &Config) instead of taking ownership whenever a function only needs to read data — it avoids move errors and keeps the caller’s data usable afterward.
  • Report errors with eprintln! and exit with a meaningful nonzero status via process::exit, rather than letting .unwrap() panics leak into production.