Skip to main content
🎀 Speaking at Red Hat Summit 2026 GPUs take flight: Safety-first multi-tenant Platform Engineering with NVIDIA and OpenShift AI Learn More
Building CLI tools in Rust with clap framework
Open Source

Building CLI Tools in Rust with Clap: From Zero to

Step-by-step guide to building professional CLI tools in Rust using clap, colored output, progress bars, and cross-platform distribution. Includes real.

LB
Luca Berton
Β· 1 min read

Rust has become the dominant language for CLI tools. ripgrep, fd, bat, eza, uv, delta, zoxide, starship β€” the tools developers love most are written in Rust. Here’s how to build production-quality CLI tools from scratch.

Project Setup

cargo new my-tool
cd my-tool
cargo add clap --features derive
cargo add anyhow
cargo add colored
cargo add indicatif  # progress bars

Basic CLI with Clap Derive

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(name = "kubectl-gpu")]
#[command(about = "Manage GPU resources in Kubernetes clusters")]
#[command(version)]
struct Cli {
    /// Kubernetes context to use
    #[arg(long, global = true)]
    context: Option<String>,

    /// Output format
    #[arg(long, default_value = "table", global = true)]
    output: OutputFormat,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// List available GPUs across all nodes
    List {
        /// Filter by GPU type
        #[arg(short, long)]
        gpu_type: Option<String>,

        /// Show only available GPUs
        #[arg(long)]
        available: bool,
    },
    /// Allocate GPU resources for a workload
    Allocate {
        /// Workload name
        name: String,

        /// Number of GPUs requested
        #[arg(short, long, default_value = "1")]
        count: u32,

        /// Minimum VRAM in GB
        #[arg(long)]
        min_vram: Option<u32>,
    },
    /// Show GPU utilization metrics
    Metrics {
        /// Refresh interval in seconds
        #[arg(short, long, default_value = "5")]
        interval: u64,
    },
}

#[derive(Clone, clap::ValueEnum)]
enum OutputFormat {
    Table,
    Json,
    Yaml,
}

Colored Output

use colored::Colorize;

fn print_gpu_status(gpu: &Gpu) {
    let status = if gpu.available {
        "AVAILABLE".green().bold()
    } else {
        "IN USE".red().bold()
    };

    let utilization = match gpu.utilization_percent {
        0..=50 => format!("{}%", gpu.utilization_percent).green(),
        51..=80 => format!("{}%", gpu.utilization_percent).yellow(),
        _ => format!("{}%", gpu.utilization_percent).red(),
    };

    println!(
        "  {} {} {} ({} VRAM, {} util)",
        "●".color(if gpu.available { "green" } else { "red" }),
        gpu.name.bold(),
        status,
        format!("{}GB", gpu.vram_gb).cyan(),
        utilization,
    );
}

Progress Bars for Long Operations

use indicatif::{ProgressBar, ProgressStyle, MultiProgress};

async fn deploy_models(models: &[Model]) -> anyhow::Result<()> {
    let multi = MultiProgress::new();

    let overall = multi.add(ProgressBar::new(models.len() as u64));
    overall.set_style(
        ProgressStyle::default_bar()
            .template("{prefix:.bold} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
            .progress_chars("━━╸━"),
    );
    overall.set_prefix("Deploying");

    for model in models {
        let spinner = multi.insert_before(
            &overall,
            ProgressBar::new_spinner(),
        );
        spinner.set_style(
            ProgressStyle::default_spinner()
                .template("  {spinner:.green} {msg}")?
        );
        spinner.set_message(format!("Pulling {}...", model.name));

        pull_model(model).await?;
        spinner.finish_with_message(format!("βœ“ {}", model.name));
        overall.inc(1);
    }

    overall.finish_with_message("All models deployed");
    Ok(())
}

Table Output

use comfy_table::{Table, ContentArrangement, presets::UTF8_FULL};

fn print_gpu_table(gpus: &[Gpu]) {
    let mut table = Table::new();
    table
        .load_preset(UTF8_FULL)
        .set_content_arrangement(ContentArrangement::Dynamic)
        .set_header(vec!["Node", "GPU", "Type", "VRAM", "Status", "Workload"]);

    for gpu in gpus {
        table.add_row(vec![
            &gpu.node,
            &gpu.device_id,
            &gpu.model,
            &format!("{}GB", gpu.vram_gb),
            if gpu.available { "Available" } else { "In Use" },
            gpu.workload.as_deref().unwrap_or("-"),
        ]);
    }

    println!("{table}");
}

Configuration Files

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Deserialize, Serialize)]
struct Config {
    default_context: Option<String>,
    default_namespace: String,
    gpu_preferences: GpuPreferences,
}

#[derive(Deserialize, Serialize)]
struct GpuPreferences {
    preferred_type: String,
    min_vram_gb: u32,
}

impl Config {
    fn load() -> anyhow::Result<Self> {
        let path = Self::config_path();
        if path.exists() {
            let content = std::fs::read_to_string(&path)?;
            Ok(toml::from_str(&content)?)
        } else {
            Ok(Self::default())
        }
    }

    fn config_path() -> PathBuf {
        dirs::config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("kubectl-gpu")
            .join("config.toml")
    }
}

Error Handling for CLIs

use anyhow::{Context, Result};
use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            // Print error chain nicely
            eprintln!("{}: {}", "error".red().bold(), e);
            for cause in e.chain().skip(1) {
                eprintln!("  {} {}", "caused by:".yellow(), cause);
            }
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::List { gpu_type, available } => {
            list_gpus(gpu_type.as_deref(), available)
                .context("failed to list GPUs")?;
        }
        Commands::Allocate { name, count, min_vram } => {
            allocate_gpu(&name, count, min_vram)
                .context("GPU allocation failed")?;
        }
        Commands::Metrics { interval } => {
            show_metrics(interval)
                .context("metrics collection failed")?;
        }
    }
    Ok(())
}

Cross-Platform Distribution

# .github/workflows/release.yml
name: Release
on:
  push:
    tags: ["v*"]

jobs:
  build:
    strategy:
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            os: ubuntu-latest
          - target: x86_64-apple-darwin
            os: macos-latest
          - target: aarch64-apple-darwin
            os: macos-latest
          - target: x86_64-pc-windows-msvc
            os: windows-latest

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - run: cargo build --release --target ${{ matrix.target }}
      - uses: softprops/action-gh-release@v2
        with:
          files: target/${{ matrix.target }}/release/kubectl-gpu*

Shell Completions

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

#[derive(Subcommand)]
enum Commands {
    // ... other commands ...

    /// Generate shell completions
    Completions {
        /// Shell to generate for
        #[arg(value_enum)]
        shell: Shell,
    },
}

// In command handler:
Commands::Completions { shell } => {
    let mut cmd = Cli::command();
    generate(shell, &mut cmd, "kubectl-gpu", &mut std::io::stdout());
}

Testing CLIs

use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn test_list_command() {
    Command::cargo_bin("kubectl-gpu")
        .unwrap()
        .arg("list")
        .arg("--output")
        .arg("json")
        .assert()
        .success()
        .stdout(predicate::str::contains("gpu_type"));
}

#[test]
fn test_invalid_args() {
    Command::cargo_bin("kubectl-gpu")
        .unwrap()
        .arg("--invalid-flag")
        .assert()
        .failure()
        .stderr(predicate::str::contains("error"));
}

The best CLI tools are invisible β€” they do exactly what you expect, fast, with helpful errors when things go wrong. Rust’s type system and ecosystem make this achievable in days, not weeks.

#Rust #CLI #DevTools #Developer Experience
Share:

πŸ“¬ Don't miss the next one

Get AI & Cloud insights delivered weekly

Join engineers getting practical tips on AI, Kubernetes, Ansible, and Platform Engineering.

Subscribe Free β†’
Luca Berton β€” AI & Cloud Advisor, Docker Captain

Luca Berton

AI & Cloud Advisor Β· Docker Captain Β· KubeCon Speaker

18+ years in enterprise infrastructure. Author of 8 technical books, creator of Ansible Pilot (1M+ YouTube views, 648K site users). Former Red Hat engineer. Speaker at KubeCon EU 2026 and Red Hat Summit 2026.

Luca Berton Ansible Pilot Ansible by Example Open Empower K8s Recipes Terraform Pilot CopyPasteLearn ProteinLens Heaven Art Shop TechMeOut

Free 30-min AI & Cloud consultation

Book Now