| name | rust-cli |
| description | | Use when this capability is needed. |
Quick Navigation
Rust CLI Development
Build fast, reliable command-line tools in Rust. The ecosystem is excellent: clap for parsing, ratatui for TUI, tracing for observability.
Quick Setup
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
colored = "2"
indicatif = "0.17"
ratatui = "0.29"
crossterm = "0.28"
[profile.release]
opt-level = "s"
lto = true
strip = true
Argument Parsing with Clap
use clap::{Parser, Subcommand, Args};
#[derive(Parser)]
#[command(name = "mytool")]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(short, long, default_value = "~/.config/mytool.toml")]
config: std::path::PathBuf,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init(InitArgs),
Run {
name: String,
#[arg(long)]
dry_run: bool,
},
Status,
}
#[derive(Args)]
struct InitArgs {
path: std::path::PathBuf,
#[arg(short, long, value_enum, default_value = "default")]
template: Template,
}
#[derive(clap::ValueEnum, Clone)]
enum Template {
Default,
Minimal,
Full,
}
fn main() -> anyhow::Result<()> {
let = Cli::();
= cli.verbose {
=> tracing::Level::WARN,
=> tracing::Level::INFO,
=> tracing::Level::DEBUG,
_ => tracing::Level::TRACE,
};
tracing_subscriber::().(log_level).();
cli.command {
Commands::(args) => (args),
Commands::Run { name, dry_run } => (&name, dry_run),
Commands::Status => (),
}
}
Config Files
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize)]
#[serde(default)]
struct Config {
pub api_url: String,
pub timeout_secs: u64,
pub output_format: OutputFormat,
}
impl Default for Config {
fn default() -> Self {
Self {
api_url: "https://api.example.com".into(),
timeout_secs: 30,
output_format: OutputFormat::Text,
}
}
}
fn load_config(path: &PathBuf) -> anyhow::Result<Config> {
if !path.exists() {
return Ok(Config::default());
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config: {}", path.display()))?;
let config: Config = toml::from_str(&content)
.context("Failed to parse config file")?;
Ok(config)
}
(config: &Config, path: &PathBuf) anyhow::<()> {
(parent) = path.() {
std::fs::(parent)?;
}
= toml::(config)?;
std::fs::(path, content)?;
(())
}
() PathBuf {
dirs::()
.(|| PathBuf::())
.()
.()
}
Stdin / Stdout / Piping
use std::io::{self, BufRead, Write};
fn main() -> anyhow::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = io::BufWriter::new(stdout.lock());
for line in stdin.lock().lines() {
let line = line?;
writeln!(out, "{}", process_line(&line))?;
}
Ok(())
}
fn is_piped() -> bool {
!atty::is(atty::Stream::Stdout)
}
fn should_colorize() -> bool {
atty::is(atty::Stream::Stdout) && std::env::var("NO_COLOR").is_err()
}
Progress Bars & Spinners
use indicatif::{ProgressBar, ProgressStyle, MultiProgress};
use std::time::Duration;
fn download_files(urls: &[String]) -> anyhow::Result<()> {
let mp = MultiProgress::new();
let overall = mp.add(ProgressBar::new(urls.len() as u64));
overall.set_style(
ProgressStyle::default_bar()
.template("{spinner} [{elapsed}] {bar:40.cyan/blue} {pos}/{len} {msg}")?
.progress_chars("█▉▊▋▌▍▎▏ "),
);
for url in urls {
let pb = mp.add(ProgressBar::new(0));
pb.set_style(
ProgressStyle::default_bar()
.template(" {msg} {bar:30} {bytes}/{total_bytes}")?
);
pb.set_message(url.clone());
download_with_progress(url, &pb)?;
pb.finish_and_clear();
overall.inc(1);
}
overall.();
(())
}
<F, T>(msg: &, f: F) T
F: () T
{
= ProgressBar::();
pb.(ProgressStyle::()
.()
.());
pb.(msg.());
pb.(Duration::());
= ();
pb.();
result
}
Colored Output
use colored::Colorize;
fn print_status(success: bool, message: &str) {
if success {
println!("{} {}", "✓".green().bold(), message);
} else {
eprintln!("{} {}", "✗".red().bold(), message);
}
}
fn print_table(headers: &[&str], rows: &[Vec<String>]) {
let header: Vec<String> = headers.iter()
.map(|h| h.bold().underline().to_string())
.collect();
println!("{}", header.join(" "));
for row in rows {
println!("{}", row.join(" "));
}
}
fn colorize_if_supported(s: &str, color: (&) colored::ColoredString) {
std::env::().() || !atty::(atty::Stream::Stdout) {
s.()
} {
(s).()
}
}
Output Formats
Support both human-readable and machine-parseable output:
#[derive(clap::ValueEnum, Clone, serde::Deserialize)]
enum OutputFormat {
Text,
Json,
Table,
}
fn output_results(items: &[Item], format: &OutputFormat) -> anyhow::Result<()> {
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(items)?);
}
OutputFormat::Table => {
print_table_view(items);
}
OutputFormat::Text => {
for item in items {
println!("{}: {}", item.name, item.value);
}
}
}
Ok(())
}
Error Output
fn main() {
if let Err(err) = run() {
eprintln!("{} {}", "error:".red().bold(), err);
for cause in err.chain().skip(1) {
eprintln!(" {} {}", "caused by:".dimmed(), cause);
}
std::process::exit(1);
}
}
fn main() {
if let Err(err) = run() {
eprintln!("{err:#}");
std::process::exit(1);
}
}
Basic TUI with Ratatui
use crossterm::{
event::{self, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{prelude::*, widgets::*};
use std::io;
fn run_tui() -> io::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut counter = 0u32;
loop {
terminal.draw(|frame| {
let area = frame.area();
let block = Block::new()
.title("My TUI App")
.borders(Borders::ALL);
let paragraph = Paragraph::new(format!("Counter: {counter}"))
.block(block)
.alignment(Alignment::Center);
frame.(paragraph, area);
})?;
event::(std::time::Duration::())? {
::(key) = event::()? {
key.code {
KeyCode::() | KeyCode::Esc => ,
KeyCode::Up | KeyCode::() => counter += ,
KeyCode::Down | KeyCode::() => counter = counter.(),
_ => {}
}
}
}
}
()?;
execute!(terminal.(), LeaveAlternateScreen)?;
(())
}
Shell Completion
use clap::CommandFactory;
use clap_complete::{generate, Shell};
fn generate_completion(shell: Shell) {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut std::io::stdout());
}
#[derive(Subcommand)]
enum Commands {
Completion {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
Project Layout
my-cli/
├── src/
│ ├── main.rs # Entry: Cli::parse() + dispatch
│ ├── cli.rs # Clap structs
│ ├── config.rs # Config loading/saving
│ ├── error.rs # AppError definition
│ └── commands/
│ ├── init.rs
│ ├── run.rs
│ └── status.rs
├── tests/
│ └── integration.rs
└── Cargo.toml
Release Distribution
[workspace.metadata.dist]
cargo-dist-version = "0.22"
ci = "github"
installers = ["shell", "powershell", "homebrew"]
targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-pc-windows-msvc"]
cargo install cargo-dist
cargo dist init
cargo dist build
Anti-Patterns
fn run() {
eprintln!("failed");
std::process::exit(1);
}
fn run() -> Result<(), CliError> {
do_work()?;
Ok(())
}
ProgressBar::new(total);
if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
ProgressBar::new(total);
}
Release Checklist
- Keep parsing in
cli.rs; keep business logic testable without clap.
- Return errors from commands; map them to user-facing messages once.
- Respect
--quiet, --verbose, and non-interactive CI output.
- Add integration tests with
assert_cmd and predicates.
- Generate shell completions and document config precedence.
- Test install artifacts on at least one target per supported OS family.
References
Source: adxptived/Rust-Skills — distributed by TomeVault.