// 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--path=
.cursor/rules/
// File Content
.cursor/rules (MDC)
1---2description: Rust CLI conventions using clap, structured errors, tracing, and integration tests.3globs:4alwaysApply: true5---67# .cursorrules — Rust CLI Applications89You 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.1011## Quick Reference1213| 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` |2526## Project Structure2728```29├── src/30│ ├── main.rs # Entry: parse args, init tracing, call run()31│ ├── cli.rs # Clap derive structs32│ ├── config.rs # Config file loading33│ ├── error.rs # Domain error types (thiserror)34│ └── commands/ # One module per subcommand35│ ├── mod.rs36│ ├── init.rs37│ └── run.rs38├── tests/39│ └── cli.rs # Integration tests (assert_cmd)40├── Cargo.toml41└── Cargo.lock # Always committed for binaries42```4344## Entry Point4546Keep `main.rs` thin: parse args, init tracing, delegate to `run()`. All fallible logic returns `anyhow::Result`.4748```rust49// src/main.rs50use anyhow::Result;51use clap::Parser;52mod cli;53mod commands;54mod config;55mod error;5657fn main() -> Result<()> {58 let args = cli::Args::parse();5960 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();6768 match args.command {69 cli::Command::Init(cmd) => commands::init::execute(cmd),70 cli::Command::Run(cmd) => commands::run::execute(cmd),71 }72}73```7475Tracing goes to `stderr`. Structured output goes to `stdout`. Users can pipe output while seeing logs.7677## CLI Definition (clap derive)7879```rust80// src/cli.rs81use std::path::PathBuf;82use clap::{Parser, Subcommand};8384#[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,8990 #[arg(short, long, global = true, default_value = "config.toml")]91 pub config: PathBuf,9293 #[command(subcommand)]94 pub command: Command,95}9697#[derive(Subcommand)]98pub enum Command {99 /// Initialize a new project100 Init(InitArgs),101 /// Run the main workflow102 Run(RunArgs),103}104105#[derive(clap::Args)]106pub struct InitArgs {107 #[arg(default_value = ".")]108 pub path: PathBuf,109 #[arg(long)]110 pub force: bool,111}112113#[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}122123#[derive(Clone, clap::ValueEnum)]124pub enum OutputFormat { Json, Yaml, Text }125```126127- 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.130131## Error Handling132133`thiserror` for domain errors callers match on. `anyhow` for the glue.134135```rust136// src/error.rs137use std::path::PathBuf;138use thiserror::Error;139140#[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```150151Always add context when propagating errors:152153```rust154use anyhow::{Context, Result};155156pub fn execute(args: RunArgs) -> Result<()> {157 let config = Config::load(&args.config).context("failed to load configuration")?;158159 for file in &args.files {160 let content = std::fs::read_to_string(file)161 .with_context(|| format!("failed to read {}", file.display()))?;162163 let result = process(&content, &config)164 .with_context(|| format!("failed to process {}", file.display()))?;165166 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```175176**Rules:** No `.unwrap()` in production code. Use `.expect()` only for genuinely impossible cases. Always `.context()` on I/O and parsing operations.177178## Config Loading179180```rust181// src/config.rs182use std::path::Path;183use anyhow::{Context, Result};184use serde::Deserialize;185use crate::error::AppError;186187#[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}195196#[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}203204fn default_output_dir() -> String { "dist".into() }205fn default_max_size() -> u64 { 10 * 1024 * 1024 }206207impl 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```219220Use `#[serde(default)]` liberally — CLIs should work with minimal config.221222## Testing223224### Unit Tests (colocated)225226```rust227#[cfg(test)]228mod tests {229 use super::*;230 use std::io::Write;231 use tempfile::NamedTempFile;232233 #[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 }241242 #[test]243 fn errors_on_missing_file() {244 assert!(Config::load(Path::new("nope.toml")).is_err());245 }246}247```248249### Integration Tests (assert_cmd)250251Test the compiled binary end-to-end:252253```rust254// tests/cli.rs255use assert_cmd::Command;256use predicates::prelude::*;257use tempfile::TempDir;258259fn cmd() -> Command { Command::cargo_bin("mytool").unwrap() }260261#[test]262fn prints_help() {263 cmd().arg("--help").assert().success()264 .stdout(predicate::str::contains("A tool that does things well"));265}266267#[test]268fn fails_without_subcommand() {269 cmd().assert().failure().stderr(predicate::str::contains("Usage"));270}271272#[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}278279#[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```285286## Conventions287288- **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`:292293```toml294[lints.clippy]295pedantic = { level = "warn", priority = -1 }296module_name_repetitions = "allow"297missing_errors_doc = "allow"298```299300## Commands301302```bash303cargo build # Debug build304cargo build --release # Release build305cargo run -- init . # Run with args306cargo test # All tests307cargo clippy # Lint308cargo fmt # Format309RUST_LOG=debug cargo run -- run input.txt # Debug logging310```311312## When Generating Code3133141. **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