Cursor Rules for Rust: 6 Rules That Make AI Write Safe, Idiomatic Rust
Cursor and Claude Code generate Rust code fast. The problem? They generate Rust that compiles but isn't idiomatic — .unwrap() calls hiding in production paths, clone() sprinkled everywhere to dodge the borrow checker, raw string errors instead of proper Result types, and lifetime annotations that fight the compiler instead of working with it.
You can fix this with targeted rules in your .cursorrules or .cursor/rules/*.mdc files. Here are 6 rules I use on every Rust project, with before/after examples showing exactly what changes.
Rule 1: No .unwrap() in Production Code — Use ? and Proper Error Handling
Never use .unwrap() or .expect() in production code paths.
Use the ? operator to propagate errors. Define custom error types
or use anyhow/thiserror for error handling. .unwrap() is only
acceptable in tests and examples.
.unwrap() is a panic waiting to happen. AI models default to it because it's the shortest path to code that compiles.
Without this rule, Cursor scatters .unwrap() everywhere:
// ❌ Bad: panics hiding in every line
fn load_config(path: &str) -> Config {
let content = std::fs::read_to_string(path).unwrap();
let config: Config = serde_json::from_str(&content).unwrap();
let db_url = config.database_url.unwrap();
let pool = PgPool::connect(&db_url).await.unwrap();
Config { pool, ..config }
}
Five .unwrap() calls. Any one of them takes down your entire service in production.
With this rule, Cursor generates recoverable error handling:
// ✅ Good: errors propagate, callers decide what to do
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config from {path}"))?;
let config: Config = serde_json::from_str(&content)
.context("invalid JSON in config file")?;
let db_url = config.database_url
.as_ref()
.context("missing database_url in config")?;
Ok(config)
}
Every failure has context. The caller decides whether to retry, log, or return a user-friendly error.
Rule 2: Prefer Borrowing Over Cloning — Respect Ownership
Never use .clone() to satisfy the borrow checker unless there is
a genuine need for an owned copy. Prefer references (&T, &mut T).
If a function only reads data, take &T not T. If .clone() is
truly necessary, add a comment explaining why.
AI models treat .clone() as a free escape hatch. In real codebases, unnecessary clones tank performance and hide ownership design problems.
Without this rule:
// ❌ Bad: cloning to avoid thinking about ownership
fn process_orders(orders: Vec<Order>, user: User) {
let username = user.name.clone();
for order in orders.clone() {
let items = order.items.clone();
send_confirmation(username.clone(), items);
}
save_audit_log(orders.clone(), user.clone());
}
Six .clone() calls. This function doesn't need to own anything — it just reads and passes data along.
With this rule:
// ✅ Good: borrows where possible, zero unnecessary copies
fn process_orders(orders: &[Order], user: &User) {
for order in orders {
send_confirmation(&user.name, &order.items);
}
save_audit_log(orders, user);
}
Zero clones. The function borrows everything it needs. If send_confirmation truly needs ownership, that function's signature communicates it.
Rule 3: Use Result<T, E> Return Types — Not Strings for Errors
Never use String as an error type. Define error enums with thiserror
or use anyhow::Error for application code. Library code should use
custom error types that implement std::error::Error.
Cursor loves returning Result<T, String>. It compiles, but you lose pattern matching, error chaining, and the entire Rust error ecosystem.
Without this rule:
// ❌ Bad: stringly-typed errors
fn create_user(email: &str) -> Result<User, String> {
if !email.contains('@') {
return Err("invalid email".to_string());
}
let hash = hash_password("default").map_err(|e| e.to_string())?;
let user = db::insert_user(email, &hash).map_err(|e| e.to_string())?;
Ok(user)
}
Every error is a flat string. Callers can't distinguish "invalid email" from "database is down."
With this rule:
// ✅ Good: typed errors that callers can match on
use thiserror::Error;
#[derive(Debug, Error)]
enum CreateUserError {
#[error("invalid email address: {0}")]
InvalidEmail(String),
#[error("failed to hash password")]
HashError(#[from] argon2::Error),
#[error("database error")]
DbError(#[from] sqlx::Error),
}
fn create_user(email: &str) -> Result<User, CreateUserError> {
if !email.contains('@') {
return Err(CreateUserError::InvalidEmail(email.to_string()));
}
let hash = hash_password("default")?;
let user = db::insert_user(email, &hash)?;
Ok(user)
}
Callers can match on specific variants. Errors chain automatically with #[from]. The ? operator just works.
Rule 4: Always Run Clippy — And Follow Its Suggestions
All code must pass `cargo clippy` with no warnings. Follow clippy
lint suggestions for idiomatic patterns. Common fixes:
- Use `if let` instead of match with one arm
- Use `.iter()` instead of `&vec` in for loops when clearer
- Prefer `unwrap_or_default()` over `unwrap_or(Vec::new())`
- Use `is_empty()` instead of `len() == 0`
Clippy catches dozens of non-idiomatic patterns that AI models generate constantly.
Without this rule:
// ❌ Bad: compiles but clippy flags every line
fn summarize(items: &Vec<Item>) -> String {
if items.len() == 0 {
return "No items".to_string();
}
let mut result = String::new();
for i in 0..items.len() {
let label = match items[i].label.as_ref() {
Some(l) => l.clone(),
None => "unknown".to_string(),
};
result = result + &format!("{}: {}\n", i, label);
}
result
}
&Vec<T> instead of &[T]. len() == 0 instead of is_empty(). Manual indexing. String concatenation with +.
With this rule:
// ✅ Good: idiomatic, clippy-clean Rust
fn summarize(items: &[Item]) -> String {
if items.is_empty() {
return String::from("No items");
}
items
.iter()
.enumerate()
.map(|(i, item)| {
let label = item.label.as_deref().unwrap_or("unknown");
format!("{i}: {label}")
})
.collect::<Vec<_>>()
.join("\n")
}
Slice parameter. Iterator chains. as_deref() instead of matching. Zero clippy warnings.
Rule 5: Lifetimes Should Be Elided When Possible — Explicit Only When Required
Do not add explicit lifetime annotations unless the compiler
requires them. Rely on lifetime elision rules. When lifetimes are
needed, use descriptive names ('input, 'conn) not single letters
('a, 'b) unless the scope is very small.
AI models over-annotate lifetimes, producing code that looks intimidating and is harder to maintain than necessary.
Without this rule:
// ❌ Bad: unnecessary lifetime annotations everywhere
fn first_word<'a>(s: &'a str) -> &'a str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
struct Parser<'a> {
input: &'a str,
}
impl<'a> Parser<'a> {
fn peek<'b>(&'b self) -> &'b str {
&self.input[..1]
}
}
first_word doesn't need explicit lifetimes — elision handles it. peek has redundant annotations.
With this rule:
// ✅ Good: lifetimes only where the compiler needs them
fn first_word(s: &str) -> &str {
match s.find(' ') {
Some(i) => &s[..i],
None => s,
}
}
struct Parser<'input> {
input: &'input str,
}
impl<'input> Parser<'input> {
fn peek(&self) -> &str {
&self.input[..1]
}
}
first_word uses elision. The struct lifetime is named 'input because it describes what the reference points to. peek lets the compiler infer.
Rule 6: Use Trait Bounds, Not Concrete Types — Write Generic Code
Prefer trait bounds over concrete types in function signatures.
Accept `impl AsRef<str>` instead of `String` or `&str` when both
should work. Accept `impl Iterator<Item = T>` instead of `Vec<T>`
when you only need to iterate. Use `where` clauses for readability
when there are multiple bounds.
AI models default to String and Vec everywhere, forcing callers to convert and allocate unnecessarily.
Without this rule:
// ❌ Bad: forces callers to allocate Strings and Vecs
fn search_logs(keyword: String, entries: Vec<LogEntry>) -> Vec<LogEntry> {
entries
.into_iter()
.filter(|e| e.message.contains(&keyword))
.collect()
}
// Caller must clone/allocate even when they already have the right data
let results = search_logs(my_keyword.to_string(), entries.clone());
The caller has to .to_string() and .clone() even if they already have the data in a compatible form.
With this rule:
// ✅ Good: generic, flexible, zero unnecessary allocations
fn search_logs<'a>(
keyword: &str,
entries: impl IntoIterator<Item = &'a LogEntry>,
) -> Vec<&'a LogEntry> {
entries
.into_iter()
.filter(|e| e.message.contains(keyword))
.collect()
}
// Caller passes what they have — no conversion needed
let results = search_logs(&my_keyword, &entries);
Works with slices, vecs, iterators — anything iterable. Zero unnecessary allocations.
Copy-Paste Ready: All 6 Rules
Drop this into your .cursorrules or .cursor/rules/rust.mdc:
# Rust Code Rules
## Error Handling
- Never use .unwrap() or .expect() in production code
- Use ? operator to propagate errors
- Use thiserror for library error types, anyhow for application code
- Never use String as an error type
## Ownership and Borrowing
- Never use .clone() to satisfy the borrow checker without justification
- Prefer &T over T when the function only reads data
- Accept &[T] instead of &Vec<T>
## Lifetimes
- Rely on lifetime elision when possible
- Only add explicit annotations when the compiler requires them
- Use descriptive lifetime names ('input, 'conn) not ('a, 'b)
## Trait Bounds
- Prefer trait bounds over concrete types (impl AsRef<str> over String)
- Accept impl IntoIterator instead of Vec when only iterating
- Use where clauses for readability with multiple bounds
## Clippy
- All code must pass cargo clippy with zero warnings
- Use is_empty() not len() == 0
- Use iterator chains over manual indexing
- Follow all clippy suggestions for idiomatic patterns
## Testing
- .unwrap() and .expect() are acceptable in test code
- Use #[should_panic] or assert matches for error case tests
Want 50+ Production-Tested Rules?
These 6 rules are a starting point. My Cursor Rules Pack v2 includes 50+ rules covering Rust, TypeScript, React, Next.js, and more — organized by language and priority so Cursor applies them consistently.
Stop fighting bad AI output. Give Cursor the rules it needs to write code the Rust way.
Top comments (0)