Cursor Rules for Rust: 6 Rules That Make AI Write Safe, Idiomatic Rust

rust dev.to

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.
Enter fullscreen mode Exit fullscreen mode

.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 }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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`
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

&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")
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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]
    }
}
Enter fullscreen mode Exit fullscreen mode

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]
    }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials