CLI Application Error Handling

use std::process;
use thiserror::Error;
use clap::Parser;

#[derive(Error, Debug)]
enum CliError {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("Configuration error: {0}")]
    Config(String),
    
    #[error("Processing error: {0}")]
    Processing(String),
}

#[derive(Parser)]
struct Args {
    #[arg(short, long)]
    config: String,
    
    #[arg(short, long)]
    verbose: bool,
}

fn run(args: Args) -> Result<(), CliError> {
    // Load configuration
    let config = load_config(&args.config)
        .map_err(|e| CliError::Config(format!("Failed to load config: {}", e)))?;
    
    // Process data
    process_data(&config)
        .map_err(|e| CliError::Processing(format!("Failed to process data: {}", e)))?;
    
    Ok(())
}

fn main() {
    let args = Args::parse();
    
    if let Err(e) = run(args) {
        eprintln!("Error: {}", e);
        
        // Print detailed error information in verbose mode
        if args.verbose {
            if let Some(source) = e.source() {
                eprintln!("Caused by: {}", source);
            }
        }
        
        process::exit(1);
    }
}

Best Practices for Error Handling

Based on experience from real-world Rust projects, here are some error handling best practices:

1. Design Error Types for Your Domain

use thiserror::Error;

// Domain-specific error types
#[derive(Error, Debug)]
enum DatabaseError {
    #[error("Connection error: {0}")]
    Connection(String),
    
    #[error("Query error: {0}")]
    Query(String),
    
    #[error("Transaction error: {0}")]
    Transaction(String),
}

#[derive(Error, Debug)]
enum AuthError {
    #[error("Invalid credentials")]
    InvalidCredentials,
    
    #[error("Token expired")]
    TokenExpired,
    
    #[error("Insufficient permissions")]
    InsufficientPermissions,
}

#[derive(Error, Debug)]
enum AppError {
    #[error("Database error: {0}")]
    Database(#[from] DatabaseError),
    
    #[error("Authentication error: {0}")]
    Auth(#[from] AuthError),
    
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

2. Add Context to Errors

use anyhow::{Context, Result};

fn process_file(path: &str) -> Result<Data> {
    // Add context to each operation
    let file = std::fs::File::open(path)
        .with_context(|| format!("Failed to open file: {}", path))?;
    
    let reader = std::io::BufReader::new(file);
    
    let data: Data = serde_json::from_reader(reader)
        .with_context(|| format!("Failed to parse JSON from file: {}", path))?;
    
    // Validate the data
    if data.items.is_empty() {
        anyhow::bail!("File contains no items: {}", path);
    }
    
    Ok(data)
}

3. Separate Internal and External Errors

use thiserror::Error;

// Internal errors (for logging and debugging)
#[derive(Error, Debug)]
enum InternalError {
    #[error("Database error: {0}")]
    Database(String),
    
    #[error("Cache error: {0}")]
    Cache(String),
    
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

// External errors (for user-facing messages)
#[derive(Error, Debug)]
enum ApiError {
    #[error("Resource not found")]
    NotFound,
    
    #[error("Invalid request: {0}")]
    BadRequest(String),
    
    #[error("Unauthorized")]
    Unauthorized,
    
    #[error("Internal server error")]
    Internal(#[from] InternalError),
}

4. Use Error Enums for Pattern Matching

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("Not found: {0}")]
    NotFound(String),
    
    #[error("Permission denied: {0}")]
    PermissionDenied(String),
    
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}

fn handle_error(error: AppError) {
    match error {
        AppError::NotFound(item) => {
            println!("Could not find: {}", item);
        }
        AppError::PermissionDenied(operation) => {
            println!("You don't have permission to: {}", operation);
        }
        AppError::Io(e) => {
            println!("I/O error: {}", e);
            log::error!("I/O error details: {:?}", e);
        }
    }
}

Conclusion

Error handling in Rust goes far beyond the basic Result and Option types. By leveraging custom error types, error context, and domain-specific error handling patterns, you can create robust applications that gracefully handle failures and provide clear feedback to users. The patterns and practices discussed in this guide can help you design effective error handling strategies for your Rust applications, making them more maintainable, user-friendly, and resilient.

The key takeaways from this exploration of Rust error handling patterns are:

  1. Custom error types provide organization and flexibility for complex applications
  2. Error context helps with debugging and user feedback
  3. Error propagation can be simplified with the ? operator and conversion traits
  4. Error reporting should be tailored to the audience (users vs. developers)
  5. Domain-specific error handling addresses the unique needs of different application types

Remember that good error handling is not just about catching and reporting errors, but also about designing your code to be resilient and providing clear paths to recovery when things go wrong. By implementing these advanced error handling patterns in your Rust code, you can build applications that not only work correctly when everything goes as planned but also degrade gracefully when they encounter unexpected situations.