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:
thiserrorfor creating custom error types with clear error messagesmiettefor rich, user-friendly error reporting with diagnostic informationtracingfor 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 providesource_codeand alabelwith aSourceSpanto 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_errorips::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 });
Related Information
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 issuesdebug: Useful information for debugginginfo: General information about the application's operationwarn: Potentially problematic situations that don't prevent the application from workingerror: Error conditions that prevent some functionality from working
Best Practices
- Be Specific: Create specific error variants for different failure scenarios.
- Be Helpful: Include helpful error messages and diagnostic information.
- Be Consistent: Use a consistent naming scheme for error codes.
- Be Transparent: Use
#[error(transparent)]for wrapping errors from dependencies. - 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,
}