Skillbase / spm
Packages

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