Cursor rules for Rust error handling: anyhow vs thiserror, propagating correctly
Published 2026-01-31 by Owner
Rust has two dominant error handling crates with different use cases: anyhow for application-level errors and thiserror for library-level structured errors. Cursor’s training data has both patterns, and defaults drift between them unpredictably. The result on a project: inconsistent error handling that needs cleanup in review.
A focused .cursorrules section fixes this.
The pattern problem
Without explicit rules, Cursor’s first-attempt suggestions for error handling vary:
- Sometimes returns
Result<T, Box<dyn Error>> - Sometimes uses
anyhow::Result<T> - Sometimes defines a custom error enum with
thiserror - Sometimes uses
?to propagate without conversion
All of these are valid Rust. None of them is wrong in isolation. But they shouldn’t coexist in one project — a codebase that mixes them is harder to maintain.
The rule
Add to .cursorrules or .cursor/rules/rust.mdc:
# Rust error handling
This project uses thiserror for library/domain errors and anyhow for
application errors. The split:
- src/lib.rs and any code in src/ that's part of the public library:
thiserror with explicit error enums
- src/bin/, examples/, and the main binary's error path: anyhow
Specific rules:
1. Define error types in src/error.rs at the workspace root and re-export
from src/lib.rs
2. Domain errors are enums with thiserror::Error:
```rust
#[derive(thiserror::Error, Debug)]
pub enum DomainError {
#[error("not found: {0}")]
NotFound(String),
#[error("invalid input: {reason}")]
InvalidInput { reason: String },
#[error(transparent)]
Db(#[from] sqlx::Error),
}
-
Library functions return
Result<T, DomainError>or a more specific error type. Never returnanyhow::Resultfrom library code. -
Application code (main.rs, integration glue) can use anyhow::Result for ergonomic error chaining. Use .context() for adding context.
-
The
?operator handles error propagation. UseFrom<X> for Yconversions instead of manualmap_errwhere possible. -
For external errors that need wrapping, define an enum variant (#[from]) rather than converting at the call site.
-
Test functions can use anyhow::Result for simplicity.
This is about 40 lines of rule. The cost is in writing it once. The benefit is consistent Rust error handling across the project.
## What changes after the rule
Concrete examples of how Cursor's suggestions improve.
**Before the rule, asking for a function that fetches a user:**
```rust
pub fn fetch_user(id: i64) -> Result<User, Box<dyn std::error::Error>> {
let db = get_db()?;
let user = db.query_one("SELECT * FROM users WHERE id = ?", &[&id])?;
Ok(user.into())
}
Box<dyn Error> is a generic catch-all. It works but loses type information.
After the rule, the same function:
pub fn fetch_user(id: i64) -> Result<User, DomainError> {
let db = get_db()?;
let user = db.query_one("SELECT * FROM users WHERE id = ?", &[&id])?;
Ok(user.into())
}
Now the error type is structured. Callers can match on specific cases. The ? operator works because DomainError has From<sqlx::Error> (assumed defined).
The functional code is the same. The error contract is meaningfully better.
A specific failure to address
A pattern I see in Rust+AI work that the rule prevents:
Cursor sometimes generates code that uses .expect("...") or .unwrap() because the error handling is “obvious” in context. For example:
let config = std::fs::read_to_string("config.toml").unwrap();
In a binary, this might be acceptable for a startup config that you’d want to crash on. In library code, it’s almost never appropriate.
Adding to the rules:
8. Never use unwrap() or expect() in library code. If you can't handle
the error, return it. unwrap() is OK in tests; expect() with a clear
message is OK in main.rs for unrecoverable startup failures only.
After this, Cursor’s suggestions stop including unwrap() in library code. The model follows the explicit instruction.
Error context for debugging
A nuance: anyhow’s .context() is great for adding context. Cursor doesn’t always use it.
Adding:
9. When propagating errors with anyhow, add context that helps debugging:
`result.context("failed to load config from disk")?`
Avoid vague contexts like "operation failed". The context should
explain what was being attempted, not what failed.
After this rule, Cursor’s anyhow-using code has more useful error chains. Debugging issues in production becomes meaningfully easier because the errors say what they were trying to do.
What about chained crate-specific errors?
For libraries that integrate with other libraries (sqlx, reqwest, serde, tokio), each has its own error types. The pattern is:
#[derive(thiserror::Error, Debug)]
pub enum DomainError {
#[error(transparent)]
Db(#[from] sqlx::Error),
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Serde(#[from] serde_json::Error),
// ... domain-specific variants ...
}
This lets ? propagate from any of these external errors automatically. Adding a rule:
10. For library functions that interact with external crates (sqlx,
reqwest, etc.), define From<TheirError> for our error enum via
#[from] on the relevant variant. This enables ? without explicit
conversion.
After this, Cursor adds the right #[from] variants when generating new error types.
Worth the investment
Writing the error handling rules takes maybe 30 minutes. The benefit is:
- Consistent error handling across the codebase
- Cursor’s suggestions match what you want on first attempt
- New engineers (human or AI) follow the patterns automatically
- Code review can focus on logic rather than error-handling style
For Rust projects of any meaningful size, this is one of the higher-leverage rule sets to write.
Adapting to your project
The specific rules above are one workable pattern. Your project may have different conventions:
- Some projects use only
anyhow, even in libraries (acceptable for binary-only crates) - Some projects use only
thiserror, even in main.rs (more verbose but more typed) - Some projects use
eyreinstead ofanyhow(drop-in replacement with different defaults)
Whatever your project’s choice, document it explicitly. The point isn’t which crate; it’s consistency.
A test of the rules
The way I verify the rules are working: ask Cursor to generate a new function with error-handling needs, in a fresh chat. If the output matches my conventions on first attempt, the rules are working. If it doesn’t, the rules need refinement.
Iteration on the rules over the first few weeks of a project produces a stable foundation. After that, the rules stop changing and the consistency benefits compound.