.cursorrules — Rust CLI Applications
Cursor rules for Rust CLI apps using clap, thiserror/anyhow, tracing, and assert_cmd tests.
Install path
Use this file for each supported tool in your project.
- Cursor: Save as
.cursorrulesin your project at.cursorrules.
Configuration
.cursorrules
1# .cursorrules — Rust CLI Applications23You 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.45## Quick Reference67| 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` |1920## Project Structure2122```23├── src/24│ ├── main.rs # Entry: parse args, init tracing, call run()25│ ├── cli.rs # Clap derive structs26│ ├── config.rs # Config file loading27│ ├── error.rs # Domain error types (thiserror)28│ └── commands/ # One module per subcommand29│ ├── mod.rs30│ ├── init.rs31│ └── run.rs32├── tests/33│ └── cli.rs # Integration tests (assert_cmd)34├── Cargo.toml35└── Cargo.lock # Always committed for binaries36```3738## Entry Point3940Keep `main.rs` thin: parse args, init tracing, delegate to `run()`. All fallible logic returns `anyhow::Result`.4142```rust43// src/main.rs44use anyhow::Result;45use clap::Parser;46mod cli;47mod commands;48mod config;49mod error;5051fn main() -> Result<()> {52 let args = cli::Args::parse();5354 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();6162 match args.command {63 cli::Command::Init(cmd) => commands::init::execute(cmd),64 cli::Command::Run(cmd) => commands::run::execute(cmd),65 }66}67```6869Tracing goes to `stderr`. Structured output goes to `stdout`. Users can pipe output while seeing logs.7071## CLI Definition (clap derive)7273```rust74// src/cli.rs75use std::path::PathBuf;76use clap::{Parser, Subcommand};7778#[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,8384 #[arg(short, long, global = true, default_value = "config.toml")]85 pub config: PathBuf,8687 #[command(subcommand)]88 pub command: Command,89}9091#[derive(Subcommand)]92pub enum Command {93 /// Initialize a new project94 Init(InitArgs),95 /// Run the main workflow96 Run(RunArgs),97}9899#[derive(clap::Args)]100pub struct InitArgs {101 #[arg(default_value = ".")]102 pub path: PathBuf,103 #[arg(long)]104 pub force: bool,105}106107#[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}116117#[derive(Clone, clap::ValueEnum)]118pub enum OutputFormat { Json, Yaml, Text }119```120121- 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.124125## Error Handling126127`thiserror` for domain errors callers match on. `anyhow` for the glue.128129```rust130// src/error.rs131use std::path::PathBuf;132use thiserror::Error;133134#[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```144145Always add context when propagating errors:146147```rust148use anyhow::{Context, Result};149150pub fn execute(args: RunArgs) -> Result<()> {151 let config = Config::load(&args.config).context("failed to load configuration")?;152153 for file in &args.files {154 let content = std::fs::read_to_string(file)155 .with_context(|| format!("failed to read {}", file.display()))?;156157 let result = process(&content, &config)158 .with_context(|| format!("failed to process {}", file.display()))?;159160 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```169170**Rules:** No `.unwrap()` in production code. Use `.expect()` only for genuinely impossible cases. Always `.context()` on I/O and parsing operations.171172## Config Loading173174```rust175// src/config.rs176use std::path::Path;177use anyhow::{Context, Result};178use serde::Deserialize;179use crate::error::AppError;180181#[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}189190#[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}197198fn default_output_dir() -> String { "dist".into() }199fn default_max_size() -> u64 { 10 * 1024 * 1024 }200201impl 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```213214Use `#[serde(default)]` liberally — CLIs should work with minimal config.215216## Testing217218### Unit Tests (colocated)219220```rust221#[cfg(test)]222mod tests {223 use super::*;224 use std::io::Write;225 use tempfile::NamedTempFile;226227 #[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 }235236 #[test]237 fn errors_on_missing_file() {238 assert!(Config::load(Path::new("nope.toml")).is_err());239 }240}241```242243### Integration Tests (assert_cmd)244245Test the compiled binary end-to-end:246247```rust248// tests/cli.rs249use assert_cmd::Command;250use predicates::prelude::*;251use tempfile::TempDir;252253fn cmd() -> Command { Command::cargo_bin("mytool").unwrap() }254255#[test]256fn prints_help() {257 cmd().arg("--help").assert().success()258 .stdout(predicate::str::contains("A tool that does things well"));259}260261#[test]262fn fails_without_subcommand() {263 cmd().assert().failure().stderr(predicate::str::contains("Usage"));264}265266#[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}272273#[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```279280## Conventions281282- **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`:286287```toml288[lints.clippy]289pedantic = { level = "warn", priority = -1 }290module_name_repetitions = "allow"291missing_errors_doc = "allow"292```293294## Commands295296```bash297cargo build # Debug build298cargo build --release # Release build299cargo run -- init . # Run with args300cargo test # All tests301cargo clippy # Lint302cargo fmt # Format303RUST_LOG=debug cargo run -- run input.txt # Debug logging304```305306## When Generating Code3073081. **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: