dotmd
// Config Record

>.cursor/rules — Rust CLI Applications

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

author:
dotmd Team
license:CC0
published:Feb 19, 2026
// Installation

>Add this file to your project repository:

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