ips/doc/rust_docs/error_handling.md

9 KiB

Error Handling Guidelines for IPS Rust Code

This document outlines best practices for error handling in the IPS Rust codebase. It covers how to use the miette and thiserror crates for robust error handling and reporting, and the tracing crate for configurable debug output.

Core Principles

The core idea is to combine:

  • thiserror for creating custom error types with clear error messages
  • miette for rich, user-friendly error reporting with diagnostic information
  • tracing for structured, configurable debug output

Project Setup

Dependencies

Add the necessary dependencies to your crate's Cargo.toml:

[dependencies]
miette = { version = "7.6.0", features = ["fancy"] }
thiserror = "1.0.50"
tracing = "0.1.37"
tracing-subscriber = "0.3.17"

Note: The "fancy" feature for miette enables colorful, detailed error reports. This feature should only be enabled in the top-level crate of your project (application crates) to avoid unnecessary dependencies in library crates.

For library crates like libips, use:

[dependencies]
miette = "7.6.0"
thiserror = "1.0.50"
tracing = "0.1.37"

Defining Custom Error Types

Define your error types as enums using thiserror. This allows you to create specific error variants for different failure scenarios. Then, use miette's Diagnostic derive macro to add rich diagnostic information to your errors.

Example

use miette::{Diagnostic, NamedSource, SourceSpan};
use thiserror::Error;

#[derive(Error, Debug, Diagnostic)]
#[error("A validation error occurred")]
#[diagnostic(
    code(ips::validation_error),
    help("Please check the input data and try again.")
)]
pub enum ValidationError {
    #[error("Invalid package name: {0}")]
    #[diagnostic(
        code(ips::validation_error::invalid_name),
        help("Package names must follow the IPS naming conventions.")
    )]
    InvalidName(String),

    #[error("Invalid version format")]
    #[diagnostic(
        code(ips::validation_error::invalid_version),
        help("Version must be in the format: major.minor.patch")
    )]
    InvalidVersion {
        #[source_code]
        src: NamedSource,

        #[label("the invalid version")]
        span: SourceSpan,
    },
}

In this example:

  • #[derive(Error, Debug, Diagnostic)] automatically implements the necessary traits from thiserror and miette.
  • #[error("...")] provides the main error message.
  • #[diagnostic(...)] adds diagnostic information like a unique error code and a help message.
  • For more specific errors, like InvalidVersion, you can provide source_code and a label with a SourceSpan to highlight the exact location of the error.

Error Codes

Use a consistent naming scheme for error codes:

  • Top-level errors: crate_name::error_category
  • Specific errors: crate_name::error_category::specific_error

For example:

  • ips::validation_error
  • ips::validation_error::invalid_name

Returning Errors

In Library Code

In library code (like libips), always return your specific error types. This makes your library's API clear and easy to use.

pub fn validate_package(name: &str, version: &str) -> Result<(), ValidationError> {
    if !is_valid_package_name(name) {
        return Err(ValidationError::InvalidName(name.to_string()));
    }

    if !is_valid_version(version) {
        let source = NamedSource::new("input.txt", version.to_string());
        let span = (0, version.len()).into();
        return Err(ValidationError::InvalidVersion { src: source, span });
    }

    Ok(())
}

In Application Code

In your application's main function and other top-level code, you can use miette::Result for convenience.

use miette::{IntoDiagnostic, Result};

fn main() -> Result<()> {
    // Initialize tracing
    setup_tracing();

    // Your application logic here
    let config = std::fs::read_to_string("config.json").into_diagnostic()?;
    
    // Process the config
    process_config(&config).map_err(|e| e.into())?;
    
    Ok(())
}

Converting Between Error Types

To convert from one error type to another, you can use the From trait or the map_err method on Result.

// Using From trait
impl From<std::io::Error> for MyError {
    fn from(err: std::io::Error) -> Self {
        MyError::IoError(err)
    }
}

// Using map_err
fn read_config() -> Result<Config, MyError> {
    std::fs::read_to_string("config.json")
        .map_err(|e| MyError::IoError(e))?
        .parse()
        .map_err(|e| MyError::ParseError(e))
}

Using Miette's Diagnostic Features

Miette provides several features to enhance error reporting:

Source Code Highlighting

You can include the source code that caused the error and highlight the specific part that's problematic:

#[error("Invalid syntax in configuration file")]
struct InvalidSyntax {
    #[source_code]
    src: NamedSource,

    #[label("this syntax is invalid")]
    span: SourceSpan,
}

To use this in your code:

let source = NamedSource::new("config.json", config_content.to_string());
let span = (error_start, error_length).into();
return Err(ConfigError::InvalidSyntax { src: source, span });

You can add related information to provide context for the error:

#[error("Failed to process package")]
#[diagnostic(
    code(ips::package_error),
    help("Check the package manifest for errors.")
)]
ProcessError {
    #[related]
    related: Vec<ValidationError>,
}

Custom Help Messages

Provide helpful messages to guide users on how to fix the error:

#[error("Invalid configuration")]
#[diagnostic(
    code(ips::config_error),
    help("The configuration file must be valid JSON. Check the syntax and try again.")
)]
InvalidConfig,

Configurable Debug Output with Tracing

The tracing crate provides a powerful framework for structured, event-based logging. You can use it to add debug output to your library that can be enabled or disabled at runtime.

Setting Up Tracing

In your application's main function, initialize the tracing-subscriber:

use tracing_subscriber::{EnvFilter, FmtSubscriber};

fn setup_tracing() {
    let subscriber = FmtSubscriber::builder()
        .with_env_filter(EnvFilter::from_default_env())
        .finish();

    tracing::subscriber::set_global_default(subscriber)
        .expect("setting default subscriber failed");
}

Instrumenting Your Code

Use the tracing macros (trace!, debug!, info!, warn!, error!) to add log statements to your code. You can also use the #[tracing::instrument] attribute to automatically log the entry and exit of a function, along with its arguments.

#[tracing::instrument]
pub fn process_package(package: &Package) -> Result<(), ProcessError> {
    tracing::debug!("Processing package: {}", package.name);
    
    // Your logic here
    
    tracing::info!("Package processed successfully");
    Ok(())
}

Controlling Log Levels

You can control the log level using the RUST_LOG environment variable. For example, to enable debug output, you would run your application like this:

RUST_LOG=debug cargo run

Common log levels, from most to least verbose:

  • trace: Very detailed information, typically only useful for debugging specific issues
  • debug: Useful information for debugging
  • info: General information about the application's operation
  • warn: Potentially problematic situations that don't prevent the application from working
  • error: Error conditions that prevent some functionality from working

Best Practices

  1. Be Specific: Create specific error variants for different failure scenarios.
  2. Be Helpful: Include helpful error messages and diagnostic information.
  3. Be Consistent: Use a consistent naming scheme for error codes.
  4. Be Transparent: Use #[error(transparent)] for wrapping errors from dependencies.
  5. Be Traceable: Use tracing to log important events and debug information.

Example: Converting Existing Code

Here's an example of how to convert an existing error type to use miette:

Before

#[derive(Debug, Error)]
pub enum FmriError {
    #[error("invalid FMRI format")]
    InvalidFormat,
    #[error("invalid version format")]
    InvalidVersionFormat,
    #[error("invalid release format")]
    InvalidReleaseFormat,
}

After

#[derive(Debug, Error, Diagnostic)]
#[diagnostic(code(ips::fmri_error))]
pub enum FmriError {
    #[error("invalid FMRI format")]
    #[diagnostic(
        help("FMRI must be in the format: pkg://publisher/package@version")
    )]
    InvalidFormat,
    
    #[error("invalid version format")]
    #[diagnostic(
        help("Version must be in the format: major.minor.patch")
    )]
    InvalidVersionFormat,
    
    #[error("invalid release format")]
    #[diagnostic(
        help("Release must be a dot-separated list of numbers")
    )]
    InvalidReleaseFormat,
}

Further Reading