rust

**Why I Build Every Command-Line Tool in Rust: 8 Essential Libraries for CLI Development**

Build powerful CLI tools in Rust with essential libraries like clap, console & env_logger. Learn practical patterns for cross-platform development & distribution.

**Why I Build Every Command-Line Tool in Rust: 8 Essential Libraries for CLI Development**

I write command-line tools. It’s what I do. Over the years, I’ve built them in many languages, but today, I keep coming back to Rust. There’s a satisfying solidity to it. The tools start fast, they don’t crash in unexpected ways, and I can hand someone a single file—no installation dance required. But the real magic isn’t just the language; it’s the collection of libraries that turn a good idea into a robust, user-friendly application. I want to show you eight of these libraries that live in my toolbox. I’ll explain why I reach for them and how they fit together.

When you’re starting a new CLI project in Rust, the first question is almost always about handling arguments. You could parse them manually, but you quickly get lost in a maze of iterators and index checks. This is where clap comes in. It’s the foundation. I use its declarative style, where I define the structure of my commands right there in the code using attributes. It feels clean and self-documenting.

Here’s a basic scaffold I might start with. It defines a tool with subcommands, like git has add and commit.

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Compress the specified files
    Compress {
        files: Vec<std::path::PathBuf>,
        #[arg(short, long)]
        level: Option<u8>,
    },
    /// Decompress an archive
    Decompress {
        archive: std::path::PathBuf,
        #[arg(short, long)]
        output_dir: Option<std::path::PathBuf>,
    },
}

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

    match cli.command {
        Commands::Compress { files, level } => {
            let compression_level = level.unwrap_or(6);
            println!("Compressing {:?} at level {}", files, compression_level);
            // ... compression logic here
        }
        Commands::Decompress { archive, output_dir } => {
            let target = output_dir.unwrap_or_else(|| std::path::PathBuf::from("."));
            println!("Decompressing {:?} into {:?}", archive, target);
            // ... decompression logic here
        }
    }
}

The comments I write right above the enum variants become the help text. clap handles --help, --version, and even common errors. If a user provides an argument that doesn’t make sense, clap gives them a clear message. I don’t have to write that logic. This frees me to focus on what my tool actually does, not on arguing with the command line.

Once the arguments are parsed, the tool needs to communicate. Raw println! statements are fine for prototypes, but for a real tool, I want color, emphasis, and maybe a progress bar. This is where the console crate shines. It gives me a simple, cross-platform way to make output readable.

I use it to highlight success in green, warnings in yellow, and errors in red. It makes scanning the output much easier for the user. It can also handle simple user input.

use console::{style, Term};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let term = Term::stdout();

    // Simple colored output
    term.write_line(&format!("{} Operation started", style("[INFO]").blue()))?;

    // A styled prompt
    let user_input = term.read_line_initial_text(
        &style("? Enter target directory (or press Enter for current):").green().to_string(),
        ".",
    )?;

    println!("You selected: {}", style(user_input).cyan());

    // Simulating a task with a progress bar (conceptual)
    term.write_line("Processing files...")?;
    for i in 1..=10 {
        std::thread::sleep(std::time::Duration::from_millis(100));
        // In a real scenario, you'd use a proper progress bar struct from console
        term.write_line(&format!("  Completed {} of 10", i))?;
    }

    term.write_line(&format!("{} Done!", style("[SUCCESS]").green()))?;
    Ok(())
}

For me, the killer feature is that console checks if the output is actually a terminal. If I pipe the output to a file (my_tool > log.txt), it automatically strips the colors and control characters. I don’t have to think about it.

When things get more complex, especially during development, I need to see what’s happening inside the tool. This is where logging comes in. I use the env_logger crate with the standard log facade. It’s incredibly straightforward. I sprinkle info!, debug!, and error! macros throughout my code.

The beauty is in the control it gives the user. By default, my tool might only show info level messages and above. But if they’re troubleshooting, they can set an environment variable and see everything.

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

fn connect_to_server(address: &str) -> Result<(), String> {
    debug!("Attempting to resolve address: {}", address);
    // ... resolution logic
    info!("Connecting to {}", address);
    // ... connection logic
    if address.is_empty() {
        warn!("Address string was empty, using default");
        return connect_to_server("localhost:8080");
    }
    // ... if connection fails
    // error!("Connection failed: {}", e);
    Ok(())
}

fn main() {
    // This reads the RUST_LOG environment variable.
    // Default is to only show 'info' level and above.
    env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));

    info!("MyTool v{} starting up", env!("CARGO_PKG_VERSION"));
    debug!("Current working dir: {:?}", std::env::current_dir());

    if let Err(e) = connect_to_server("") {
        error!("Startup failed: {}", e);
        std::process::exit(1);
    }

    info!("Startup sequence complete.");
}

A user runs it normally, they see the basic info messages. If they hit a bug, they can run RUST_LOG=debug mytool --some-flag and get a flood of internal details. It turns a black box into a transparent one.

Most tools need to remember settings between runs. Where do you put a config file? The correct answer changes between Linux, macOS, and Windows. The directories crate solves this by knowing the standard paths for each operating system. I pair it with serde to read and write configs in TOML, which is readable and well-suited for configuration.

use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;

#[derive(Deserialize, Serialize, Default)]
struct AppConfig {
    editor: String,
    check_for_updates: bool,
    default_port: u16,
}

fn load_config() -> AppConfig {
    // This finds the OS-appropriate config directory.
    // e.g., ~/.config/mytool/ on Linux, ~/Library/Preferences/com.mydomain.mytool/ on macOS.
    if let Some(proj_dirs) = ProjectDirs::from("com", "mycompany", "mytool") {
        let config_dir = proj_dirs.config_dir();
        // Create the directory if it doesn't exist
        fs::create_dir_all(config_dir).ok();
        let config_file = config_dir.join("config.toml");

        if config_file.exists() {
            match fs::read_to_string(&config_file) {
                Ok(contents) => match toml::from_str(&contents) {
                    Ok(config) => return config,
                    Err(e) => eprintln!("Could not parse config file: {}. Using defaults.", e),
                },
                Err(e) => eprintln!("Could not read config file: {}. Using defaults.", e),
            }
        } else {
            // Create a default config file
            let default_config = AppConfig {
                editor: "vim".to_string(),
                check_for_updates: true,
                default_port: 8080,
            };
            let toml = toml::to_string_pretty(&default_config).unwrap();
            fs::write(config_file, toml).ok(); // Ignore errors on write
            return default_config;
        }
    }
    // Fallback if we can't determine directories
    AppConfig::default()
}

fn main() {
    let config = load_config();
    println!("Using editor: {}", config.editor);
    if config.check_for_updates {
        println!("Update checks are enabled.");
    }
}

This pattern is respectful of the user’s system. It uses the right places, and if the config file gets corrupted, my tool doesn’t crash—it warns the user and falls back to defaults.

If your tool lists things—a list of files, search results, system processes—presenting them clearly is crucial. A wall of text is hard to read. I use comfy-table to format data into neat tables automatically. It handles borders, column widths, and alignment.

use comfy_table::Table;
use comfy_table::presets::UTF8_FULL_CONDENSED;
use comfy_table::modifiers::UTF8_ROUND_CORNERS;

struct Task {
    id: u32,
    description: String,
    status: String,
    priority: u8,
}

fn display_task_list(tasks: Vec<Task>) {
    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL_CONDENSED)
        .apply_modifier(UTF8_ROUND_CORNERS)
        .set_header(vec!["ID", "Description", "Status", "Priority"]);

    for task in tasks {
        let priority_str = match task.priority {
            1 => "High",
            2 => "Medium",
            _ => "Low",
        };
        table.add_row(vec![
            task.id.to_string(),
            task.description,
            task.status,
            priority_str.to_string(),
        ]);
    }

    println!("{table}");
}

fn main() {
    let tasks = vec![
        Task { id: 1, description: "Write documentation".to_string(), status: "Done".to_string(), priority: 2 },
        Task { id: 2, description: "Fix login bug".to_string(), status: "In Progress".to_string(), priority: 1 },
        Task { id: 3, description: "Design new icon".to_string(), status: "Backlog".to_string(), priority: 3 },
    ];
    display_task_list(tasks);
}

The output is immediately more professional and scannable. comfy-table lets you choose from different border styles or even disable them entirely for simpler output.

This is one of my favorite quality-of-life features to add. clap can generate completion scripts for Bash, Zsh, Fish, and PowerShell. When a user installs these completions, they get tab-completion for your tool’s commands and arguments. It feels polished.

I usually add a hidden subcommand to my tool that outputs the script.

use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Process data from a file
    Process {
        input_file: std::path::PathBuf,
    },
    /// Manage user profiles
    Profile {
        #[command(subcommand)]
        action: ProfileAction,
    },
    /// Generate shell completions
    Completions {
        shell: Shell,
    },
}

#[derive(Subcommand)]
enum ProfileAction {
    List,
    Create { name: String },
}

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

    match cli.command {
        Some(Commands::Process { input_file }) => {
            println!("Processing {:?}", input_file);
        }
        Some(Commands::Profile { action }) => {
            match action {
                ProfileAction::List => println!("Listing profiles..."),
                ProfileAction::Create { name } => println!("Creating profile '{}'", name),
            }
        }
        Some(Commands::Completions { shell }) => {
            let mut cmd = Cli::command();
            let app_name = cmd.get_name().to_string();
            generate(shell, &mut cmd, app_name, &mut std::io::stdout());
        }
        None => {
            println!("No command provided. Use --help for usage.");
        }
    }
}

A user can run mytool completions bash > ~/.local/share/bash-completion/completions/mytool and from then on, typing mytool pro and hitting Tab will complete it to mytool profile. It’s a small touch that has an outsized impact on user satisfaction.

The final, often overlooked, step is getting the tool into people’s hands. Rust excels here. When I run cargo build --release, I get a single, self-contained binary in the target/release folder. There are no external dependencies, no runtime to install. I can scp that file to a server and it just runs.

For cross-compilation—building a binary for Windows from my Linux machine—I use cross. It’s a wrapper around cargo that uses Docker containers to provide the right build environment.

# Install cross
cargo install cross

# Build for 64-bit Windows from a Linux host
cross build --target x86_64-pc-windows-gnu --release

# Build for a Raspberry Pi (ARMv7)
cross build --target armv7-unknown-linux-gnueabihf --release

The resulting binary is ready to ship. I can attach it to a GitHub release, and users download one file. This simplicity is a huge part of why I enjoy making CLI tools in Rust. The journey from an idea to a reliable, distributable tool is remarkably smooth when you have the right libraries. Each one solves a concrete problem, letting me focus on the unique value my tool provides, rather than the boilerplate that surrounds it.

Keywords: rust cli tools, rust command line tools, clap rust library, rust argument parsing, rust terminal applications, rust console applications, command line interface rust, cli development rust, rust cross platform tools, rust binary distribution, rust cli framework, clap derive macros, rust subcommands, rust cli arguments, console rust crate, rust colored output, rust terminal colors, env_logger rust, rust logging, rust debug logging, rust configuration management, directories rust crate, serde rust config, toml configuration rust, comfy-table rust, rust table formatting, rust data display, clap-complete shell completions, rust tab completion, cross compilation rust, rust static binaries, cargo cross platform, rust cli best practices, rust tool development, rust systems programming, rust performance cli, rust memory safety tools, rust error handling cli, rust project structure, rust crate ecosystem, rust standard library cli, rust file handling, pathbuf rust, rust string processing, rust pattern matching, rust enum commands, rust struct cli, derive parser rust, rust terminal io, rust stdin stdout, rust user input, rust progress bars, rust file compression, rust archive tools, rust system utilities, rust development workflow, cargo build release, rust deployment strategies, rust github releases, rust installation methods, rust package management, cli ux rust, rust user experience, professional cli tools, rust production tools, rust enterprise applications, rust developer tools, rust automation scripts, rust batch processing, rust data processing tools, rust text processing, rust file manipulation, rust network tools, rust monitoring tools, rust diagnostic tools, rust maintenance utilities



Similar Posts
Blog Image
8 Rust Trait Patterns to Write Flexible, Safer, and Cleaner Code

Learn 8 practical Rust trait patterns to write flexible, type-safe code. From generics to marker traits, master the techniques that make Rust powerful. Start coding smarter today.

Blog Image
Professional Rust File I/O Optimization Techniques for High-Performance Systems

Optimize Rust file operations with memory mapping, async I/O, zero-copy parsing & direct access. Learn production-proven techniques for faster disk operations.

Blog Image
Exploring the Limits of Rust’s Type System with Higher-Kinded Types

Higher-kinded types in Rust allow abstraction over type constructors, enhancing generic programming. Though not natively supported, the community simulates HKTs using clever techniques, enabling powerful abstractions without runtime overhead.

Blog Image
7 High-Performance Rust Patterns for Professional Audio Processing: A Technical Guide

Discover 7 essential Rust patterns for high-performance audio processing. Learn to implement ring buffers, SIMD optimization, lock-free updates, and real-time safe operations. Boost your audio app performance. #RustLang #AudioDev

Blog Image
Why Your Rust Code Is Slow: Writing Cache-Friendly Code for Real Performance

Learn how to write cache-friendly Rust code that maximizes CPU performance. Master data layout, memory access patterns, and locality to build faster programs. Start optimizing today.

Blog Image
Building Extensible Concurrency Models with Rust's Sync and Send Traits

Rust's Sync and Send traits enable safe, efficient concurrency. They allow thread-safe custom types, preventing data races. Mutex and Arc provide synchronization. Actor model fits well with Rust's concurrency primitives, promoting encapsulated state and message passing.