Rust CLI Tool Development: 5 Production Patterns from Argument Parsing to Terminal UI
Why Rust Is the Optimal Choice for CLI Tools in 2026
You wrote a CLI in Python, and users say "I don't have Python installed"; you wrote one in Node.js, and node_modules is bigger than the tool itself; you wrote one in Go, cross-compilation is convenient, but error handling is a mess of try-catch. In 2026, Rust CLI has finally become the first choice for command-line tool development — clap v4's type-safe argument parsing, indicatif's beautiful progress bars, Ratatui's terminal UI, and most importantly: single binary, zero dependencies, millisecond startup.
This article starts from argument parsing and walks you through clap argument parsing → config-rs configuration management → indicatif progress indicators → Ratatui terminal UI → cross-platform distribution — 5 production patterns to take Rust CLI from "works" to "excellent".
Rust CLI Core Ecosystem
| Library | Purpose | 2026 Version |
|---|---|---|
| clap | Argument parsing & subcommands | v4.5+ |
| config-rs | Multi-format configuration management | v0.14+ |
| indicatif | Progress bars & status indicators | v0.17+ |
| Ratatui | Terminal UI framework | v0.29+ |
| crossterm | Cross-platform terminal control | v0.28+ |
| anyhow/thiserror | Error handling | v1.0+/v2.0+ |
| cargo-dist | Cross-platform distribution | v0.22+ |
| dialoguer | Interactive prompts | v0.11+ |
| console | Terminal styling & colors | v0.15+ |
| serde | Serialization/deserialization | v1.0+ |
CLI Tool Architecture Flow
CLI Tool Processing Flow:
1. User executes command-line instruction
2. clap parses arguments & subcommands, generates Cli struct
3. config-rs loads configuration files (TOML/YAML/ENV), merges with CLI args
4. Business logic executes, indicatif displays progress bars
5. Optional: Ratatui renders interactive terminal UI
6. Output results, return exit code
7. cargo-dist builds cross-platform binaries
Problem Analysis: 5 Major Rust CLI Production Challenges
- Complex argument parsing: Nested subcommands, argument validation, mutually exclusive arguments, default values — hand-written parsers are bug-prone
- Configuration management chaos: Unclear priority and merge strategy between CLI arguments, config files, and environment variables
- Missing progress feedback: Long-running tasks without progress indicators, users think the program is frozen
- Primitive terminal interaction: Only print output, no selection menus, table displays, or real-time refresh
- Distribution difficulties: Building, signing, and publishing for Windows/macOS/Linux across package managers is cumbersome
Step-by-Step: 5 Production Patterns
Pattern 1: clap v4 Argument Parsing & Subcommands
use clap::{Parser, Subcommand, Args, ValueEnum};
#[derive(Parser)]
#[command(name = "toolkit")]
#[command(about = "ToolsKu CLI - Developer Toolkit", long_about = None)]
#[command(version, author)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(long, global = true, help = "Enable verbose logging")]
verbose: bool,
#[arg(long, global = true, value_name = "FILE", help = "Specify config file path")]
config: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
#[command(about = "JSON processing tools")]
Json(JsonCommand),
#[command(about = "Encoding conversion tools")]
Encode(EncodeCommand),
#[command(about = "Code formatting")]
Format(FormatCommand),
#[command(about = "File hash calculation")]
Hash(HashCommand),
#[command(about = "Batch processing")]
Batch(BatchCommand),
}
#[derive(Args)]
struct JsonCommand {
#[command(subcommand)]
action: JsonAction,
#[arg(short, long, default_value = "stdin", help = "Input file path")]
input: String,
#[arg(short, long, help = "Output file path")]
output: Option<String>,
}
#[derive(Subcommand)]
enum JsonAction {
#[command(about = "Format JSON")]
Format {
#[arg(short, long, default_value_t = 2, help = "Number of indent spaces")]
indent: usize,
},
#[command(about = "Validate JSON")]
Validate,
#[command(about = "JSON Path query")]
Query {
#[arg(help = "JSON Path expression")]
expression: String,
},
#[command(about = "Minify JSON")]
Minify,
}
#[derive(Args)]
struct EncodeCommand {
#[command(subcommand)]
action: EncodeAction,
}
#[derive(Subcommand)]
enum EncodeAction {
#[command(about = "Base64 encode/decode")]
Base64 {
#[arg(help = "Input string")]
input: String,
#[arg(short, long, help = "Decode mode")]
decode: bool,
#[arg(short, long, default_value_t = Encoding::Standard)]
encoding: Encoding,
},
#[command(about = "URL encode/decode")]
Url {
#[arg(help = "Input string")]
input: String,
#[arg(short, long, help = "Decode mode")]
decode: bool,
},
}
#[derive(Clone, ValueEnum)]
enum Encoding {
Standard,
UrlSafe,
}
#[derive(Args)]
struct HashCommand {
#[arg(help = "File path")]
file: String,
#[arg(short, long, default_value_t = HashAlgorithm::Sha256)]
algorithm: HashAlgorithm,
}
#[derive(Clone, ValueEnum)]
enum HashAlgorithm {
Md5,
Sha1,
Sha256,
Sha512,
Blake3,
}
#[derive(Args)]
struct BatchCommand {
#[arg(short, long, help = "Input directory")]
input_dir: String,
#[arg(short, long, help = "Output directory")]
output_dir: Option<String>,
#[arg(short, long, default_value_t = 4, help = "Parallel workers")]
parallel: usize,
#[arg(long, help = "Process subdirectories recursively")]
recursive: bool,
#[arg(long, help = "File glob pattern", default_value = "*.json")]
pattern: String,
}
#[derive(Args)]
struct FormatCommand {
#[arg(help = "File path")]
file: String,
#[arg(short, long, default_value_t = 2, help = "Number of indent spaces")]
indent: usize,
#[arg(long, help = "Edit file in place")]
write: bool,
}
use clap::Parser;
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.verbose {
std::env::set_var("RUST_LOG", "debug");
}
env_logger::init();
match cli.command {
Commands::Json(cmd) => handle_json(cmd)?,
Commands::Encode(cmd) => handle_encode(cmd)?,
Commands::Format(cmd) => handle_format(cmd)?,
Commands::Hash(cmd) => handle_hash(cmd)?,
Commands::Batch(cmd) => handle_batch(cmd)?,
}
Ok(())
}
fn handle_json(cmd: JsonCommand) -> anyhow::Result<()> {
let input = read_input(&cmd.input)?;
match cmd.action {
JsonAction::Format { indent } => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let formatted = serde_json::to_string_pretty(&value)?;
write_output(&formatted, cmd.output.as_deref())?;
}
JsonAction::Validate => {
match serde_json::from_str::<serde_json::Value>(&input) {
Ok(_) => println!("✓ Valid JSON"),
Err(e) => {
eprintln!("✗ Invalid JSON: {}", e);
std::process::exit(1);
}
}
}
JsonAction::Query { expression } => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let results = jsonpath_lib::select(&value, &expression)?;
let output = serde_json::to_string_pretty(&results)?;
write_output(&output, cmd.output.as_deref())?;
}
JsonAction::Minify => {
let value: serde_json::Value = serde_json::from_str(&input)?;
let minified = serde_json::to_string(&value)?;
write_output(&minified, cmd.output.as_deref())?;
}
}
Ok(())
}
fn read_input(path: &str) -> anyhow::Result<String> {
if path == "stdin" {
use std::io::Read;
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
Ok(buffer)
} else {
Ok(std::fs::read_to_string(path)?)
}
}
fn write_output(content: &str, path: Option<&str>) -> anyhow::Result<()> {
match path {
Some(p) => std::fs::write(p, content)?,
None => print!("{}", content),
}
Ok(())
}
Pattern 2: config-rs Multi-Source Configuration Management
use config::{Config, ConfigBuilder, Environment, File, FileFormat};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub general: GeneralConfig,
pub output: OutputConfig,
pub network: NetworkConfig,
pub processing: ProcessingConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct GeneralConfig {
pub verbose: bool,
pub color: bool,
pub pager: bool,
pub log_level: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct OutputConfig {
pub format: OutputFormat,
pub indent: usize,
pub line_width: usize,
}
#[derive(Debug, Deserialize, Clone)]
pub struct NetworkConfig {
pub timeout_secs: u64,
pub max_retries: u32,
pub proxy: Option<String>,
pub user_agent: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ProcessingConfig {
pub parallel: usize,
pub batch_size: usize,
pub max_file_size_mb: u64,
pub temp_dir: PathBuf,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Json,
Yaml,
Toml,
Table,
Plain,
}
impl AppConfig {
pub fn load(cli_config: Option<&str>) -> anyhow::Result<Self> {
let config_path = cli_config
.map(|s| PathBuf::from(s))
.or_else(|| {
dirs::home_dir().map(|h| h.join(".toolkit").join("config.toml"))
});
let mut builder = Config::builder()
.set_default("general.verbose", false)?
.set_default("general.color", true)?
.set_default("general.pager", false)?
.set_default("general.log_level", "info")?
.set_default("output.format", "json")?
.set_default("output.indent", 2)?
.set_default("output.line_width", 80)?
.set_default("network.timeout_secs", 30)?
.set_default("network.max_retries", 3)?
.set_default("network.user_agent", "ToolsKu-CLI/1.0")?
.set_default("processing.parallel", 4)?
.set_default("processing.batch_size", 100)?
.set_default("processing.max_file_size_mb", 50)?
.set_default("processing.temp_dir", "/tmp/toolkit")?;
if let Some(path) = &config_path {
if path.exists() {
builder = builder.add_source(File::from(path.as_path()));
}
}
builder = builder.add_source(
Environment::with_prefix("TOOLKIT")
.separator("_")
.try_parsing(true)
);
let config: AppConfig = builder.build()?.try_deserialize()?;
Ok(config)
}
}
# ~/.toolkit/config.toml
[general]
verbose = false
color = true
pager = true
log_level = "debug"
[output]
format = "table"
indent = 4
line_width = 120
[network]
timeout_secs = 60
max_retries = 5
proxy = "http://proxy.example.com:8080"
user_agent = "ToolsKu-CLI/1.0"
[processing]
parallel = 8
batch_size = 200
max_file_size_mb = 100
temp_dir = "/home/user/.toolkit/tmp"
use crate::config::AppConfig;
fn handle_batch(cmd: BatchCommand) -> anyhow::Result<()> {
let config = AppConfig::load(None)?;
let parallel = if cmd.parallel > 0 { cmd.parallel } else { config.processing.parallel };
let max_size = config.processing.max_file_size_mb * 1024 * 1024;
println!("Parallel workers: {}", parallel);
println!("Max file size: {}MB", config.processing.max_file_size_mb);
println!("Output format: {:?}", config.output.format);
Ok(())
}
Pattern 3: indicatif Progress Indicators & Multi-Task
use indicatif::{ProgressBar, ProgressStyle, MultiProgress, ProgressDrawTarget};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
fn create_progress_bar(total: u64, message: &str) -> ProgressBar {
let pb = ProgressBar::new(total);
pb.set_style(
ProgressStyle::with_template(
"{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({eta}) {msg}"
)
.unwrap()
.progress_chars("█▓░")
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(100));
pb
}
fn create_spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.green} {msg} ({elapsed})")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
async fn process_files_with_progress(
files: Vec<PathBuf>,
parallel: usize,
) -> anyhow::Result<Vec<ProcessResult>> {
let multi = Arc::new(MultiProgress::new());
let semaphore = Arc::new(Semaphore::new(parallel));
let total_pb = multi.add(create_progress_bar(
files.len() as u64,
"Total progress",
));
let mut handles = vec![];
for (idx, file) in files.into_iter().enumerate() {
let multi = multi.clone();
let semaphore = semaphore.clone();
let total_pb = total_pb.clone();
let handle = tokio::spawn(async move {
let _permit = semaphore.acquire().await.unwrap();
let file_name = file.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let file_pb = multi.add(create_spinner(&format!("[{}] Processing", file_name)));
let result = process_single_file(&file).await;
match &result {
Ok(_) => {
file_pb.finish_with_message(format!("[{}] ✓ Done", file_name));
}
Err(e) => {
file_pb.finish_with_message(format!("[{}] ✗ Failed: {}", file_name, e));
}
}
total_pb.inc(1);
result
});
handles.push(handle);
}
total_pb.finish_with_message("All complete");
let results: Vec<ProcessResult> = handles
.into_iter()
.filter_map(|h| h.ok().and_then(|r| r.ok()))
.collect();
Ok(results)
}
async fn process_single_file(path: &PathBuf) -> anyhow::Result<ProcessResult> {
let content = tokio::fs::read_to_string(path).await?;
let processed = transform_content(&content)?;
let output_path = path.with_extension("processed.json");
tokio::fs::write(&output_path, &processed).await?;
Ok(ProcessResult {
input: path.clone(),
output: output_path,
size: processed.len(),
})
}
struct ProcessResult {
input: PathBuf,
output: PathBuf,
size: usize,
}
use indicatif::{ProgressBar, ProgressStyle};
fn download_with_progress(url: &str, dest: &str) -> anyhow::Result<()> {
let response = reqwest::blocking::Client::new()
.get(url)
.send()?;
let total_size = response.content_length().unwrap_or(0);
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::with_template(
"{msg}\n{spinner:.green} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})"
)
.unwrap()
.progress_chars("█▓░")
);
pb.set_message(format!("Downloading {}", url));
let mut file = std::fs::File::create(dest)?;
let mut downloaded: u64 = 0;
use std::io::Write;
for chunk in response.chunk() {
let chunk = chunk?;
file.write_all(&chunk)?;
let new = downloaded + chunk.len() as u64;
pb.set_position(new);
downloaded = new;
}
pb.finish_with_message(format!("✓ Download complete: {}", dest));
Ok(())
}
Pattern 4: Ratatui Interactive Terminal UI
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEventKind},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, HighlightSpacing, Row, Table, TableState, Tabs},
Frame, DefaultTerminal,
};
use std::time::Duration;
struct App {
should_quit: bool,
current_tab: usize,
table_state: TableState,
tasks: Vec<Task>,
progress: u16,
}
#[derive(Clone)]
struct Task {
name: String,
status: TaskStatus,
progress: u16,
size: String,
}
#[derive(Clone, PartialEq)]
enum TaskStatus {
Pending,
Running,
Completed,
Failed,
}
impl App {
fn new() -> Self {
let tasks = vec![
Task { name: "config.json".into(), status: TaskStatus::Completed, progress: 100, size: "2.3KB".into() },
Task { name: "data.csv".into(), status: TaskStatus::Running, progress: 67, size: "15.7MB".into() },
Task { name: "output.yaml".into(), status: TaskStatus::Pending, progress: 0, size: "—".into() },
Task { name: "report.md".into(), status: TaskStatus::Failed, progress: 34, size: "8.1KB".into() },
Task { name: "schema.toml".into(), status: TaskStatus::Running, progress: 89, size: "1.2KB".into() },
];
let mut table_state = TableState::default();
table_state.select(Some(0));
Self {
should_quit: false,
current_tab: 0,
table_state,
tasks,
progress: 0,
}
}
fn run(&mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> {
while !self.should_quit {
terminal.draw(|frame| self.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.handle_key(key.code);
}
}
}
self.update();
}
Ok(())
}
fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
KeyCode::Char('1') => self.current_tab = 0,
KeyCode::Char('2') => self.current_tab = 1,
KeyCode::Char('3') => self.current_tab = 2,
KeyCode::Down => self.select_next(),
KeyCode::Up => self.select_previous(),
KeyCode::Enter => self.toggle_task(),
_ => {}
}
}
fn select_next(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i >= self.tasks.len() - 1 { 0 } else { i + 1 }
}
None => 0,
};
self.table_state.select(Some(i));
}
fn select_previous(&mut self) {
let i = match self.table_state.selected() {
Some(i) => {
if i == 0 { self.tasks.len() - 1 } else { i - 1 }
}
None => 0,
};
self.table_state.select(Some(i));
}
fn toggle_task(&mut self) {
if let Some(i) = self.table_state.selected() {
let task = &mut self.tasks[i];
match task.status {
TaskStatus::Pending => {
task.status = TaskStatus::Running;
}
TaskStatus::Running => {
task.status = TaskStatus::Completed;
task.progress = 100;
}
_ => {}
}
}
}
fn update(&mut self) {
for task in &mut self.tasks {
if task.status == TaskStatus::Running && task.progress < 100 {
task.progress = (task.progress + 1).min(100);
if task.progress == 100 {
task.status = TaskStatus::Completed;
}
}
}
}
fn draw(&self, frame: &mut Frame) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
let titles = vec!["Tasks", "System", "Config"];
let tabs = Tabs::new(titles)
.block(Block::default().borders(Borders::ALL).title(" ToolsKu CLI "))
.select(self.current_tab)
.style(Style::default().fg(Color::White))
.highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
frame.render_widget(tabs, chunks[0]);
match self.current_tab {
0 => self.draw_task_table(frame, chunks[1]),
1 => self.draw_system_status(frame, chunks[1]),
2 => self.draw_config_panel(frame, chunks[1]),
_ => {}
}
let help = Line::from(vec![
Span::styled(" ↑↓ ", Style::default().bg(Color::DarkGray)),
Span::raw(" Select "),
Span::styled(" Enter ", Style::default().bg(Color::DarkGray)),
Span::raw(" Toggle "),
Span::styled(" 1-3 ", Style::default().bg(Color::DarkGray)),
Span::raw(" Tab "),
Span::styled(" q ", Style::default().bg(Color::DarkGray)),
Span::raw(" Quit "),
]);
frame.render_widget(
Block::default().borders(Borders::ALL).title(help),
chunks[2],
);
}
fn draw_task_table(&self, frame: &mut Frame, area: Rect) {
let header = Row::new(vec![
Cell::from("Filename").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Status").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Progress").style(Style::default().add_modifier(Modifier::BOLD)),
Cell::from("Size").style(Style::default().add_modifier(Modifier::BOLD)),
])
.style(Style::default().bg(Color::DarkGray))
.height(1)
.bottom_margin(1);
let rows: Vec<Row> = self.tasks.iter().map(|task| {
let status_str = match task.status {
TaskStatus::Pending => Span::styled("● Pending", Style::default().fg(Color::Yellow)),
TaskStatus::Running => Span::styled("● Running", Style::default().fg(Color::Cyan)),
TaskStatus::Completed => Span::styled("● Done", Style::default().fg(Color::Green)),
TaskStatus::Failed => Span::styled("● Failed", Style::default().fg(Color::Red)),
};
let progress_str = format!("{:>3}%", task.progress);
let progress_style = if task.progress == 100 {
Style::default().fg(Color::Green)
} else if task.progress > 50 {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Yellow)
};
Row::new(vec![
Cell::from(Span::raw(&task.name)),
Cell::from(status_str),
Cell::from(Span::styled(progress_str, progress_style)),
Cell::from(Span::raw(&task.size)),
])
.height(1)
}).collect();
let table = Table::new(
rows,
[
Constraint::Percentage(40),
Constraint::Percentage(20),
Constraint::Percentage(20),
Constraint::Percentage(20),
],
)
.header(header)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_spacing(HighlightSpacing::Always)
.block(Block::default().borders(Borders::ALL).title(" Task List "));
frame.render_stateful_widget(table, area, &mut self.table_state.clone());
}
fn draw_system_status(&self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(area);
let cpu_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" CPU "))
.gauge_style(Style::default().fg(Color::Cyan))
.percent(45);
frame.render_widget(cpu_gauge, chunks[0]);
let mem_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" Memory "))
.gauge_style(Style::default().fg(Color::Magenta))
.percent(62);
frame.render_widget(mem_gauge, chunks[1]);
let disk_gauge = Gauge::default()
.block(Block::default().borders(Borders::ALL).title(" Disk "))
.gauge_style(Style::default().fg(Color::Yellow))
.percent(78)
.label("78% (156GB/200GB)");
frame.render_widget(disk_gauge, chunks[2]);
}
fn draw_config_panel(&self, frame: &mut Frame, area: Rect) {
let config_text = vec![
Line::from(vec![
Span::styled(" verbose: ", Style::default().fg(Color::Yellow)),
Span::raw("false"),
]),
Line::from(vec![
Span::styled(" color: ", Style::default().fg(Color::Yellow)),
Span::raw("true"),
]),
Line::from(vec![
Span::styled(" format: ", Style::default().fg(Color::Yellow)),
Span::raw("json"),
]),
Line::from(vec![
Span::styled(" parallel: ", Style::default().fg(Color::Yellow)),
Span::raw("4"),
]),
Line::from(vec![
Span::styled(" timeout: ", Style::default().fg(Color::Yellow)),
Span::raw("30s"),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Current Config (~/.toolkit/config.toml) ");
frame.render_widget(
ratatui::widgets::Paragraph::new(config_text).block(block),
area,
);
}
}
fn main() -> anyhow::Result<()> {
let mut terminal = ratatui::init();
let mut app = App::new();
let result = app.run(&mut terminal);
ratatui::restore();
result
}
Pattern 5: Cross-Platform Distribution & Build
# Cargo.toml
[package]
name = "toolkit"
version = "1.0.0"
edition = "2021"
description = "ToolsKu CLI - Developer Toolkit"
license = "MIT"
repository = "https://github.com/toolsku/cli"
[[bin]]
name = "toolkit"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive", "env", "unicode"] }
config = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
thiserror = "2.0"
indicatif = "0.17"
ratatui = "0.29"
crossterm = "0.28"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
dialoguer = "0.11"
console = "0.15"
dirs = "5.0"
env_logger = "0.11"
log = "0.4"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
panic = "abort"
# .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
release:
name: Release ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
- os: macos-latest
target: x86_64-apple-darwin
- os: macos-latest
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: cargo-dist/setup-cargo-dist@v0
- run: cargo dist build --target=${{ matrix.target }} --artifacts=global
- uses: actions/upload-artifact@v4
with:
name: toolkit-${{ matrix.target }}
path: target/dist/
publish:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: cargo-dist/setup-cargo-dist@v0
- run: cargo dist publish
# [dist] section in Cargo.toml or dist.toml
[dist]
installers = ["shell", "powershell", "homebrew"]
targets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
]
merge-scripts = false
install-path = "CARGO_HOME"
[dist.dependencies.homebrew]
name = "toolkit"
[dist.dependencies.apt]
name = "toolkit"
[dist.dependencies.chocolatey]
name = "toolkit"
# Windows MSI build script
# build-msi.ps1
$version = "1.0.0"
$cargoTarget = "x86_64-pc-windows-msvc"
Write-Host "Building toolkit v$version for Windows..."
cargo build --release --target $cargoTarget
$binaryPath = "target\$cargoTarget\release\toolkit.exe"
if (-not (Test-Path $binaryPath)) {
Write-Error "Binary not found: $binaryPath"
exit 1
}
Write-Host "Binary size: $((Get-Item $binaryPath).Length / 1MB) MB"
Write-Host "Creating MSI installer..."
wix build `
--arch x64 `
--define "Version=$version" `
--output "toolkit-$version-x64.msi" `
toolkit.wxs
Write-Host "MSI installer created: toolkit-$version-x64.msi"
<!-- toolkit.wxs -->
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="ToolsKu CLI"
Manufacturer="ToolsKu"
Version="$(var.Version)"
UpgradeCode="E9F8A7B6-C5D4-3E2F-1A0B-9C8D7E6F5A4B">
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed" />
<Feature Id="MainFeature" Title="ToolsKu CLI">
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="PathEnvironment" />
</Feature>
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="ToolsKu">
<Component Id="MainExecutable" Guid="*">
<File Id="ToolkitExe"
Source="target\x86_64-pc-windows-msvc\release\toolkit.exe"
KeyPath="yes" />
</Component>
<Component Id="PathEnvironment" Guid="*">
<Environment
Id="PATH"
Name="PATH"
Value="[INSTALLFOLDER]"
Permanent="no"
Part="last"
Action="set"
System="yes" />
</Component>
</Directory>
</StandardDirectory>
</Package>
</Wix>
Pitfall Guide
Pitfall 1: clap derive macro argument name mismatch
// ❌ Wrong: field name and argument name mismatch
#[derive(Args)]
struct BadCmd {
#[arg(long)]
output_dir: String, // generates --output-dir
}
// ✅ Correct: explicitly specify argument name
#[derive(Args)]
struct GoodCmd {
#[arg(long = "output-dir")]
output_dir: String, // explicit mapping
}
Pitfall 2: config-rs environment variable override not working
// ❌ Wrong: improper prefix and separator configuration
Environment::with_prefix("TOOLKIT")
// Expects TOOLKIT_NETWORK_TIMEOUT but actually looks for TOOLKIT_NETWORKTIMEOUT
// ✅ Correct: explicitly set separator
Environment::with_prefix("TOOLKIT")
.separator("_") // TOOLKIT_NETWORK_TIMEOUT_SECS
.try_parsing(true)
Pitfall 3: indicatif multi-progress bar output chaos
// ❌ Wrong: multiple ProgressBars printing directly, output interleaves
let pb1 = ProgressBar::new(100);
let pb2 = ProgressBar::new(200);
pb1.println("task1 message"); // interleaves with pb2 output
// ✅ Correct: use MultiProgress for unified management
let mp = MultiProgress::new();
let pb1 = mp.add(ProgressBar::new(100));
let pb2 = mp.add(ProgressBar::new(200));
// All output coordinated through MultiProgress, no interleaving
Pitfall 4: Ratatui terminal state not restored after panic
// ❌ Wrong: terminal left in raw mode after panic, no input echo
fn main() -> Result<()> {
let terminal = ratatui::init();
run_app(terminal)?; // if panic, terminal won't restore
ratatui::restore();
Ok(())
}
// ✅ Correct: use custom panic hook to ensure terminal restoration
fn main() -> Result<()> {
let terminal = ratatui::init();
let result = run_app(terminal);
ratatui::restore(); // executes even on panic (via Drop or defer)
result
}
fn setup_panic_hook() {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
ratatui::restore();
default_hook(info);
}));
}
Pitfall 5: ANSI colors not displaying on Windows
// ❌ Wrong: Windows doesn't support ANSI escape sequences by default
fn main() {
println!("\x1b[32mgreen text\x1b[0m"); // Windows CMD won't show colors
}
// ✅ Correct: enable Windows ANSI support
fn main() {
#[cfg(windows)]
{
let _ = enable_ansi_support();
}
println!("\x1b[32mgreen text\x1b[0m");
}
#[cfg(windows)]
fn enable_ansi_support() -> std::io::Result<()> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::System::Console::{
GetConsoleMode, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_PROCESSING,
STD_OUTPUT_HANDLE,
};
unsafe {
let handle = windows_sys::Win32::System::Console::GetStdHandle(STD_OUTPUT_HANDLE);
let mut mode: u32 = 0;
GetConsoleMode(handle, &mut mode);
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
Ok(())
}
Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | error: unexpected argument '--xxx' |
clap argument name doesn't match derive field | Use #[arg(long = "xxx")] explicitly |
| 2 | missing field 'xxx' in config |
Config file missing required field | Set default values or check config file format |
| 3 | Cannot read termios |
Terminal doesn't support raw mode (e.g. CI) | Check is_term() before enabling Ratatui |
| 4 | ProgressBar overflow |
Progress bar inc exceeds total | Use pb.set_position() instead of pb.inc() |
| 5 | terminal is not fully interactive |
Running TUI in non-interactive terminal | Check std::io::stdout().is_term() |
| 6 | cannot find cargo-dist |
cargo-dist not installed | cargo install cargo-dist |
| 7 | linker 'cc' not found |
Missing cross-compilation toolchain on Linux | Install gcc-aarch64-linux-gnu |
| 8 | MSI build failed |
WiX Toolset not installed | Install wix and add to PATH |
| 9 | Environment variable not found |
config-rs env var format incorrect | Check prefix, separator, nesting level |
| 10 | the trait FromStr is not implemented |
ValueEnum enum missing derive | Ensure enum derives ValueEnum |
Advanced Optimization
1. Shell Completion & Man Page Generation
use clap::{CommandFactory, Parser};
use clap_complete::{generate, Shell};
use clap_mangen::Man;
#[derive(Parser)]
struct Cli {
#[arg(long, value_name = "SHELL", help = "Generate shell completion script")]
completions: Option<Shell>,
#[arg(long, help = "Generate man page")]
manpage: bool,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, &name, &mut std::io::stdout());
return Ok(());
}
if cli.manpage {
let cmd = Cli::command();
let man = Man::new(cmd);
man.render(&mut std::io::stdout())?;
return Ok(());
}
Ok(())
}
2. Interactive Wizard Mode
use dialoguer::{Select, MultiSelect, Confirm, Input, theme::ColorfulTheme};
fn interactive_mode() -> anyhow::Result<()> {
println!("🛠 ToolsKu CLI Interactive Mode\n");
let action = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select operation")
.items(&["JSON Processing", "Encoding", "File Hash", "Batch Processing", "Config Management"])
.default(0)
.interact()?;
match action {
0 => {
let sub_action = Select::with_theme(&ColorfulTheme::default())
.with_prompt("JSON operation")
.items(&["Format", "Validate", "Query", "Minify"])
.default(0)
.interact()?;
let input_path: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Input file path")
.default("stdin".into())
.interact()?;
let indent: usize = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Indent spaces")
.default(2)
.interact()?;
println!("Execute: JSON {:?}, input: {}, indent: {}", sub_action, input_path, indent);
}
1 => {
let encode_type = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Encoding type")
.items(&["Base64", "URL", "Hex"])
.default(0)
.interact()?;
let decode = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Decode mode?")
.default(false)
.interact()?;
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Input string")
.interact()?;
println!("Execute: {:?} {}, input: {}", encode_type, if decode { "decode" } else { "encode" }, input);
}
_ => {}
}
Ok(())
}
3. Plugin System Architecture
use std::collections::HashMap;
use std::path::PathBuf;
use libloading::{Library, Symbol};
type PluginInitFn = extern "C" fn() -> Box<dyn Plugin>;
pub trait Plugin {
fn name(&self) -> &str;
fn version(&self) -> &str;
fn execute(&self, args: &[String]) -> anyhow::Result<String>;
}
pub struct PluginManager {
plugins: HashMap<String, Box<dyn Plugin>>,
libraries: Vec<Library>,
}
impl PluginManager {
pub fn new() -> Self {
Self {
plugins: HashMap::new(),
libraries: Vec::new(),
}
}
pub fn load_plugin(&mut self, path: &PathBuf) -> anyhow::Result<()> {
unsafe {
let lib = Library::new(path)?;
let init: Symbol<PluginInitFn> = lib.get(b"plugin_init")?;
let plugin = init();
let name = plugin.name().to_string();
println!("Loaded plugin: {} v{}", name, plugin.version());
self.plugins.insert(name, plugin);
self.libraries.push(lib);
}
Ok(())
}
pub fn execute(&self, name: &str, args: &[String]) -> anyhow::Result<String> {
let plugin = self.plugins.get(name)
.ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
plugin.execute(args)
}
pub fn list_plugins(&self) -> Vec<(&str, &str)> {
self.plugins.values()
.map(|p| (p.name(), p.version()))
.collect()
}
}
Comparison Analysis
| Dimension | Rust CLI | Go CLI | Python CLI | Node.js CLI |
|---|---|---|---|---|
| Binary size | 2-10MB | 5-20MB | Needs Python | Needs Node |
| Startup speed | <5ms | 5-20ms | 100-500ms | 50-200ms |
| Memory usage | 1-10MB | 5-30MB | 20-100MB | 30-150MB |
| Argument parsing | ✅clap(derive) | ✅cobra | ✅click/typer | ✅commander/yargs |
| Progress indicators | ✅indicatif | ✅progressbar | ✅rich/tqdm | ✅ora/cli-progress |
| Terminal UI | ✅Ratatui | ⚠️tview/bubbletea | ✅textual/rich | ⚠️blessed/ink |
| Cross-compilation | ✅cargo+target | ✅GOOS/GOARCH | ❌needs PyInstaller | ❌needs pkg/nexe |
| Error handling | ✅compile-time | ⚠️runtime panic | ❌runtime exception | ❌runtime exception |
| Ecosystem maturity | ⭐High | ⭐Very High | ⭐Very High | ⭐High |
| Learning curve | ⭐Steep | ⭐Gentle | ⭐Very Gentle | ⭐Gentle |
Summary: The core advantage of Rust CLI isn't "fast" (though it certainly is), but reliability — compile-time type checking eliminates runtime crashes, clap derive makes argument definitions self-documenting, config-rs makes configuration priority transparent, indicatif makes long-running tasks no longer a "black box", and Ratatui takes CLI from "works" to "excellent". The 2026 production practice: clap derive for arguments → config-rs three-layer config merging → indicatif multi-progress bars → Ratatui interactive TUI → cargo-dist one-click distribution to five platforms. The key is making every aspect exceptional, so when users get your CLI tool, their first reaction is "this tool is really professional".
Recommended Online Tools
- JSON Formatter: /en/json/format
- Base64 Encode/Decode: /en/encode/base64
- Code Formatter: /en/dev/code-formatter
- Hash Calculator: /en/encode/hash
Try these browser-local tools — no sign-up required →