LearningRust.org
LessonsPlaygroundAbout
Sign In
Lessons/advanced/CLI and Args
PreviousPracticeNext

TL;DR

Build command-line tools in Rust by parsing arguments, handling flags, and structuring real-world CLI applications

Key concepts

  • Rust CLI tutorial
  • Rust command line arguments
  • Rust CLI application
  • Rust argument parsing
  • Rust terminal tools
Loading...

Next lesson

Capstone Project: CLI Todo App

Build a complete command-line todo application combining structs, enums, error handling, traits, and collections

35 min

Related lessons

  • LifetimesRust lifetimes explained — learn lifetime annotations, elision rules, and how the borrow checker validates reference validity
  • Traits & GenericsLearn to write flexible, reusable code with Rust's trait system and generic programming
  • ConcurrencyLearn safe concurrent programming in Rust with threads, message passing, and shared state

Also learn

ZigLearn Zig ProgrammingTypeScriptMaster TypeScript

Also learn

ZigLearn Zig ProgrammingTypeScriptMaster TypeScript

A14A

Building digital products that matter.

© 2026 A14A. All rights reserved.
KVK: 87105004PrivacyTerms

CLI and Args

Rust is one of the best languages for building command-line tools. The ecosystem is mature, binaries are small and fast, and the type system helps you handle edge cases before your users ever encounter them. In this lesson, you'll learn how to read arguments from the command line, validate input, and structure a real CLI application using the standard library.

Reading Arguments with std::env::args

Every Rust program has access to its command-line arguments through std::env::args(). This returns an iterator of String values — the first being the program name, followed by any arguments the user passed.

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

    println!("Total arguments: {}", args.len());

    for (index, arg) in args.iter().enumerate() {
        println!("  args[{}] = {:?}", index, arg);
    }
}

In the playground above, args[0] will be the binary path. When you run a real CLI tool like ./mytool hello world, args[1] is "hello" and args[2] is "world".

Handling Missing Arguments Gracefully

A robust CLI tool never panics when arguments are missing — it falls back to defaults or prints a helpful message. The get method on slices returns an Option, making this pattern clean and safe.

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

    // Use a default value when no argument is provided
    let name = args.get(1).map(String::as_str).unwrap_or("World");
    let times: u32 = args
        .get(2)
        .and_then(|s| s.parse().ok())
        .unwrap_or(1);

    for _ in 0..times {
        println!("Hello, {}!", name);
    }
}

Notice the use of .and_then(|s| s.parse().ok()) — this chains Option operations cleanly. If the argument doesn't exist or can't be parsed as a number, we fall back to 1. No unwrap panics, no verbose if let chains.

Parsing Flags and Subcommands

Many CLI tools accept flags like --verbose or subcommands like git commit. You can implement basic flag parsing by scanning arguments for known patterns.

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

    let verbose = args.contains(&String::from("--verbose"));
    let subcommand = args.get(1).map(String::as_str);

    match subcommand {
        Some("--verbose") | None => {
            println!("No subcommand given. Usage: tool <command> [--verbose]");
        }
        Some("greet") => {
            let name = args.get(2).map(String::as_str).unwrap_or("stranger");
            if verbose {
                println!("[DEBUG] Running greet command with name={:?}", name);
            }
            println!("Hey there, {}!", name);
        }
        Some("count") => {
            let n: usize = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
            if verbose {
                println!("[DEBUG] Counting to {}", n);
            }
            for i in 1..=n {
                println!("{}", i);
            }
        }
        Some(unknown) => {
            eprintln!("Unknown command: {:?}", unknown);
            std::process::exit(1);
        }
    }
}

A few things worth noting here:

  • eprintln! writes to stderr, which is the correct stream for error messages.
  • std::process::exit(1) terminates with a non-zero exit code, signalling failure to the shell.
  • The --verbose flag can appear anywhere in the args list because we use contains rather than positional indexing.

Building a Mini Calculator

Let's put everything together in a realistic example — a small calculator that takes an operation and two numbers as arguments.

fn print_usage() {
    eprintln!("Usage: calc <add|sub|mul|div> <number> <number>");
    eprintln!("Example: calc add 10 3");
}

fn parse_number(s: &str, label: &str) -> Result<f64, String> {
    s.parse::<f64>()
        .map_err(|_| format!("'{}' is not a valid number for {}", s, label))
}

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

    if args.len() < 4 {
        print_usage();
        std::process::exit(1);
    }

    let operation = &args[1];

    let a = match parse_number(&args[2], "first argument") {
        Ok(n) => n,
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    };

    let b = match parse_number(&args[3], "second argument") {
        Ok(n) => n,
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    };

    let result = match operation.as_str() {
        "add" => Ok(a + b),
        "sub" => Ok(a - b),
        "mul" => Ok(a * b),
        "div" => {
            if b == 0.0 {
                Err(String::from("Division by zero"))
            } else {
                Ok(a / b)
            }
        }
        op => Err(format!("Unknown operation: '{}'", op)),
    };

    match result {
        Ok(value) => println!("{} {} {} = {}", a, operation, b, value),
        Err(e) => {
            eprintln!("Error: {}", e);
            std::process::exit(1);
        }
    }
}

This example shows a complete CLI pattern: validate argument count early, parse each argument with a helper that returns Result, handle errors at each step, and use exit codes to communicate success or failure.

Try It Yourself

Extend the template below into a small word-count tool. It currently counts arguments — modify it to also detect a --upper flag that transforms the output to uppercase, and print a summary line showing how many "words" were processed.

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

    // Skip args[0] (the program name)
    let words: Vec<&str> = args[1..]
        .iter()
        .filter(|a| !a.starts_with("--"))
        .map(String::as_str)
        .collect();

    let upper = args.contains(&String::from("--upper"));

    for word in &words {
        if upper {
            println!("{}", word.to_uppercase());
        } else {
            println!("{}", word);
        }
    }

    println!("---");
    println!("Processed {} word(s)", words.len());
}

Try adding a --count flag that only prints the summary line without listing each word. Then try adding a --reverse flag that prints the words in reverse order.

Key Takeaways

  • std::env::args() returns an iterator — collect it into a Vec<String> to work with arguments by index.
  • args[0] is always the program name; user-provided arguments start at index 1.
  • Use .get(n) instead of direct indexing to avoid panics when arguments are missing.
  • Chain Option methods like .map, .and_then, and .unwrap_or for clean, safe argument parsing.
  • Write errors to stderr with eprintln! and exit with a non-zero code using std::process::exit(1).
  • Validate argument count early and show usage instructions before attempting any parsing.
  • For production CLI tools, consider the clap crate — it handles flags, subcommands, help generation, and shell completions automatically.

Pro Tip: Always test your CLI tool with zero arguments, one argument, and invalid types for numeric arguments. These three cases catch the vast majority of real-world user errors, and Rust's Option and Result types make handling them explicit rather than leaving them as silent panics.


## Next Steps

You've built a solid CLI tool! Now it's time to put everything together in the capstone project — a complete application that combines structs, enums, error handling, traits, collections, and more.

Ready to continue? Head to [Capstone Project](/lessons/15-capstone-project)!