Table of Contents
- Why Rust for CLI Tools?
- Setting Up Your Rust Environment
- Your First Rust CLI Tool: “Hello World”
- Parsing Command-Line Arguments with
clap - Handling Input/Output
- Error Handling: Robust and User-Friendly
- Subcommands: Organizing Complex Tools
- Testing CLI Tools
- Distributing Your CLI Tool
- Conclusion
- References
Why Rust for CLI Tools?
Rust’s unique combination of features makes it ideal for CLI development:
- Performance: Rust compiles to native code with minimal overhead, ensuring fast execution—critical for CLI tools that run frequently.
- Safety: Memory safety (no null pointers, buffer overflows) and thread safety prevent crashes and security vulnerabilities.
- Ergonomics: Rust’s expressive type system and modern tooling (Cargo,
rustfmt,clippy) streamline development. - Ecosystem: Rich crates (libraries) like
clap(argument parsing),thiserror(error handling), andassert_cmd(testing) simplify building professional-grade tools.
Examples of popular Rust CLI tools: ripgrep (fast search), exa (ls alternative), bat (cat alternative), and fd-find (find alternative).
Setting Up Your Rust Environment
Before building, install Rust and its toolchain:
-
Install Rust: Use
rustup, the Rust toolchain manager:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shFollow the prompts to add
cargo(Rust’s package manager) to your PATH. -
Verify Installation:
rustc --version # Should print Rust version (e.g., rustc 1.75.0) cargo --version # Should print Cargo version (e.g., cargo 1.75.0) -
Create a New Project: Use Cargo to scaffold a new CLI project:
cargo new my_cli --bin # --bin creates an executable (not a library) cd my_cli
Cargo generates a basic project structure:
src/main.rs: Entry point of your CLI tool.Cargo.toml: Manifest file for dependencies and project metadata.
Your First Rust CLI Tool: “Hello World”
Let’s start with a minimal “Hello World” CLI. Open src/main.rs and replace its contents with:
fn main() {
println!("Hello, CLI World!");
}
Running the Tool
-
Compile and run:
cargo run # Output: Hello, CLI World! -
Pass arguments: To accept user input, read command-line arguments. Modify
main.rs:use std::env; fn main() { let args: Vec<String> = env::args().collect(); // `args[0]` is the program name (e.g., `my_cli`), so skip it. if let Some(name) = args.get(1) { println!("Hello, {}!", name); } else { println!("Hello, World!"); } }Test it:
cargo run -- Alice # Use `--` to separate Cargo args from your tool's args # Output: Hello, Alice!
This works for simple cases, but for real-world tools, we need robust argument parsing—enter clap.
Parsing Command-Line Arguments with clap
clap (Command Line Argument Parser) is the de facto standard for Rust CLI argument parsing. It supports flags, options, subcommands, and auto-generated help messages.
Adding clap to Your Project
Add clap to Cargo.toml with the derive feature (for easy struct-based parsing):
[dependencies]
clap = { version = "4.4", features = ["derive"] } # Use the latest version
Basic Argument Parsing with clap
Define a struct to represent your CLI arguments, then derive clap::Parser to auto-generate parsing logic. Update src/main.rs:
use clap::Parser;
/// A simple CLI tool to greet users
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None)] // Auto-generate --help and --version
struct Cli {
/// Name of the person to greet
#[clap(short, long)] // Allow `-n` or `--name` flag
name: Option<String>,
/// Number of times to greet
#[clap(short, long, default_value_t = 1)] // Default: 1
count: u8,
}
fn main() {
let cli = Cli::parse(); // Parse arguments into `cli` struct
let name = cli.name.unwrap_or_else(|| "World".to_string());
for _ in 0..cli.count {
println!("Hello, {}!", name);
}
}
Testing the Tool
Run with --help to see auto-generated documentation:
cargo run -- --help
Output:
A simple CLI tool to greet users
Usage: my_cli [OPTIONS]
Options:
-n, --name <NAME> Name of the person to greet
-c, --count <COUNT> Number of times to greet [default: 1]
-h, --help Print help
-V, --version Print version
Test with arguments:
cargo run -- --name Bob --count 3
# Output:
# Hello, Bob!
# Hello, Bob!
# Hello, Bob!
Handling Input/Output
CLIs often read from stdin (standard input) and write to stdout (standard output) or stderr (standard error). Rust’s std::io module simplifies this.
Example: Read from stdin and Process
Let’s build a tool that reads text from stdin, converts it to uppercase, and writes to stdout:
use clap::Parser;
use std::io::{self, Read};
#[derive(Parser, Debug)]
#[clap(about = "Convert input to uppercase")]
struct Cli;
fn main() -> io::Result<()> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?; // Read all input into `input`
let output = input.to_uppercase();
println!("{}", output);
Ok(())
}
Test by piping input:
echo "hello rust" | cargo run
# Output: HELLO RUST
Writing to stderr
Use eprintln! for error messages (avoids polluting stdout):
if let Err(e) = some_operation() {
eprintln!("Error: {}", e); // Prints to stderr
std::process::exit(1); // Exit with non-zero code to signal failure
}
Error Handling: Robust and User-Friendly
CLI tools must handle errors gracefully. Rust’s Result type and ? operator simplify error propagation, but for user-friendly errors, use thiserror to define custom error types.
Adding thiserror
Add thiserror to Cargo.toml:
[dependencies]
thiserror = "1.0" # For custom error types
Defining Custom Errors
Create a custom error enum with thiserror:
use thiserror::Error;
#[derive(Error, Debug)]
enum CliError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid number: {0}")]
InvalidNumber(#[from] std::num::ParseIntError),
#[error("Name cannot be empty")]
EmptyName,
}
Using Custom Errors in main
Update main to return Result<(), CliError>:
use clap::Parser;
#[derive(Parser, Debug)]
struct Cli {
#[clap(short, long)]
name: Option<String>,
#[clap(short, long)]
age: Option<String>,
}
fn main() -> Result<(), CliError> {
let cli = Cli::parse();
// Validate name
let name = cli.name.ok_or(CliError::EmptyName)?;
// Parse age (converts ParseIntError to CliError::InvalidNumber)
let age: u32 = cli.age.ok_or(CliError::InvalidNumber("age is required".into()))?
.parse()?;
println!("Hello, {}! You are {} years old.", name, age);
Ok(())
}
Test error cases:
cargo run -- --age "not_a_number"
# Output: Error: Invalid number: invalid digit found in string
Subcommands: Organizing Complex Tools
For tools with multiple actions (e.g., git add, git commit), use subcommands. clap supports subcommands via #[clap(subcommand)].
Example: Tool with Subcommands
Define subcommands as an enum:
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[clap(about = "A multi-purpose CLI tool")]
struct Cli {
#[clap(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Greet a user
Greet {
/// Name of the person to greet
name: String,
},
/// Calculate the square of a number
Square {
/// Number to square
number: i32,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Greet { name } => {
println!("Hello, {}!", name);
}
Commands::Square { number } => {
println!("{} squared is {}", number, number * number);
}
}
}
Test subcommands:
cargo run -- greet Alice
# Output: Hello, Alice!
cargo run -- square 5
# Output: 5 squared is 25
Run cargo run -- --help to see subcommand documentation.
Testing CLI Tools
Testing CLI tools requires validating output, exit codes, and error messages. Use assert_cmd and predicates for integration testing.
Adding Testing Dependencies
Add to Cargo.toml:
[dev-dependencies]
assert_cmd = "2.0" # Run CLI and check output
predicates = "3.0" # Assertions for output
Writing Integration Tests
Create tests/cli.rs (Cargo runs tests in tests/ directory):
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_greet_subcommand() {
let mut cmd = Command::cargo_bin("my_cli").unwrap(); // Get path to binary
cmd.arg("greet").arg("Alice");
cmd.assert().success().stdout(predicate::str::contains("Hello, Alice!"));
}
#[test]
fn test_square_subcommand() {
let mut cmd = Command::cargo_bin("my_cli").unwrap();
cmd.arg("square").arg("5");
cmd.assert().success().stdout("5 squared is 25\n");
}
#[test]
fn test_empty_name_error() {
let mut cmd = Command::cargo_bin("my_cli").unwrap();
cmd.arg("greet"); // Missing name
cmd.assert().failure().stderr(predicate::str::contains("error: the following required arguments were not provided"));
}
Run tests with cargo test.
Distributing Your CLI Tool
Once your tool is ready, distribute it to users via:
1. cargo install
Users can install directly from crates.io:
cargo install my_cli # Requires publishing to crates.io
To install locally (for testing):
cargo install --path . # Installs from current directory
2. Publishing to Crates.io
- Create an account at crates.io.
- Login with
cargo login <your-token>. - Publish with
cargo publish(ensureCargo.tomlhasname,version, andauthors).
3. Cross-Compiling
Use cross (a wrapper for cargo) to build for other platforms:
# Install cross
cargo install cross
# Build for Windows (64-bit)
cross build --target x86_64-pc-windows-gnu
# Build for Linux (ARM)
cross build --target aarch64-unknown-linux-gnu
Conclusion
Rust’s performance, safety, and ecosystem make it a powerhouse for building CLI tools. With clap for argument parsing, thiserror for error handling, and assert_cmd for testing, you can create robust, user-friendly tools. Whether you’re building a simple script or a complex utility, Rust equips you with the tools to succeed.
Now, go build something awesome!