Phase 5Practical project

#18 Building a CLI tool

clap, args, subcommands

Parsing arguments

Rust provides std::env::args() to access command-line arguments. This works for simple tools, but quickly becomes tedious for complex CLIs:

Manual argument parsing
use std::env;

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

    // args[0] is the program name
    if args.len() < 2 {
        println!("Usage: {} <name>", args[0]);
        return;
    }

    let name = &args[1];
    println!("Hello, {}!", name);
}

For anything beyond trivial argument parsing, use the clap crate.

Using clap

clap is the most popular argument parsing library in Rust. Its derive macro lets you define your CLI interface as a struct:

Cargo.toml
[package]
name = "cli-tool"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
Basic clap usage
use clap::Parser;

/// A simple greeting program
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// Name of the person to greet
    #[arg(short, long)]
    name: String,

    /// Number of times to greet
    #[arg(short, long, default_value_t = 1)]
    count: u8,

    /// Use uppercase
    #[arg(short, long)]
    uppercase: bool,
}

fn main() {
    let args = Args::parse();

    for _ in 0..args.count {
        let greeting = format!("Hello, {}!", args.name);
        if args.uppercase {
            println!("{}", greeting.to_uppercase());
        } else {
            println!("{}", greeting);
        }
    }
}

With this definition, clap automatically generates --help, --version, error messages, and shell completions.

Subcommands

Real CLI tools often have subcommands (like git commit, cargo build). Define them as an enum:

Subcommands with clap
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(name = "cli-tool", version, about = "A simple CLI tool built with clap")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// Greet a user
    Greet {
        /// Name to greet
        #[arg(short, long)]
        name: String,
    },
    /// Count lines in a file
    Count {
        /// Path to the file
        path: String,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Greet { name } => {
            println!("Hello, {}!", name);
        }
        Commands::Count { path } => {
            match std::fs::read_to_string(&path) {
                Ok(content) => {
                    let lines = content.lines().count();
                    println!("{}: {} lines", path, lines);
                }
                Err(e) => eprintln!("Error: {}", e),
            }
        }
    }
}

Reading from stdin

Many CLI tools read from standard input, allowing them to be used in pipelines:

Reading from stdin
use std::io::{self, BufRead};

fn main() {
    let stdin = io::stdin();
    let mut total_lines = 0;
    let mut total_words = 0;

    for line in stdin.lock().lines() {
        let line = line.expect("Failed to read line");
        total_lines += 1;
        total_words += line.split_whitespace().count();
    }

    println!("Lines: {}", total_lines);
    println!("Words: {}", total_words);
}

You can also prompt for user input interactively:

Interactive input
use std::io::{self, Write};

fn prompt(message: &str) -> String {
    print!("{}", message);
    io::stdout().flush().unwrap();

    let mut input = String::new();
    io::stdin().read_line(&mut input).unwrap();
    input.trim().to_string()
}

fn main() {
    let name = prompt("Enter your name: ");
    let age = prompt("Enter your age: ");
    println!("Hello {}, you are {} years old!", name, age);
}

Putting it together

Here is a complete CLI tool that combines subcommands, file I/O, and error handling:

Complete CLI tool
use clap::{Parser, Subcommand};
use std::fs;
use std::io::{self, BufRead};

#[derive(Parser)]
#[command(name = "wordtool", version, about = "A word counting tool")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Count words in a file
    Count {
        /// File to process
        path: String,
    },
    /// Search for a word in a file
    Search {
        /// Word to search for
        word: String,
        /// File to search in
        path: String,
        /// Case-insensitive search
        #[arg(short, long)]
        ignore_case: bool,
    },
    /// Read from stdin and count
    Stdin,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Count { path } => {
            let content = fs::read_to_string(&path)?;
            let words: usize = content.split_whitespace().count();
            let lines = content.lines().count();
            println!("{}: {} words, {} lines", path, words, lines);
        }
        Commands::Search { word, path, ignore_case } => {
            let content = fs::read_to_string(&path)?;
            for (i, line) in content.lines().enumerate() {
                let matches = if ignore_case {
                    line.to_lowercase().contains(&word.to_lowercase())
                } else {
                    line.contains(&word)
                };
                if matches {
                    println!("{}:{}: {}", path, i + 1, line);
                }
            }
        }
        Commands::Stdin => {
            let stdin = io::stdin();
            let mut count = 0;
            for line in stdin.lock().lines() {
                count += line?.split_whitespace().count();
            }
            println!("Total words: {}", count);
        }
    }

    Ok(())
}

Summary

Quick reference
// Manual args
let args: Vec<String> = std::env::args().collect();

// clap derive
#[derive(Parser)]
struct Args {
    #[arg(short, long)]          // -n / --name
    name: String,
    #[arg(default_value_t = 1)]  // default value
    count: u8,
}

// Subcommands
#[derive(Subcommand)]
enum Commands {
    Greet { name: String },
    Count { path: String },
}

// Build release binary
cargo build --release
// Binary at: target/release/cli-tool

Your turn

Try running the CLI tool with cargo run, cargo run -- --help, and build a release binary with cargo build --release:

terminal — cargo
user@stemlegacy:~/cli-tool$