build-rust-cli
Skill9 starsChanged 9 months agoMITView on GitHub
Best practices for building CLI tools in Rust
In this repository4 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- create-pull-requestSkill
- merge-pull-requestSkill
- releaseSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 3 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 14
Extract params structs: when command handlers need more than ~7 arguments.
- Very specific: line 31
Never
.unwrap()in production paths: Use ? with context instead. - Distinctive: line 43
Graceful partial failure: when iterating over multiple items (files, API resources), collect errors and continue rather than aborting on …
- Distinctive: line 70
Test pure logic: config merging, data transformation, validation.
- Very specific: line 71
Use wiremock for HTTP mocking — create a MockServer, mount expectations, pass the mock URI as the base URL.
- Very specific: line 72
Use tempfile::TempDir for filesystem tests.
- Very specific: line 73
Use temp-env::with_vars + serial_test::serial for environment variable tests (prevents contamination between tests).
- Very specific: line 77
Use assert_cmd to run the compiled binary as a subprocess.
- Very specific: line 78
Use predicates for composable stdout/stderr assertions.
- Distinctive: line 81
Test empty results: verify "No X found." messages.
- Very specific: line 82
Test query parameter forwarding: use wiremock::matchers::query_param to verify filters reach the server.
- Very specific: line 87
Dir + TestCommand pattern: create an isolated temp directory per test, run the binary, assert on stdout/stderr/exit code.
- Very specific: line 97
Use std::process::exit(code) when you need explicit control.
- Very specific: line 121
Use
rustls-tls: instead of native-tls for easier cross-compilation.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Rust CLI Best Practices
Reference guide for building and testing CLI applications in Rust. Synthesized from the Rust CLI Book, clap examples, and ripgrep.
Project Structure
- Keep
main.rsthin. Parse args, load settings, dispatch to command handlers. Business logic lives in dedicated modules (commands.rs,client.rs, etc.). - One module per concern. Separate argument parsing, configuration, HTTP client, and command handlers into distinct files.
- Extract params structs when command handlers need more than ~7 arguments. Use
'alifetime params borrowing from the caller. - For larger CLIs, consider a workspace with focused sub-crates (ripgrep pattern). For smaller CLIs, modules within a single crate are fine.
Argument Parsing (clap derive)
- Use
#[derive(Parser)]and#[derive(Subcommand)]for structured, type-safe argument parsing. - Always add
versionto#[command()]so--versionworks. - Use
ValueEnumfor constrained string choices (e.g., priority levels, output formats). This gives free validation, help text, and tab completion instead of opaque API errors from invalid values. - Use doc comments (
///) on struct fields — clap uses them as help text automatically. - Use
#[arg(short, long)]for common flags. - Use
#[arg(long, default_value = "...")]for options with sensible defaults. - Use
Vec<T>for repeatable flags (e.g.,--label id1 --label id2).
Error Handling
- Use
anyhowfor application-level errors. Usethiserroronly for library crates. - Always add
.context()or.with_context()to give errors meaning. "failed to read config" is better than "No such file or directory". - Never
.unwrap()in production paths. Use?with context instead. - Handle errors in main explicitly rather than returning
Resultfrommain():
This lets you style the error output (red, bold prefix) and control the format.fn main() { if let Err(err) = run() { eprintln!("error: {err:#}"); std::process::exit(1); } } - Handle HTTP status codes specifically: 401 (unauthorized), 404 (not found), 429 (rate limited), 5xx (server error) — each with a user-friendly message.
- Graceful partial failure: when iterating over multiple items (files, API resources), collect errors and continue rather than aborting on the first failure.
Output (stdout vs stderr)
- stdout is for data. This is what gets piped to other programs or redirected to files.
- stderr is for diagnostics. Errors, warnings, spinners, progress bars, log messages.
- Spinners/progress bars (
indicatif) default to stderr — keep it that way. - Provide a
--jsonflag for machine-readable output. When active, disable spinners and colors. Useserde_json::to_string_pretty()for human-readable JSON. - Use
comfy_tablefor tabular human output with UTF-8 borders and colored headers. - Use
consolecrate for styled text (bold, colors, dim). - Detect if stdout is a terminal (
IsTerminaltrait, stable since Rust 1.70) to auto-disable colors when piped.
Configuration
- Layer configuration with clear precedence (each layer overrides the previous):
- Hardcoded defaults (
Defaultimpl) - System/project config files (JSON, TOML)
- User-local config files (git-ignored)
- Environment variables (
APP_KEY,APP_BASE_URL, etc.) - CLI arguments (highest priority)
- Hardcoded defaults (
- Use deep merge for nested config objects — overlay keys from higher layers, preserve unset keys from lower layers.
- Silently skip missing config files (not an error if the file doesn't exist).
- Error on malformed config files with context including the file path.
Testing
Unit Tests (in-module #[cfg(test)])
- Test pure logic: config merging, data transformation, validation.
- Use
wiremockfor HTTP mocking — create aMockServer, mount expectations, pass the mock URI as the base URL. - Use
tempfile::TempDirfor filesystem tests. - Use
temp-env::with_vars+serial_test::serialfor environment variable tests (prevents contamination between tests). - Group tests by concern with separator comments:
// ── Construction ──,// ── Error handling ──.
Integration Tests (tests/cli.rs)
- Use
assert_cmdto run the compiled binary as a subprocess. - Use
predicatesfor composable stdout/stderr assertions. - Test both JSON and human output modes for every command.
- Test error conditions: missing required args, API errors (401, 404), invalid enum values.
- Test empty results: verify "No X found." messages.
- Test query parameter forwarding: use
wiremock::matchers::query_paramto verify filters reach the server. - Create a helper function (
plane_cmd_with(mock_uri)) to reduce boilerplate in tests that need a mock server. - Use
.expect(1)on mocks when you want to verify the request was actually made.
Test patterns from ripgrep
- Dir + TestCommand pattern: create an isolated temp directory per test, run the binary, assert on stdout/stderr/exit code.
- Self-contained flag tests: each flag definition has its own test that parses args and verifies the resulting config.
- Snapshot testing (
trycmd/snapbox): write expected input/output as files, run them automatically. Good for regression testing help text and output format.
Exit Codes
- 0 = success.
- 1 = general failure (default when
main()returnsErr). - 2 = usage error (invalid arguments — clap handles this automatically).
- For search-like tools, follow grep convention: 0 = found, 1 = not found, 2 = error.
- Use
std::process::exit(code)when you need explicit control.
Dependencies (recommended stack)
| Purpose | Crate |
| Arg parsing | clap (derive) |
| Error handling | anyhow |
| Serialization | serde + serde_json |
| HTTP client | reqwest (json, rustls-tls) |
| Async runtime | tokio |
| Terminal colors | console |
| Progress bars | indicatif |
| Tables | comfy-table |
| HTTP mocking | wiremock |
| CLI testing | assert_cmd + predicates |
| Temp files | tempfile |
| Env var testing | temp-env + serial_test |
Misc
- Run
cargo fmtandcargo clippy --all-targets -- -D warningsbefore every commit. edition = "2024"(or latest stable) in Cargo.toml.- Fill in Cargo.toml metadata: description, license, repository, authors.
- Use
rustls-tlsinstead of native-tls for easier cross-compilation. - Set
default-features = falseonreqwestto avoid pulling in OpenSSL.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

