dotmd

.cursorrules — Rust CLI Applications

Cursor rules for Rust CLI apps using clap, thiserror/anyhow, tracing, and assert_cmd tests.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • Cursor: Save as .cursorrules in your project at .cursorrules.

Configuration

.cursorrules

1# .cursorrules — Rust CLI Applications
2
3You are a senior Rust developer building a command-line application. You use clap for argument parsing, thiserror + anyhow for error handling, tracing for observability, and serde for serialization. You write idiomatic Rust — clarity over cleverness, explicit error handling, composable design. You ship binaries that are fast, correct, and pleasant to use.
4
5## Quick Reference
6
7| Area | Convention |
8| ----------------- | ------------------------------------------------------------- |
9| Argument parsing | clap v4 (derive macro) |
10| Error handling | `thiserror` for domain errors, `anyhow` for main/glue |
11| Logging | `tracing` + `tracing-subscriber` (env-filter) |
12| Serialization | `serde` + `serde_json` / `toml` |
13| HTTP client | `reqwest` (with `rustls-tls`, only if needed) |
14| Async runtime | `tokio` (only if the app needs concurrent I/O; prefer sync) |
15| Config files | TOML via `serde` + `toml` crate |
16| Testing | Built-in `#[test]` + `assert_cmd` + `predicates` |
17| Linting | `clippy` (pedantic where practical) |
18| Formatting | `rustfmt` |
19
20## Project Structure
21
22```
23├── src/
24│ ├── main.rs # Entry: parse args, init tracing, call run()
25│ ├── cli.rs # Clap derive structs
26│ ├── config.rs # Config file loading
27│ ├── error.rs # Domain error types (thiserror)
28│ └── commands/ # One module per subcommand
29│ ├── mod.rs
30│ ├── init.rs
31│ └── run.rs
32├── tests/
33│ └── cli.rs # Integration tests (assert_cmd)
34├── Cargo.toml
35└── Cargo.lock # Always committed for binaries
36```
37
38## Entry Point
39
40Keep `main.rs` thin: parse args, init tracing, delegate to `run()`. All fallible logic returns `anyhow::Result`.
41
42```rust
43// src/main.rs
44use anyhow::Result;
45use clap::Parser;
46mod cli;
47mod commands;
48mod config;
49mod error;
50
51fn main() -> Result<()> {
52 let args = cli::Args::parse();
53
54 tracing_subscriber::fmt()
55 .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env()
56 .unwrap_or_else(|_| {
57 tracing_subscriber::EnvFilter::new(if args.verbose { "debug" } else { "info" })
58 }))
59 .with_writer(std::io::stderr)
60 .init();
61
62 match args.command {
63 cli::Command::Init(cmd) => commands::init::execute(cmd),
64 cli::Command::Run(cmd) => commands::run::execute(cmd),
65 }
66}
67```
68
69Tracing goes to `stderr`. Structured output goes to `stdout`. Users can pipe output while seeing logs.
70
71## CLI Definition (clap derive)
72
73```rust
74// src/cli.rs
75use std::path::PathBuf;
76use clap::{Parser, Subcommand};
77
78#[derive(Parser)]
79#[command(name = "mytool", version, about = "A tool that does things well")]
80pub struct Args {
81 #[arg(short, long, global = true)]
82 pub verbose: bool,
83
84 #[arg(short, long, global = true, default_value = "config.toml")]
85 pub config: PathBuf,
86
87 #[command(subcommand)]
88 pub command: Command,
89}
90
91#[derive(Subcommand)]
92pub enum Command {
93 /// Initialize a new project
94 Init(InitArgs),
95 /// Run the main workflow
96 Run(RunArgs),
97}
98
99#[derive(clap::Args)]
100pub struct InitArgs {
101 #[arg(default_value = ".")]
102 pub path: PathBuf,
103 #[arg(long)]
104 pub force: bool,
105}
106
107#[derive(clap::Args)]
108pub struct RunArgs {
109 #[arg(required = true)]
110 pub files: Vec<PathBuf>,
111 #[arg(short, long, default_value = "json")]
112 pub format: OutputFormat,
113 #[arg(short, long)]
114 pub output: Option<PathBuf>,
115}
116
117#[derive(Clone, clap::ValueEnum)]
118pub enum OutputFormat { Json, Yaml, Text }
119```
120
121- Global flags use `global = true` to work with any subcommand.
122- Use `clap::ValueEnum` for closed option sets, not raw strings.
123- Doc comments become `--help` text automatically.
124
125## Error Handling
126
127`thiserror` for domain errors callers match on. `anyhow` for the glue.
128
129```rust
130// src/error.rs
131use std::path::PathBuf;
132use thiserror::Error;
133
134#[derive(Error, Debug)]
135pub enum AppError {
136 #[error("config file not found: {path}")]
137 ConfigNotFound { path: PathBuf },
138 #[error("invalid config: {reason}")]
139 ConfigInvalid { reason: String },
140 #[error("unsupported format: {0}")]
141 UnsupportedFormat(String),
142}
143```
144
145Always add context when propagating errors:
146
147```rust
148use anyhow::{Context, Result};
149
150pub fn execute(args: RunArgs) -> Result<()> {
151 let config = Config::load(&args.config).context("failed to load configuration")?;
152
153 for file in &args.files {
154 let content = std::fs::read_to_string(file)
155 .with_context(|| format!("failed to read {}", file.display()))?;
156
157 let result = process(&content, &config)
158 .with_context(|| format!("failed to process {}", file.display()))?;
159
160 match &args.output {
161 Some(path) => std::fs::write(path, &result)
162 .with_context(|| format!("failed to write {}", path.display()))?,
163 None => print!("{result}"),
164 }
165 }
166 Ok(())
167}
168```
169
170**Rules:** No `.unwrap()` in production code. Use `.expect()` only for genuinely impossible cases. Always `.context()` on I/O and parsing operations.
171
172## Config Loading
173
174```rust
175// src/config.rs
176use std::path::Path;
177use anyhow::{Context, Result};
178use serde::Deserialize;
179use crate::error::AppError;
180
181#[derive(Debug, Deserialize)]
182pub struct Config {
183 pub project_name: String,
184 #[serde(default = "default_output_dir")]
185 pub output_dir: String,
186 #[serde(default)]
187 pub features: Features,
188}
189
190#[derive(Debug, Default, Deserialize)]
191pub struct Features {
192 #[serde(default)]
193 pub minify: bool,
194 #[serde(default = "default_max_size")]
195 pub max_file_size: u64,
196}
197
198fn default_output_dir() -> String { "dist".into() }
199fn default_max_size() -> u64 { 10 * 1024 * 1024 }
200
201impl Config {
202 pub fn load(path: &Path) -> Result<Self> {
203 if !path.exists() {
204 return Err(AppError::ConfigNotFound { path: path.to_owned() }.into());
205 }
206 let content = std::fs::read_to_string(path)
207 .with_context(|| format!("could not read {}", path.display()))?;
208 toml::from_str(&content)
209 .with_context(|| format!("invalid TOML in {}", path.display()))
210 }
211}
212```
213
214Use `#[serde(default)]` liberally — CLIs should work with minimal config.
215
216## Testing
217
218### Unit Tests (colocated)
219
220```rust
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::io::Write;
225 use tempfile::NamedTempFile;
226
227 #[test]
228 fn loads_valid_config() {
229 let mut f = NamedTempFile::new().unwrap();
230 writeln!(f, r#"project_name = "test""#).unwrap();
231 let config = Config::load(f.path()).unwrap();
232 assert_eq!(config.project_name, "test");
233 assert_eq!(config.output_dir, "dist");
234 }
235
236 #[test]
237 fn errors_on_missing_file() {
238 assert!(Config::load(Path::new("nope.toml")).is_err());
239 }
240}
241```
242
243### Integration Tests (assert_cmd)
244
245Test the compiled binary end-to-end:
246
247```rust
248// tests/cli.rs
249use assert_cmd::Command;
250use predicates::prelude::*;
251use tempfile::TempDir;
252
253fn cmd() -> Command { Command::cargo_bin("mytool").unwrap() }
254
255#[test]
256fn prints_help() {
257 cmd().arg("--help").assert().success()
258 .stdout(predicate::str::contains("A tool that does things well"));
259}
260
261#[test]
262fn fails_without_subcommand() {
263 cmd().assert().failure().stderr(predicate::str::contains("Usage"));
264}
265
266#[test]
267fn init_creates_config_file() {
268 let dir = TempDir::new().unwrap();
269 cmd().args(["init", dir.path().to_str().unwrap()]).assert().success();
270 assert!(dir.path().join("config.toml").exists());
271}
272
273#[test]
274fn run_fails_on_missing_input() {
275 cmd().args(["run", "nope.txt"]).assert().failure()
276 .stderr(predicate::str::contains("failed to read"));
277}
278```
279
280## Conventions
281
282- **Naming:** crate kebab-case (`my-tool`), modules snake_case, types PascalCase, functions snake_case.
283- **Dependencies:** Be deliberate. Prefer `std` when close enough. Pin major versions. Always commit `Cargo.lock`. Use `features` to trim unused deps.
284- **Performance:** `&str`/`&Path` over owned types in signatures. `BufReader`/`BufWriter` for file I/O. `Vec::with_capacity()` when size is known. Only reach for async when you have concurrent I/O.
285- **Clippy:** Run before committing. Configure in `Cargo.toml`:
286
287```toml
288[lints.clippy]
289pedantic = { level = "warn", priority = -1 }
290module_name_repetitions = "allow"
291missing_errors_doc = "allow"
292```
293
294## Commands
295
296```bash
297cargo build # Debug build
298cargo build --release # Release build
299cargo run -- init . # Run with args
300cargo test # All tests
301cargo clippy # Lint
302cargo fmt # Format
303RUST_LOG=debug cargo run -- run input.txt # Debug logging
304```
305
306## When Generating Code
307
3081. **Search first.** Check existing modules before adding types or functions.
3092. **Match existing patterns.** If commands use a certain error handling style, follow it.
3103. **No new dependencies** without asking. Include rationale and feature flags.
3114. **Handle all errors.** No `.unwrap()` in business logic. `?` with `.context()`.
3125. **Write integration tests** for user-facing behavior. Unit tests for complex internals.
3136. **Keep functions small.** If it does two things, split it.
314

Community feedback

0 found this helpful

Works with: