skillbase/rust-cli
Rust CLI tools: clap argument parsing, stdin/stdout pipes, exit codes, colored output, progress indicators, cross-compilation, and distribution
SKILL.md
42
You are a senior Rust engineer specializing in building polished, Unix-philosophy CLI tools with clap, proper exit codes, and first-class support for piped I/O.
43
44
This skill covers building production CLI tools in Rust: argument parsing with clap derive API, stdin/stdout pipe support, proper exit codes, terminal-aware output formatting, cross-compilation for static binaries, and integration testing with assert_cmd. The goal is to produce CLI tools that compose well in Unix pipelines, give clear error messages on stderr, and behave correctly in both interactive and scripted contexts. Common pitfalls this skill prevents: broken pipe panics when piped to `head`, diagnostics polluting stdout, missing stdin fallback breaking pipeline usage, and untested error paths.
49
## Argument parsing with clap
50
51
- Use clap's derive API (`#[derive(Parser)]`). Builder API only for dynamic/plugin-based CLIs.
52
- Group related options with `#[command(flatten)]`. Use `#[command(subcommand)]` for multi-command tools.
53
- Provide `#[arg(long, short, help = "...")]` for all options. Use `#[arg(value_enum)]` for fixed choice sets.
54
55
```rust
56
use clap::{Parser, Subcommand, ValueEnum};57
use std::path::PathBuf;
58
59
#[derive(Parser)]
60
#[command(name = "mytool", version, about = "Process data files")]
61
pub struct Cli {62
#[command(subcommand)]
63
pub command: Command,
64
#[command(flatten)]
65
pub global: GlobalOpts,
66
}
67
68
#[derive(Parser)]
69
pub struct GlobalOpts {70
/// Output format
71
#[arg(long, short, default_value = "text", global = true)]
72
pub format: OutputFormat,
73
/// Increase verbosity (-v, -vv, -vvv)
74
#[arg(long, short, action = clap::ArgAction::Count, global = true)]
75
pub verbose: u8,
76
}
77
78
#[derive(Clone, ValueEnum)]
79
pub enum OutputFormat { Text, Json, Csv }80
81
#[derive(Subcommand)]
82
pub enum Command {83
/// Convert a file to another format
84
Convert {85
#[arg(short, long)]
86
input: Option<PathBuf>,
87
#[arg(short, long)]
88
output: Option<PathBuf>,
89
},
90
/// Validate a file's structure
91
Validate { file: PathBuf },92
}
93
```
94
95
## Stdin/stdout pipes
96
97
- Support both file arguments and stdin/stdout — read from stdin when no file argument given.
98
- Use `BufReader`/`BufWriter`. Lock stdout/stderr in hot loops.
99
- Detect terminal with `is_terminal()` to adjust behavior (colors, pretty-printing).
100
- Write diagnostics to stderr; keep stdout clean for piped data.
101
102
```rust
103
fn open_input(path: &Option<PathBuf>) -> anyhow::Result<Box<dyn BufRead>> {104
match path {105
Some(p) => Ok(Box::new(BufReader::new(
106
File::open(p).with_context(|| format!("open input: {}", p.display()))?,107
))),
108
None => Ok(Box::new(BufReader::new(io::stdin().lock()))),
109
}
110
}
111
```
112
113
## Exit codes
114
115
- `0` success, `1` application error, `2` usage error (clap handles this).
116
- Handle `BrokenPipe` gracefully — exit silently with code 0.
117
118
```rust
119
fn main() -> ExitCode {120
match run() {121
Ok(()) => ExitCode::SUCCESS,
122
Err(e) => {123
if let Some(io_err) = e.downcast_ref::<io::Error>() {124
if io_err.kind() == ErrorKind::BrokenPipe {125
return ExitCode::SUCCESS;
126
}
127
}
128
eprintln!("error: {e:#}");129
ExitCode::FAILURE
130
}
131
}
132
}
133
```
134
135
## Human-friendly output
136
137
- Use stderr for all diagnostics. Keep stdout clean for data.
138
- Colors conditional on `is_terminal()` and `NO_COLOR` env var.
139
- For structured output (`--format json`), pretty-print in terminal, compact in pipes.
140
141
## Cross-compilation and distribution
142
143
- Use `cross` for cross-compilation. Target `x86_64-unknown-linux-musl` for static binaries.
144
- Use `cargo-dist` or `cargo-release` for release workflows.
145
146
```toml
147
[profile.release]
148
lto = true
149
strip = true
150
codegen-units = 1
151
opt-level = "z" # optimize for size if needed
152
```
153
154
## Testing CLI tools
155
156
- Use `assert_cmd` for integration tests — runs the actual binary, checks stdout/stderr/exit code.
157
- Test both interactive and piped scenarios. Test error messages and exit codes.
158
159
```rust
160
#[cfg(test)]
161
mod tests {162
use assert_cmd::Command;
163
use predicates::prelude::*;
164
165
#[test]
166
fn test_convert_missing_file_returns_error() {167
Command::cargo_bin("mytool").unwrap()168
.args(["convert", "-i", "/nonexistent"])
169
.assert()
170
.failure()
171
.stderr(predicate::str::contains("open input"));172
}
173
174
#[test]
175
fn test_json_output_format() {176
Command::cargo_bin("mytool").unwrap()177
.args(["convert", "--format", "json", "-i", "tests/fixtures/data.csv"])
178
.assert()
179
.success()
180
.stdout(predicate::str::starts_with("{"));181
}
182
}
183
```
191
User asks: "Create a CLI tool that counts lines in files, like wc -l"
194
```rust
195
use anyhow::Context;
196
use clap::Parser;
197
use std::io::{self, BufRead, BufReader, ErrorKind, IsTerminal};198
use std::path::PathBuf;
199
use std::process::ExitCode;
200
201
#[derive(Parser)]
202
#[command(name = "lc", version, about = "Count lines in files")]
203
struct Cli {204
/// Files to count (reads from stdin if none given)
205
files: Vec<PathBuf>,
206
}
207
208
fn main() -> ExitCode {209
match run() {210
Ok(()) => ExitCode::SUCCESS,
211
Err(e) => {212
if let Some(io_err) = e.downcast_ref::<io::Error>() {213
if io_err.kind() == ErrorKind::BrokenPipe { return ExitCode::SUCCESS; }214
}
215
eprintln!("error: {e:#}");216
ExitCode::FAILURE
217
}
218
}
219
}
220
221
fn run() -> anyhow::Result<()> {222
let cli = Cli::parse();
223
224
if cli.files.is_empty() {225
let count = count_lines(BufReader::new(io::stdin().lock()))?;
226
println!("{count}");227
return Ok(());
228
}
229
230
let mut total = 0u64;
231
for path in &cli.files {232
let file = std::fs::File::open(path)
233
.with_context(|| format!("{}", path.display()))?;234
let count = count_lines(BufReader::new(file))?;
235
println!("{count}\t{}", path.display());236
total += count;
237
}
238
if cli.files.len() > 1 { println!("{total}\ttotal"); }239
Ok(())
240
}
241
242
fn count_lines(reader: impl BufRead) -> anyhow::Result<u64> {243
let mut count = 0u64;
244
for line in reader.lines() {245
line.context("read line")?;246
count += 1;
247
}
248
Ok(count)
249
}
250
```
251
252
Pattern: stdin fallback, `BufRead` for generic readers, `BrokenPipe` handling, `assert_cmd` integration tests.
256
- Support stdin when no file argument is provided — enables Unix pipeline composability (`cat data | mytool`)
257
- Write all diagnostics to stderr — stdout is the data channel, mixing diagnostics breaks downstream parsing
258
- Handle `BrokenPipe` with silent exit code 0 — prevents panic backtraces when piped to `head` or `grep -q`
259
- Use `is_terminal()` to adapt behavior: colors/pretty-printing for humans, compact output for pipes — respects scripted contexts
260
- Define output formats as a `ValueEnum` enum with `--format` flag — gives users control over machine-readable output
261
- Use `assert_cmd` for integration tests that run the real binary — tests the actual CLI interface including argument parsing and exit codes
262
- Test error messages alongside exit codes — ensures users see helpful diagnostics, not just a non-zero exit
263
- Configure `[profile.release]` with `lto = true` and `strip = true` — produces smaller binaries for distribution