SuperAPI AI Directory

rust-common-pitfalls

Coding & development

View repository

Updated 10-15 20:13

About this skill

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:

  1. Frequent compiler errors — Quick reference for error codes and solutions
  2. Struct constructor patterns — Builder, factory, and newtype patterns
  3. Test organization — Module placement, naming, and integration tests
  4. 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:

// Wrong: use chrono::NaiveDate;
use chrono::NaiveDate;  // Add import or check Cargo.toml

// Check for typos in type names
struct User { name: String }  // typo in "name" vs "named"

E0597: Value does not live long enough

Cause: Lifetime mismatch between borrowed value and its container.

Solution:

// Problem: returning reference to temporary
fn get_str() -> &str {
    let s = String::from("temp");
    &s  // ERROR: s dropped before reference returned
}

// Fix: Return owned value or use static lifetime
fn get_str() -> String {
    String::from("temp")  // Ownership moves
}

// Or with static lifetime for constants
fn get_str() -> &'static str {
    "temp"  // Static lifetime
}

E0308: Mismatched types

Cause: Type inference failure or expected vs actual type mismatch.

Real-World Examples from baco (ses_1015b0539ffe):

  • String vs &String drift in security_agent_verification.rs
  • u32 vs usize mismatch in loop counters and array indexing

Solution:

// Problem: Expected i32, got &str
fn add(a: i32, b: i32) -> i32 { a + b }
let result = add("1", "2");  // ERROR

// Fix: Convert string to number
let result = add("1".parse::<i32>().unwrap(), "2".parse().unwrap());

// Or use type annotation
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:

// Problem
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);  // ERROR: cannot mutate while borrowed

// Fix: Separate borrow scopes
let mut v = vec![1, 2, 3];
{
    let first = &v[0];
    println!("{}", first);
}  // borrow ends
v.push(4);  // now works

E0277: Trait not satisfied

Cause: Type doesn't implement required trait.

Solution:

// Problem: T doesn't implement Display
fn print<T>(val: T) {
    println!("{}", val);  // ERROR
}

// Fix: Add trait bound
fn print<T: std::fmt::Display>(val: T) {
    println!("{}", val);
}

// Or use generic formatting
fn print(val: &impl std::fmt::Display) {
    println!("{}", val);
}

E0282: Cannot infer type

Cause: Compiler cannot determine type from context.

Solution:

// Problem: Cannot infer type of iterator
let v = vec![1, 2, 3].iter().map(|x| x * 2).collect();  // ERROR

// Fix: Add type annotation
let v: Vec<i32> = vec![1, 2, 3].iter().map(|x| x * 2).collect();

// Or collect into specific type
use std::collections::HashMap;
let m: HashMap<_, _> = vec![(1, "a"), (2, "b")].into_iter().collect();

E0382: Use of moved value

Cause: Value moved into another owner, then used again.

Solution:

// Problem: String moved to vec, then used
let s = String::from("hello");
let v = vec![s];  // s moved
println!("{}", s);  // ERROR: use after move

// Fix: Clone or borrow
let s = String::from("hello");
let v = vec![s.clone()];  // Clone ownership
println!("{}", s);  // OK

// Or borrow if clone is expensive
let s = String::from("hello");
let v = vec![&s];  // Borrow
println!("{}", s);  // OK

E0502: Cannot borrow as immutable because it is also borrowed as mutable

Cause: Simultaneous mutable and immutable borrows violate Rust's borrowing rules.

Solution:

// Problem: mutable borrow while immutable borrow active
let mut v = vec![1, 2, 3];
let first = &v[0];  // immutable borrow
v.push(4);  // ERROR: mutable borrow while immutable active
println!("{}", first);

// Fix: Limit immutable borrow scope
let mut v = vec![1, 2, 3];
let first = v[0];  // Copy (i32 implements Copy)
v.push(4);
println!("{}", first);

// Or separate scopes for references
let mut v = vec![1, 2, 3];
{
    let first = &v[0];  // immutable borrow
    println!("{}", first);
}  // borrow ends
v.push(4);  // now OK

E0507: Cannot move out of borrowed content

Cause: Trying to move a value out of a reference or container.

Solution:

// Problem: Moving out of Option in reference
struct Container { data: Option<String> }
fn extract(c: &Container) -> String {
    c.data.take().unwrap()  // ERROR: cannot move out of &Option
}

// Fix: Use take() to replace with None
fn extract(c: &mut Container) -> String {
    c.data.take().unwrap()  // OK: takes ownership, leaves None
}

// Or clone if mutation not possible
fn extract(c: &Container) -> String {
    c.data.clone().unwrap()  // OK: clones the String
}

E0716: Temporary value dropped while borrowed

Cause: Reference to temporary that gets dropped at end of statement.

Solution:

// Problem: Temporary string literal dropped
fn get_line() -> &str {
    String::from("hello").as_str()  // ERROR: temp dropped
}

// Fix: Return owned String
fn get_line() -> String {
    String::from("hello")
}

// Or use static lifetime for constants
fn get_line() -> &'static str {
    "hello"  // Static string literal
}

---

## Part 2: Struct Constructor Patterns

### Pattern 1: Simple Constructor with Validation

```rust
pub struct User {
    name: String,
    email: String,
    age: u8,
}

impl User {
    /// Creates a new user with validation.
    /// Returns Err if validation fails.
    pub fn new(name: impl Into<String>, email: impl Into<String>, age: u8) -> Result<Self, UserError> {
        let name = name.into();
        let email = email.into();

        // Validate
        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
    }

    /// Builds the User, performing validation.
    /// # Errors
    /// Returns UserError if required fields are missing or invalid.
    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);  // default

        User::new(name, email, age)
    }
}

impl Default for UserBuilder {
    fn default() -> Self {
        Self::new()
    }
}

// Usage
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,
    // ... many more fields
}

pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl VulnerabilityFinding {
    /// Factory for SQL injection findings
    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,
            // ... set other fields appropriately
        }
    }

    /// Factory for hardcoded credentials
    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

/// Newtype wrapper to prevent mixing up UserId and OrderId
#[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)
    }
}

// This prevents accidental argument swapping
fn get_user_orders(user_id: UserId, order_id: OrderId) -> Result<Order, ()> {
    // Cannot accidentally swap - type system catches it
    todo!()
}

// Usage
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

// src/some_module.rs

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    // Unit tests for this module
    #[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() {
        // Test error conditions
        let result = add(i32::MAX, 1);
        assert!(result.is_negative());  // Wraps to negative
    }
}

Integration Tests

// tests/integration_test.rs
use my_crate::{add, User, UserBuilder};

#[test]
fn test_full_user_flow() {
    // Integration test - tests components working together
    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::*;

    // DESCRIPTIVE: test_function_scenario_expected_behavior
    #[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);  // default
    }

    // Group related tests with prefix
    #[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

# .cargo/config.toml
[profile.release]
lto = true
opt-level = 3

[profile.dev]
debug = true

CI Integration with cargo-llvm-cov

# .github/workflows/coverage.yml
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

# Run with minimum coverage requirement
cargo llvm-cov --fail-under-lines 80

# Or in CI with specific targets
cargo llvm-cov --fail-under-lines 80 \
  --fail-under-functions 70 \
  --fail-under-regions 60

Excluding Code from Coverage

// Exclude generated code
#[cfg(test)]
mod generated_tests {
    // Tests for generated code - exclude from coverage
    include!("generated.rs");
}

// Exclude platform-specific code
#[cfg(target_os = "linux")]
fn linux_only_function() { /* ... */ }

#[cfg(not(target_os = "linux"))]
fn linux_only_function() {
    unreachable!("Linux only");
}

Coverage Reports

# HTML report
cargo llvm-cov --html

# Terminal summary
cargo llvm-cov

# JSON for CI tools
cargo llvm-cov --json --output-path coverage.json

Part 5: Common Runtime Issues Prevention

Thread Safety with Send + Sync

Thin Send/Sync for async: Most async types are !Send by default. Use Arc<T> for shared state across tasks.

use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};

// Rc/RefCell are !Send and !Sync — use only in single-threaded contexts
// BAD for async: Rc<String> cannot cross task boundaries

// GOOD: Arc for shared ownership across tasks
#[derive(Clone)]
struct SharedState {
    data: Arc<RwLock<Vec<String>>>,  // RwLock better for read-heavy workloads
}

// Arc<Mutex<T>> vs Arc<RwLock<T>>:
// - Mutex: single writer, any reader (but only one holder total)
// - RwLock: multiple readers OR single writer (better for read-heavy)
// In async: prefer tokio::sync::{Mutex, RwLock}, not std::sync::*

Async-specific Send/Sync issues:

use tokio::task::JoinSet;

// !Send futures: Cannot be sent to another thread
async fn non_send_task() {
    let data = std::rc::Rc::new(42);  // Rc is !Send
    // This future cannot be .spawn()ed on multi-threaded runtime
}

// JoinSet requires Send + 'static
let mut set = JoinSet::new();
set.spawn(async {
    // Must be Send + 'static
});

// select! cancellation: Ensure futures are cancel-safe
// Avoid holding locks across .await points when possible
select! {
    result = async_task => {
        // Handle result
    },
    _ = cancellation_token.cancelled() => {
        // Cleanup: ensure locks are released
    }
}
use std::sync::{Arc, Mutex};

// Shared state must be Send + Sync to cross thread boundaries
struct AppState {
    counter: Mutex<i32>,
}

// Derive automatically when possible
#[derive(Clone)]
struct CloneableState {
    data: Arc<Mutex<Vec<String>>>,
}

// Explicit bounds for generics
fn process_in_background<T: Send + 'static>(data: T) {
    std::thread::spawn(move || {
        // Process data
    });
}

Avoiding Deadlocks

use std::sync::{Mutex, MutexGuard};

// Always acquire locks in consistent order
// BAD: Potential deadlock
// fn bad_example(m1: &Mutex<T>, m2: &Mutex<U>) { ... }

// GOOD: Always acquire in same order, use scoping
fn good_example(m1: &Mutex<i32>, m2: &Mutex<String>) {
    let _g1 = m1.lock().unwrap();
    let _g2 = m2.lock().unwrap();  // Always second
    
    // Work here
}  // Locks released in reverse order

Testing: #[tokio::test] and rstest

// Basic async test
#[tokio::test]
async fn test_async_function() {
    let result = async_fn().await;
    assert_eq!(result, expected);
}

// Parametrized tests with rstest
use rstest::rstest;

#[rstest]
#[case(1, 2)]
#[case(5, 10)]
async fn test_with_cases(#[case] input: i32, #[case] expected: i32) {
    assert_eq!(process(input).await, expected);
}

Async Best Practices

use tokio::time::{sleep, Duration};

// Use async-specific utilities
async fn fetch_with_timeout() -> Result<String, reqwest::Error> {
    Ok(
        tokio::time::timeout(
            Duration::from_secs(5),
            reqwest::get("https://example.com")
        )
        .await??  // ? for timeout error, ? for request error
        .text()
        .await?
    )
}

// NEVER block the async executor
async fn bad_example() {
    std::thread::sleep(Duration::from_secs(1));  // BAD: blocks executor
    // Use instead:
    sleep(Duration::from_secs(1)).await;  // GOOD: yields to executor
}

Part 6: Module Splitting Strategies and Dead Code Detection

Field Log from baco: Undeclared Modules and #[ignore] Accumulation

Pitfall #1: Module file exists but never declared in lib.rs (ses_125f87e23ffe)

Silent dead code: 9 dead modules, 3274 lines in baco (e.g., attack_scenarios.rs:474, endpoints.rs:248).

Detection:

# Audit mod declarations vs file existence
cargo-modules tree --format json  # Requires cargo-modules crate

# Manual grep: find .rs files without corresponding `mod` in lib.rs
find src -name "*.rs" -exec grep -L "mod {}" lib.rs \;

# Check for orphaned modules
grep -r "^mod " src/ | cut -d: -f2 | sed 's/mod //;s/;//' | sort > /tmp/declared_mods.txt
find src -name "*.rs" | sed 's|.*/||;s/\.rs$//' | sort > /tmp/files.txt
comm -23 /tmp/files.txt /tmp/declared_mods.txt  # Shows undeclared files

Pitfall #8: Disabled #[ignore]/#[cfg] tests as dead weight (ses_125f87e23ffe)

3 tests, 208 lines of dead code in baco from accumulated #[ignore] tests.

Detection:

# Find ignored tests
grep -rn "#\[ignore\]" src/ tests/

# Find cfg-gated dead code
grep -rn "#\[cfg(not(test))\]" src/ | grep -v "// "

# Count disabled tests
cargo test -- --list 2>/dev/null | grep -c "ignored"

Duplicate Code Detection

Pitfall #5-7 from baco (ses_125f87e23ffe, ses_1015b0539ffe):

  • Cross-phase boilerplate duplication: LLM client config x3 → deduplicated to create_llm_client_with_metrics() in src/llm.rs (-36 lines)
  • Intra-file duplicate blocks: conflict_resolver.rs:24-33 == :37-41 (39 tokens)
  • Cross-file duplicates: indexing.rs:46-51 == llm_static.rs:83-88 (44 tokens; 73 groups total)

Detection with aft_inspect:

# Run inspect to find duplicate groups
aft_inspect --sections duplicates

Manual detection:

# Use fdupes or rdfind for exact duplicates
fdupes -r src/

# For semantic duplicates: use cargo-deny or custom diff scripts
# Pattern: extract function, check if same logic appears elsewhere
grep -rn "fn create_llm_client" src/  # Should be 1, not 3

Fix pattern: Extract common logic to shared helper:

// Before: 3 copies of LLM client creation
async fn scan_llm_client() -> LLMClient { ... }
async fn index_llm_client() -> LLMClient { ... }
async fn report_llm_client() -> LLMClient { ... }

// After: Single source of truth
async fn create_llm_client_with_metrics(phase: &str) -> LLMClient {
    // Shared logic with phase-specific metrics
}

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):

// scanner.rs (500 lines)
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> { ... }
// ... 490 more lines

After (split into submodules):

// scanner/mod.rs
pub mod phases;
pub mod staging;
pub mod semgrep;

// Re-export public API so callers see no difference
pub use phases::scan_phase;
pub use staging::stage_results;
pub use semgrep::run_semgrep;

// Also re-export types used in public signatures
pub use phases::ScanPhase;
pub use staging::StagedResults;
// scanner/phases.rs
use crate::scanner::{ScanPhase, Finding, ScanError};

pub fn scan_phase(phase: ScanPhase) -> Result<Vec<Finding>, ScanError> {
    // Implementation
}

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
// scanner/mod.rs
pub mod phases;
mod staging_internal;  // Private helper module

pub use phases::scan_phase;

// Internal function, not exposed
pub(crate) fn internal_helper() -> Result<(), ScanError> {
    // Used by multiple modules but not part of public API
}
// scanner/phases.rs
use crate::scanner::internal_helper;  // Can access pub(crate)

pub fn scan_phase(phase: ScanPhase) -> Result<Vec<Finding>, ScanError> {
    internal_helper()?;  // Cross-module internal access
    // ...
}

Preserving API Compatibility During Refactor

When splitting a module, maintain the public facade:

// Before split — callers import from scanner
use scanner::scan_phase;
use scanner::ScanPhase;

// After split — same imports still work
use scanner::scan_phase;  // Re-exported from scanner/mod.rs
use scanner::ScanPhase;   // Re-exported type

Key steps:

  1. Create scanner/mod.rs with pub mod declarations
  2. Move functions/types to appropriate submodules
  3. Add pub use re-exports in mod.rs for all public symbols
  4. 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:

// tests/integration_test.rs
use my_crate::scanner::{scan_phase, ScanPhase};  // Still works!

#[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:

// With #[from]
#[derive(Debug, Error)]
pub enum ScanError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

// Now this works:
fn read_file(path: &Path) -> Result<String, ScanError> {
    let content = std::fs::read_to_string(path)?;  // Auto-converts io::Error
    Ok(content)
}

// Without #[from], you'd need:
fn read_file(path: &Path) -> Result<String, ScanError> {
    let content = std::fs::read_to_string(path)
        .map_err(ScanError::Io)?;  // Manual conversion
    Ok(content)
}

Error Context Chaining

Wrap errors at each layer with structured context:

fn load_config(path: &Path) -> Result<Config, ScanError> {
    // Layer 1: IO error → FileNotFound
    let content = std::fs::read_to_string(path)
        .map_err(|e| ScanError::FileNotFound { path: path.to_path_buf() })?;
    
    // Layer 2: Parse error → InvalidConfig
    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:

// scanner/error.rs
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 },
}

// semgrep/error.rs
#[derive(Debug, Error)]
pub enum SemgrepError {
    #[error("semgrep not found")]
    NotInstalled,
    #[error("semgrep exited with code {code}")]
    ExitCode { code: i32 },
}

// config/error.rs
#[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;

// Domain-specific error types
#[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,
}

// Usage with context
fn run_scan(config_path: &Path) -> Result<(), ScanError> {
    let config = load_config(config_path)?;  // ConfigError auto-converted
    let results = scan(&config)?;            // Io error auto-converted
    
    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

// Bad: panics on None
fn load_config() -> Config {
    let content = std::fs::read_to_string("config.json").unwrap();
    serde_json::from_str(&content).unwrap()
}

// Good: propagates error
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:

// Acceptable — invariant: process must have current thread
fn spawn_worker() -> JoinHandle<()> {
    let handle = std::thread::current()
        .expect("must have current thread to spawn worker");
    // ...
}

// NOT acceptable — file may legitimately not exist
fn load_data(path: &Path) -> Data {
    let file = File::open(path).expect("file should exist"); // BAD
}

// Good — handle the error
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:

// Bad
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(),
    }
}

// Good
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 with Clippy

Replace raw grep with clippy lints and structured error handling:

# Use clippy lints instead of grep
cargo clippy -- -D clippy::expect_used -D clippy::unwrap_used
// Add #[track_caller] for better panic locations
#[track_caller]
fn panic_with_location(msg: &str) {
    panic!("{}", msg);  // Panic reports caller's location
}

// For FFI boundaries: catch_unwind
use std::panic::{catch_unwind, AssertUnwindSafe};

fn safe_ffi_call(f: impl FnOnce() + Send) -> Result<(), ()> {
    catch_unwind(AssertUnwindSafe(f)).map_err(|_| ())
}
# Find all unwraps except in test code
grep -rn '\.unwrap()' src/ | grep -v test

# Find expect calls with context
grep -rn '\.expect(' src/ | grep -v "test\|cfg(test)"

# Use cargo-udeps to find unused error handling
cargo udeps

CI Gate: Deny unwrap() in Non-Test Code

Add to clippy.toml:

# clippy.toml
allow-unwrap-in-tests = true

# Or in Cargo.toml with rustfmt
[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
// Acceptable — exhaustive match proves unreachable
match status {
    Status::Active => process_active(),
    Status::Inactive => process_inactive(),
    _ => unreachable!("All status variants handled"),
}

// Acceptable — test setup
#[test]
fn test_scan() {
    let config = load_test_config().unwrap();  // Test fails if config invalid
    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

# Check code quickly
cargo check

# Run with all warnings
cargo build --all-targets

# Run clippy
cargo clippy -- -D warnings

# Format code
cargo fmt

# Run tests
cargo test

# Coverage report
cargo llvm-cov --html

# Audit dependencies
cargo audit

Anti-Patterns to Avoid

// BAD: unwrap() in production
let value = map.get("key").unwrap();  // Panics on missing key!

// GOOD: Handle missing case
let value = map.get("key")
    .ok_or_else(|| Error::KeyNotFound)?;

// BAD: Clone to avoid borrow checker
fn process(data: &Vec<u8>) -> usize {
    let cloned = data.clone();  // Wasteful
    cloned.len()
}

// GOOD: Use reference directly
fn process(data: &[u8]) -> usize {
    data.len()
}

// BAD: String when &str suffices
fn greet(name: String) { ... }

// GOOD: Borrow when read-only
fn greet(name: &str) { ... }

// BAD: Ignoring Result
let _ = validate(input);  // Silently ignores error

// GOOD: Handle or expect
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:

  1. Read compiler errors literally — Rust's compiler is helpful
  2. Use builders for complex construction with validation
  3. Keep tests close to code they test (inline) or in tests/
  4. Enforce coverage in CI — 80% is a good starting target
  5. Never use unwrap() in production code — always handle errors explicitly
  6. Derive Clone, Debug, Eq, PartialEq when possible — let the compiler do work