8 Rust Crates That Transform Ugly CLI Scripts Into Tools People Actually Want to Use

Build polished Rust CLI tools with 8 essential crates. Learn to use clap, indicatif, colored, and more with real code examples. Start building better tools today.

8 Rust Crates That Transform Ugly CLI Scripts Into Tools People Actually Want to Use

I started building command-line tools in Rust because I wanted something fast that I could ship as a single binary. No runtime dependencies, no interpretive overhead—just a file that runs on any machine. The first few tools I wrote were shamefully ugly. Arguments were parsed by hand with std::env::args(), output was plain text, and if a user pressed the wrong key the program would panic. Over time I discovered the crate ecosystem that turns a weekend script into a tool that people actually enjoy using. This article is a tour of eight crates I now reach for in every CLI project. I’ll show you exactly how to use them, with code you can copy and adapt. I’ll also tell you where I tripped up so you don’t have to.

Let’s start with the foundation: argument parsing. Before I met clap, I wrote argument parsers that looked like a stack of if statements. They worked for two flags but became unmanageable at ten. clap changed everything. You define a struct with fields that represent your CLI’s options, flags, and positional arguments, and you derive Parser on it. The crate automatically generates help text, error messages, and even shell completions. I use the derive API because it keeps the parsing logic separate from the business logic and reads like documentation.

use clap::Parser;

#[derive(Parser)]
#[command(name = "filer", version = "1.0", about = "A simple file organizer")]
struct Cli {
    /// Path to the directory to organize
    dir: String,

    /// Recursive mode
    #[arg(short, long)]
    recursive: bool,

    /// Dry run – only show what would be done
    #[arg(short = 'n', long)]
    dry_run: bool,

    /// Output format: text or json
    #[arg(long, default_value = "text")]
    format: String,
}

fn main() {
    let args = Cli::parse();
    println!("Organizing {} (recursive: {}, dry: {}, format: {})",
        args.dir, args.recursive, args.dry_run, args.format);
}

The #[arg] attribute lets me set short flags, long names, defaults, and help descriptions. I no longer worry about parsing -h or --helpclap handles it. One lesson: always test your argument parsing with invalid inputs early. I once forgot to mark a field as Option for an optional flag, and trying to run the tool without it would panic with a cryptic message. Now I annotate everything carefully.

Now imagine running a command that takes ten seconds to process files. The user stares at a blank terminal and wonders if the program hung. indicatif gives them a progress bar that moves, a spinner that spins, and an estimate of remaining time. It uses the terminal’s ability to update a line without scrolling, so the output stays clean.

use indicatif::{ProgressBar, ProgressStyle};
use std::{thread, time::Duration};

fn main() {
    let total = 200;
    let pb = ProgressBar::new(total);
    pb.set_style(ProgressStyle::default_bar()
        .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
        .unwrap()
        .progress_chars("=>-"));

    for i in 0..total {
        // Simulate work
        thread::sleep(Duration::from_millis(10));
        pb.inc(1);
    }
    pb.finish_with_message("All done!");
}

I used indicatif in a tool that copies thousands of files. The first version had no progress bar, and I got complaints that the tool “felt dead”. After adding the bar, users told me they finally trusted it to work. The key is to set a meaningful length and update often. For tasks where the total is unknown, use a spinner style instead.

Next, colors. When you print errors, warnings, and success messages, plain white text can be hard to scan. colored lets you add color to specific parts of a string. It works on Windows, macOS, and Linux without extra configuration. I use it to highlight paths, numbers, and statuses.

use colored::Colorize;

fn main() {
    let file = "config.yml";
    println!("{} {}", "[OK]".green().bold(), file);
    println!("{} {}", "[WARN]".yellow(), "Disk space low");
    println!("{} {}", "[ERR]".red().bold(), "Cannot read database");
}

A common mistake: applying too many colors. I once colored every piece of output in rainbow style and users with visual impairments found it hard to read. Now I limit to three colors: green for success, yellow for caution, red for errors. The rest stays default. colored also supports background colors, underlines, and blinking, but I rarely use them because they can be distracting.

Sometimes your tool needs to ask the user something interactively. Maybe you want to confirm a dangerous operation, pick from a list, or enter a password without echoing to the screen. dialoguer provides all those prompts in a way that respects whether the terminal is actually interactive.

use dialoguer::{Input, Select, Confirmation, Password};

fn main() {
    let name: String = Input::new()
        .with_prompt("Enter your name")
        .interact_text()
        .unwrap();

    let choices = &["Small", "Medium", "Large"];
    let size = Select::new()
        .with_prompt("Choose size")
        .items(choices)
        .default(1)
        .interact()
        .unwrap();

    let confirm = Confirmation::new()
        .with_prompt("Proceed with these settings?")
        .interact()
        .unwrap();

    let password = Password::new()
        .with_prompt("Enter access code")
        .with_confirmation("Confirm code", "Codes do not match")
        .interact()
        .unwrap();

    if confirm {
        println!("Hello {}, size {}, code entered: {}",
            name, choices[size], password.len());
    }
}

I built a deployment tool that asked for credentials and a target environment. Without dialoguer, I would have read from stdin and hoped the user knew how to pipe input. The crate handles backspace, CTRL+C, and even falls back to reading line by line if the terminal doesn’t support fancy prompts.

Shell completions. If your CLI has many subcommands and flags, users will love tab completion. clap_complete works with clap to generate completion scripts for Bash, Zsh, Fish, PowerShell, and others. The typical way is to add a hidden subcommand like completions that prints the script.

use clap::{CommandFactory, Parser};
use clap_complete::{Generator, Shell};

#[derive(Parser)]
#[command(name = "mycli", subcommand_negates_reqs = true)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(clap::Subcommand)]
enum Commands {
    /// Generate shell completions
    Completions { shell: Shell },
}

fn main() {
    let cli = Cli::parse();
    if let Some(Commands::Completions { shell }) = cli.command {
        let mut cmd = Cli::command();
        let name = cmd.get_name().to_string();
        clap_complete::generate(shell, &mut cmd, &name, &mut std::io::stdout());
        return;
    }
    // ... rest of the app
}

I put this in every CLI tool now. Users can run mycli completions bash > /etc/bash_completion.d/mycli and instantly get tab completion. It took me ten minutes to implement and saved hundreds of people from typing long flag names.

Debugging a CLI can be painful because normal println! statements get mixed with regular output. That’s where env_logger comes in. It uses the log crate’s macros and controls verbosity via the RUST_LOG environment variable. During development I set RUST_LOG=debug to see every step. Users can set RUST_LOG=warn to see only warnings and errors.

use log::{info, warn, error};

fn main() {
    env_logger::init();

    info!("Starting backup");
    match backup_files() {
        Ok(_) => info!("Backup complete"),
        Err(e) => error!("Backup failed: {}", e),
    }
}

fn backup_files() -> Result<(), String> {
    warn!("No backup destination specified, using default");
    Ok(())
}

Run it: RUST_LOG=info ./myapp. You’ll see timestamps, module names, and the message. I once forgot to call init() and wondered why no logs appeared. The crate also supports custom formatting, but the default works for 90% of cases.

When your CLI outputs tabular data, like a list of files with sizes and dates, formatting columns by hand is error-prone. comfy_table builds tables that align automatically, handle Unicode, and respect terminal width. You can set a header, add rows, and even apply styles.

use comfy_table::{Table, Cell, CellAlignment};

fn main() {
    let mut table = Table::new();
    table.set_header(vec![
        Cell::new("Name").set_alignment(CellAlignment::Center),
        Cell::new("Size (MB)").set_alignment(CellAlignment::Right),
        Cell::new("Modified"),
    ]);

    table.add_row(vec!["report.pdf", "2.3", "2024-03-15"]);
    table.add_row(vec!["photo.jpg", "4.1", "2024-03-16"]);
    table.add_row(vec!["notes.txt", "0.1", "2024-03-14"]);

    println!("{table}");
}

I used comfy_table in a log analyzer that produced summary tables. The first version printed raw lines, and the columns never lined up. After switching to this crate, the output looked professional. You can also add separators, color cells, and use Unicode box drawing if you like.

Finally, the crate that gives you ultimate control: crossterm. It provides cross-platform primitives for moving the cursor, clearing the screen, reading individual key presses, and setting colors. It’s the foundation for many terminal UI libraries, but you can use it directly for simple interactive experiences.

use crossterm::{
    execute,
    style::{Color, Print, SetForegroundColor, ResetColor},
    terminal::{Clear, ClearType, size},
    cursor::{MoveTo, Hide, Show},
    event::{read, Event, KeyCode},
};
use std::io::{stdout, Write};

fn main() {
    let mut stdout = stdout();
    execute!(stdout, Hide, Clear(ClearType::All)).unwrap();

    loop {
        execute!(stdout, MoveTo(0, 0), Print("Press 'q' to quit")).unwrap();
        if let Event::Key( key ) = read().unwrap() {
            match key.code {
                KeyCode::Char('q') => break,
                _ => {}
            }
        }
    }

    execute!(stdout, Show, MoveTo(0, 20), Print("Goodbye!")).unwrap();
}

I used crossterm to build a simple TUI for a CLI game. It taught me how terminals actually work: clearing sections, moving the cursor, and reading raw input. The crate handles Windows consoles too, which many other terminal libraries used to ignore.

Now, how do these crates work together in real life? I’ll show you a small but complete example: a log tailer that colors lines by severity, displays a progress bar while loading a file, and accepts arguments via clap.

use clap::Parser;
use indicatif::ProgressBar;
use colored::*;
use log::{info, error};
use std::fs::File;
use std::io::{BufRead, BufReader};

#[derive(Parser)]
#[command(name = "logtail")]
struct Cli {
    /// Path to log file
    file: String,

    /// Follow the file (like tail -f)
    #[arg(short, long)]
    follow: bool,
}

fn main() {
    env_logger::init();
    let args = Cli::parse();
    info!("Opening {}", args.file);

    // Progress bar while reading initial lines
    let file = File::open(&args.file).expect("File not found");
    let reader = BufReader::new(&file);
    let lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect();
    let pb = ProgressBar::new(lines.len() as u64);
    for line in &lines {
        print_colored(line);
        pb.inc(1);
    }
    pb.finish_and_clear();

    if args.follow {
        // simplified: not real tail -f, just loop
        println!("{}", "Follow mode not fully implemented".yellow());
    }
}

fn print_colored(line: &str) {
    if line.contains("ERROR") {
        println!("{}", line.red());
    } else if line.contains("WARN") {
        println!("{}", line.yellow());
    } else {
        println!("{}", line);
    }
}

This article gave you eight crates. But the real power is in how you combine them. Start with clap for arguments. Add indicatif when operations take longer than half a second. Color important messages with colored. Use dialoguer for interactive prompts. Generate completions with clap_complete. Log with env_logger. Format tables with comfy_table. Take full terminal control with crossterm.

I built a tool called ripsearch that searches text in multiple files. It uses clap for flags like --regex, --color, --count. It uses indicatif to show progress when searching thousands of files. It uses colored to highlight matches. It uses comfy_table to show results in a table with file names and line numbers. It even offers a --format json option for piping into other tools. The combination of these crates made it feel like a polished utility from the get-go.

One final piece of advice: do not over-engineer. You might be tempted to add interactive prompts even for simple tools, but if the tool is meant to be used in scripts, those prompts will break automation. Always check if stdout is a terminal (using atty or crossterm’s is_stdout_a_terminal()). Provide a way to skip prompts with --yes or --no-confirm flags.

Writing a CLI in Rust is a joy because the ecosystem supports every piece you need. I hope these eight crates give you the foundation to build something you’re proud to ship. Now go write a tool that makes your colleagues ask, “How did you make that?”


// Keep Reading

Similar Articles