| name | rust-common-pitfalls |
| description | Common Rust development pitfalls: frequent compiler errors, struct constructor patterns, test organization, and coverage enforcement for reliable codebases. |
| metadata | {"author":"mte90","version":"1.0.0","tags":["rust","compiler-errors","testing","patterns","best-practices"]} |
Rust Common Development Pitfalls
Comprehensive guide for avoiding and fixing the most frequent issues encountered when developing in Rust.
When to Use
- Resolving compiler errors in Rust projects
- Designing struct constructors and builders
- Organizing tests in Rust crates
- Setting up code coverage gates
- Debugging common runtime issues
How It Works
This skill addresses the four most common pain points identified in Rust development:
- Frequent compiler errors — Quick reference for error codes and solutions
- Struct constructor patterns — Builder, factory, and newtype patterns
- Test organization — Module placement, naming, and integration tests
- Coverage enforcement — CI integration and threshold configuration
Part 1: Common Compiler Errors Quick Reference
E0433: Cannot find type in scope
Cause: Missing import or typo in type name.
Solution:
use chrono::NaiveDate;
struct User { name: String }
E0597: Value does not live long enough
Cause: Lifetime mismatch between borrowed value and its container.
Solution:
fn get_str() -> &str {
let s = String::from("temp");
&s
}
fn get_str() -> String {
String::from("temp")
}
fn get_str() -> &'static str {
"temp"
}
E0308: Mismatched types
Cause: Type inference failure or expected vs actual type mismatch.
Solution:
fn add(a: i32, b: i32) -> i32 { a + b }
let result = add("1", "2");
let result = add("1".parse::<i32>().unwrap(), "2".parse().unwrap());
let a: i32 = "1".parse().unwrap();
let b: i32 = "2".parse().unwrap();
E0596: Cannot borrow as mutable because it is also borrowed as immutable
Cause: Simultaneous mutable and immutable borrows.
Solution:
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
}
v.push(4);
E0277: Trait not satisfied
Cause: Type doesn't implement required trait.
Solution:
fn print<T>(val: T) {
println!("{}", val);
}
fn print<T: std::fmt::Display>(val: T) {
println!("{}", val);
}
fn print(val: &impl std::fmt::Display) {
println!("{}", val);
}
E0282: Cannot infer type
Cause: Compiler cannot determine type from context.
Solution:
let v = vec![1, 2, 3].iter().map(|x| x * 2).collect();
let v: Vec<i32> = vec![1, 2, 3].iter().map(|x| x * 2).collect();
use std::collections::HashMap;
let m: HashMap<_, _> = vec![(1, "a"), (2, "b")].into_iter().collect();
Part 2: Struct Constructor Patterns
Pattern 1: Simple Constructor with Validation
pub struct User {
name: String,
email: String,
age: u8,
}
impl User {
pub fn new(name: impl Into<String>, email: impl Into<String>, age: u8) -> Result<Self, UserError> {
let name = name.into();
let email = email.into();
if name.trim().is_empty() {
return Err(UserError::EmptyName);
}
if !email.contains('@') {
return Err(UserError::InvalidEmail(email));
}
if age > 150 {
return Err(UserError::InvalidAge(age));
}
Ok(Self { name, email, age })
}
}
#[derive(Debug)]
pub enum UserError {
EmptyName,
InvalidEmail(String),
InvalidAge(u8),
}
Pattern 2: Builder Pattern with Validation
pub struct UserBuilder {
name: Option<String>,
email: Option<String>,
age: Option<u8>,
}
impl UserBuilder {
pub fn new() -> Self {
Self {
name: None,
email: None,
age: None,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
pub fn age(mut self, age: u8) -> Self {
self.age = Some(age);
self
}
pub fn build(self) -> Result<User, UserError> {
let name = self.name.ok_or(UserError::MissingField("name"))?;
let email = self.email.ok_or(UserError::MissingField("email"))?;
let age = self.age.unwrap_or(0);
User::new(name, email, age)
}
}
impl Default for UserBuilder {
fn default() -> Self {
Self::new()
}
}
let user = UserBuilder::new()
.name("Alice")
.email("alice@example.com")
.age(30)
.build()
.expect("valid input");
Pattern 3: Factory Pattern for Multiple Variants
pub struct VulnerabilityFinding {
id: String,
severity: Severity,
message: String,
location: Location,
}
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl VulnerabilityFinding {
pub fn sql_injection(location: Location, query: &str) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
severity: Severity::High,
message: format!("Potential SQL injection in: {}", query),
location,
}
}
pub fn hardcoded_credential(location: Location, credential_type: &str) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
severity: Severity::Critical,
message: format!("Hardcoded {} detected", credential_type),
location,
}
}
}
Pattern 4: Newtype for Type Safety
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct UserId(pub u64);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct OrderId(pub u64);
impl UserId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
impl OrderId {
pub fn new(id: u64) -> Self {
Self(id)
}
}
fn get_user_orders(user_id: UserId, order_id: OrderId) -> Result<Order, ()> {
todo!()
}
let user_id = UserId::new(42);
let order_id = OrderId::new(1);
get_user_orders(user_id, order_id).ok();
Part 3: Test Organization
Module Structure
my_crate/
├── src/
│ └── lib.rs
├── tests/
│ ├── integration_test.rs # One file = one test binary
│ └── common/
│ └── mod.rs # Shared test utilities
└── src/
└── some_module.rs # Inline tests below
Inline Tests in Source
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_positive() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
#[test]
fn test_add_returns_error_when_overflow() {
let result = add(i32::MAX, 1);
assert!(result.is_negative());
}
}
Integration Tests
use my_crate::{add, User, UserBuilder};
#[test]
fn test_full_user_flow() {
let user = UserBuilder::new()
.name("Test")
.email("test@example.com")
.age(25)
.build()
.unwrap();
assert_eq!(user.name(), "Test");
}
#[test]
fn test_invalid_email_rejected() {
let result = UserBuilder::new()
.name("Test")
.email("invalid-email")
.build();
assert!(result.is_err());
}
Test Modules Inside impl Blocks (Advanced)
⚠️ Rare pattern - use only when necessary:
pub struct Config {
value: i32,
}
impl Config {
pub fn new(value: i32) -> Self {
Self { value }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_creates_config() {
let cfg = Config::new(42);
assert_eq!(cfg.value, 42);
}
}
}
Test Naming Conventions
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_new_rejects_empty_email() {
assert!(User::new("name", "").is_err());
}
#[test]
fn test_builder_provides_defaults_for_optional_fields() {
let user = UserBuilder::new()
.name("Test")
.email("test@example.com")
.build()
.unwrap();
assert_eq!(user.age(), 0);
}
#[test]
fn test_vulnerability_sql_injection_severity_is_high() {
let finding = VulnerabilityFinding::sql_injection(
Location::new("test.rs", 1),
"SELECT * FROM users"
);
assert!(matches!(finding.severity(), Severity::High));
}
}
Part 4: Code Coverage Enforcement
Cargo Configuration
[profile.release]
lto = true
opt-level = 3
[profile.dev]
debug = true
CI Integration with cargo-llvm-cov
name: Coverage
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage
run: cargo llvm-cov --workspace --lcov --output-path lcov.info
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
files: lcov.info
fail_ci_if_error: true
threshold: 80%
Coverage with Failure Threshold
cargo llvm-cov --fail-under-lines 80
cargo llvm-cov --fail-under-lines 80 \
--fail-under-functions 70 \
--fail-under-regions 60
Excluding Code from Coverage
#[cfg(test)]
mod generated_tests {
include!("generated.rs");
}
#[cfg(target_os = "linux")]
fn linux_only_function() { }
#[cfg(not(target_os = "linux"))]
fn linux_only_function() {
unreachable!("Linux only");
}
Coverage Reports
cargo llvm-cov --html
cargo llvm-cov
cargo llvm-cov --json --output-path coverage.json
Part 5: Common Runtime Issues Prevention
Thread Safety with Send + Sync
use std::sync::{Arc, Mutex};
struct AppState {
counter: Mutex<i32>,
}
#[derive(Clone)]
struct CloneableState {
data: Arc<Mutex<Vec<String>>>,
}
fn process_in_background<T: Send + 'static>(data: T) {
std::thread::spawn(move || {
});
}
Avoiding Deadlocks
use std::sync::{Mutex, MutexGuard};
fn good_example(m1: &Mutex<i32>, m2: &Mutex<String>) {
let _g1 = m1.lock().unwrap();
let _g2 = m2.lock().unwrap();
}
Async Best Practices
use tokio::time::{sleep, Duration};
async fn fetch_with_timeout() -> Result<String, reqwest::Error> {
Ok(
tokio::time::timeout(
Duration::from_secs(5),
reqwest::get("https://example.com")
)
.await??
.text()
.await?
)
}
async fn bad_example() {
std::thread::sleep(Duration::from_secs(1));
sleep(Duration::from_secs(1)).await;
}
Part 6: Module Splitting Strategies
When to Split a Module
Signs a module has outgrown its single file:
- >300 lines — readability degrades, navigation becomes painful
- Multiple responsibilities — scanner logic mixed with staging, error handling, and output formatting
- Frequent merge conflicts — multiple developers editing the same large file
- Hard to test — too many internal dependencies to isolate units
The mod.rs vs mod/ Directory Pattern
Before (single large file):
pub fn scan_phase(phase: ScanPhase) -> Result<Vec<Finding>, ScanError> { ... }
pub fn stage_results(findings: &[Finding]) -> StagedResults { ... }
pub fn run_semgrep(path: &Path) -> Result<SemgrepOutput, SemgrepError> { ... }
After (split into submodules):
pub mod phases;
pub mod staging;
pub mod semgrep;
pub use phases::scan_phase;
pub use staging::stage_results;
pub use semgrep::run_semgrep;
pub use phases::ScanPhase;
pub use staging::StagedResults;
use crate::scanner::{ScanPhase, Finding, ScanError};
pub fn scan_phase(phase: ScanPhase) -> Result<Vec<Finding>, ScanError> {
}
Visibility Strategy
| Visibility | Use Case |
|---|
pub | True public API — stable across versions |
pub(crate) | Internal cross-module access within the crate |
(no pub) | Private to the module — implementation detail |
pub mod phases;
mod staging_internal;
pub use phases::scan_phase;
pub(crate) fn internal_helper() -> Result<(), ScanError> {
}
use crate::scanner::internal_helper;
pub fn scan_phase(phase: ScanPhase) -> Result<Vec<Finding>, ScanError> {
internal_helper()?;
}
Preserving API Compatibility During Refactor
When splitting a module, maintain the public facade:
use scanner::scan_phase;
use scanner::ScanPhase;
use scanner::scan_phase;
use scanner::ScanPhase;
Key steps:
- Create
scanner/mod.rs with pub mod declarations
- Move functions/types to appropriate submodules
- Add
pub use re-exports in mod.rs for all public symbols
- Run tests — integration tests should pass without modification
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|
| Forgetting to re-export types in public signatures | Callers get "type not found" errors | Add pub use submodule::TypeName in mod.rs |
Using mod instead of pub mod | Submodule not accessible outside parent | Change to pub mod if submodule is part of public API |
| Circular dependencies between submodules | Compilation error | Restructure — extract shared code to a separate module |
Forgetting pub on items in submodules | Items not visible even with pub use | Ensure items are pub in their defining module |
| Integration test imports break | Test fails to compile | Verify mod.rs re-exports match the old single-file API |
Integration Test Imports
After splitting, integration tests continue to work if mod.rs re-exports correctly:
use my_crate::scanner::{scan_phase, ScanPhase};
#[test]
fn test_scan_phase() {
let results = scan_phase(ScanPhase::Semgrep).unwrap();
assert!(!results.is_empty());
}
Part 7: Error Design with thiserror
When to Use thiserror vs anyhow
| Tool | Best For | Example |
|---|
thiserror | Library errors, enum-based errors, public APIs | ScanError::FileNotFound, ConfigError::InvalidPath |
anyhow | Application-level error aggregation, CLI tools | Result<T, anyhow::Error> in main() |
baco uses thiserror 9:1 over anyhow (225 vs 26 mentions) — this is the standard pattern for libraries.
Designing Error Enums
use thiserror::Error;
use std::path::PathBuf;
#[derive(Debug, Error)]
pub enum ScanError {
#[error("file not found: {path}")]
FileNotFound { path: PathBuf },
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error("scan failed: {source}")]
ScanFailed {
#[from]
source: std::io::Error,
},
#[error("semgrep error: {0}")]
Semgrep(#[from] SemgrepError),
#[error("phase {phase} timed out after {duration}s")]
Timeout { phase: String, duration: u64 },
}
The #[from] Attribute
Automatically implements From<E> for your error type, enabling the ? operator:
#[derive(Debug, Error)]
pub enum ScanError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
fn read_file(path: &Path) -> Result<String, ScanError> {
let content = std::fs::read_to_string(path)?;
Ok(content)
}
fn read_file(path: &Path) -> Result<String, ScanError> {
let content = std::fs::read_to_string(path)
.map_err(ScanError::Io)?;
Ok(content)
}
Error Context Chaining
Wrap errors at each layer with structured context:
fn load_config(path: &Path) -> Result<Config, ScanError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ScanError::FileNotFound { path: path.to_path_buf() })?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| ScanError::InvalidConfig(format!("JSON parse: {}", e)))?;
Ok(config)
}
Unifying Errors Across Modules
When multiple modules produce different error types, create a top-level enum:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ScanError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("semgrep error: {0}")]
Semgrep(#[from] semgrep::SemgrepError),
#[error("config error: {0}")]
Config(#[from] config::ConfigError),
#[error("scan failed: {message}")]
ScanFailed { message: String },
}
#[derive(Debug, Error)]
pub enum SemgrepError {
#[error("semgrep not found")]
NotInstalled,
#[error("semgrep exited with code {code}")]
ExitCode { code: i32 },
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("missing field: {field}")]
MissingField { field: String },
}
Common Pitfalls
| Pitfall | Consequence | Fix |
|---|
Forgetting #[from] and writing manual From impls | Boilerplate, error-prone | Use #[from] for automatic conversion |
Using String error messages instead of structured variants | Lost context, hard to match on | Use enum variants with typed fields |
Not deriving Debug on the error enum | Compilation error (required by Error trait) | Add #[derive(Debug, Error)] |
| Flat enum with 20+ variants | Hard to navigate, unclear domain boundaries | Nest by domain — create submodule error types |
Mixing thiserror and anyhow in the same module | Confusing error handling strategy | Pick one per module — thiserror for libraries, anyhow for app entry points |
Example: Complete Error Design
use thiserror::Error;
use std::path::PathBuf;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("config file not found: {0}")]
NotFound(PathBuf),
#[error("invalid config: {0}")]
ParseError(String),
}
#[derive(Debug, Error)]
pub enum ScanError {
#[error("config error: {0}")]
Config(#[from] ConfigError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("no findings")]
NoFindings,
}
fn run_scan(config_path: &Path) -> Result<(), ScanError> {
let config = load_config(config_path)?;
let results = scan(&config)?;
if results.is_empty() {
return Err(ScanError::NoFindings);
}
Ok(())
}
Part 8: Panic Elimination Strategies
Why Panics in Hot Paths Are Dangerous
- Crash on unexpected input — no graceful degradation
- No recovery — process dies, user loses work
- Hard to test — panic paths often untested
- Poor UX — users see raw error messages or silent crashes
.unwrap() → ? Migration
fn load_config() -> Config {
let content = std::fs::read_to_string("config.json").unwrap();
serde_json::from_str(&content).unwrap()
}
fn load_config() -> Result<Config, ScanError> {
let content = std::fs::read_to_string("config.json")?
.map_err(|e| ScanError::FileNotFound { path: "config.json".into() })?;
serde_json::from_str(&content)
.map_err(|e| ScanError::ConfigParse(e.to_string()))?
}
.expect() with Context
.expect() is acceptable only for true invariants that are impossible to violate:
fn spawn_worker() -> JoinHandle<()> {
let handle = std::thread::current()
.expect("must have current thread to spawn worker");
}
fn load_data(path: &Path) -> Data {
let file = File::open(path).expect("file should exist");
}
fn load_data(path: &Path) -> Result<Data, ScanError> {
let file = File::open(path)?;
}
Result Propagation Pattern
Make functions return Result<T, E> instead of panicking:
fn parse_config(content: &str) -> Config {
let json: Value = serde_json::from_str(content).unwrap();
Config {
name: json["name"].as_str().unwrap().to_string(),
port: json["port"].as_u16().unwrap(),
}
}
fn parse_config(content: &str) -> Result<Config, ConfigError> {
let json: Value = serde_json::from_str(content)
.map_err(|e| ConfigError::ParseError(e.to_string()))?;
let name = json["name"]
.as_str()
.ok_or(ConfigError::MissingField("name".into()))?
.to_string();
let port = json["port"]
.as_u16()
.ok_or(ConfigError::MissingField("port".into()))?;
Ok(Config { name, port })
}
Common Panic Sources and Fixes
| Source | Panic Risk | Fix |
|---|
Array indexing arr[i] | Out of bounds | arr.get(i).ok_or(Error)? or bounds check first |
unwrap() on Option | None value | ok_or(error)? or `ok_or_else( |
unwrap() on Result | Error variant | ? with error mapping via map_err() |
| Integer division | Divide by zero | checked_div() / saturating_div() |
Vec::remove out of bounds | Index >= len | Bounds check: if i < vec.len() { vec.remove(i) } |
unwrap() on parse() | Invalid format | parse().map_err(...)? |
expect() on fallible I/O | File not found, permissions | Return Result instead |
Audit Techniques
Find production .unwrap() calls:
grep -rn '\.unwrap()' src/ | grep -v test
grep -rn '\.expect(' src/ | grep -v "test\|cfg(test)"
cargo udeps
CI Gate: Deny unwrap() in Non-Test Code
Add to clippy.toml:
allow-unwrap-in-tests = true
[lints.clippy]
panic_in_result_fn = "deny"
unwrap_used = "deny"
Run in CI:
cargo clippy -- -D clippy::unwrap_used -D clippy::panic_in_result_fn
When Panics ARE Acceptable
| Scenario | Example | Rationale |
|---|
| True invariants | debug_assert!, unreachable!() after exhaustive match | Logic guarantees impossibility |
| Test setup | #[test] fn foo() { setup().unwrap(); } | Test failure is expected on bad setup |
unreachable!() | match value { A => ..., B => ..., _ => unreachable!() } | Exhaustive match proves impossibility |
unimplemented!() | Stub for future work during development | Explicit marker, not production code |
todo!() | Placeholder during implementation | Development-only, should be removed |
match status {
Status::Active => process_active(),
Status::Inactive => process_inactive(),
_ => unreachable!("All status variants handled"),
}
#[test]
fn test_scan() {
let config = load_test_config().unwrap();
let results = scan(&config).unwrap();
assert!(!results.is_empty());
}
Quick Reference Card
| Issue | Error Code | Quick Fix |
|---|
| Type not found | E0433 | Add import, check spelling |
| Lifetime mismatch | E0597 | Return owned value or 'static |
| Type mismatch | E0308 | Add type annotation or convert |
| Borrow conflict | E0596 | Separate borrow scopes |
| Trait not satisfied | E0277 | Add trait bound |
| Cannot infer type | E0282 | Add type annotation |
Essential Commands
cargo check
cargo build --all-targets
cargo clippy -- -D warnings
cargo fmt
cargo test
cargo llvm-cov --html
cargo audit
Anti-Patterns to Avoid
let value = map.get("key").unwrap();
let value = map.get("key")
.ok_or_else(|| Error::KeyNotFound)?;
fn process(data: &Vec<u8>) -> usize {
let cloned = data.clone();
cloned.len()
}
fn process(data: &[u8]) -> usize {
data.len()
}
fn greet(name: String) { ... }
fn greet(name: &str) { ... }
let _ = validate(input);
let _ = validate(input).expect("validation should pass");
Best Practices
- Read compiler errors fully before acting: Rust's error messages are verbose for a reason—read the entire diagnostic including suggestions.
- Use
cargo check in a tight loop: Faster than full builds; run after every small change to catch errors early.
- Structure tests in
tests/ for integration and #[cfg(test)] mod tests for unit: Keep unit tests close to code; use tests/ for cross-module integration tests.
- Enforce coverage with
cargo tarpaulin or cargo-llvm-cov: Set CI gates (e.g., 80% line coverage) to prevent regression.
- Prefer
Result<T, E> over Option<T> when errors carry meaning: Use Option for absence, Result for recoverable failures with context.
References
Summary
Remember:
- Read compiler errors literally — Rust's compiler is helpful
- Use builders for complex construction with validation
- Keep tests close to code they test (inline) or in
tests/
- Enforce coverage in CI — 80% is a good starting target
- Never use
unwrap() in production code — always handle errors explicitly
- Derive
Clone, Debug, Eq, PartialEq when possible — let the compiler do work