Error Handling Conventions
This document outlines the error handling patterns and conventions used throughout the OPNsense Config Faker project.
Architecture Overview
The project uses a layered error handling approach:
Library Layer (src/lib.rs and modules):
├── Use thiserror for all error types ✅
├── Return domain-specific Results
└── Provide rich error context
Binary Layer (src/main.rs and CLI):
├── Use anyhow::Result for main()
├── Add .context() for additional debugging info
└── Aggregate errors from multiple sources
Library Code Error Handling
Error Type Standards
All library error types use thiserror for automatic Display and Error trait implementations:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Invalid VLAN ID: {id}. Must be between 1 and {max}")]
InvalidVlanId { id: u16, max: u16 },
#[error("Network range conflict: {range1} conflicts with {range2}")]
NetworkRangeConflict { range1: String, range2: String },
#[error("XML generation failed")]
XmlGenerationFailed(#[from] quick_xml::Error),
}
}
Result Type Usage
Use consistent Result<T, E> types throughout the library:
#![allow(unused)]
fn main() {
pub type Result<T> = std::result::Result<T, ConfigError>;
pub fn generate_vlan_config(count: u32, base_id: u16) -> Result<Vec<VlanConfig>> {
if base_id == 0 || base_id > MAX_VLAN_ID {
return Err(ConfigError::InvalidVlanId {
id: base_id,
max: MAX_VLAN_ID,
});
}
// Implementation...
Ok(vlans)
}
}
Error Context Guidelines
- Include relevant parameters in error messages
- Provide actionable suggestions when possible
- Preserve error source chain using
#[from]attributes - Use descriptive error variants for different failure modes
Binary/CLI Code Error Handling
anyhow Integration
The binary layer uses anyhow for error aggregation and context preservation:
use anyhow::{Context, Result};
fn main() -> Result<()> {
let cli = Cli::parse();
// Set up environment with context
setup_environment(&cli).context("Failed to setup CLI environment")?;
// Execute command with rich context
match cli.command {
Commands::Generate(args) => {
generate::execute(args).context("Failed to generate configurations")?
} // ... other commands
}
Ok(())
}
Context Preservation
Add context to error operations using .context() and .with_context():
#![allow(unused)]
fn main() {
pub fn execute(args: GenerateArgs) -> Result<()> {
let configs = generate_vlan_configurations(args.count, args.seed, None)
.with_context(|| format!("Failed to generate {} VLAN configurations", args.count))?;
write_csv(&configs, &args.output)
.with_context(|| format!("Failed to write CSV to {:?}", args.output))?;
Ok(())
}
}
CLI-Specific Error Types
Use CliError for CLI-specific error handling:
#![allow(unused)]
fn main() {
#[derive(Debug, Error)]
pub enum CliError {
#[error("Invalid command-line argument: {0}")]
InvalidArgument(String),
#[error("Interactive mode failed: {0}")]
InteractiveModeError(String),
#[error(transparent)]
Config(#[from] crate::model::ConfigError),
}
}
Error Message Guidelines
Structure
Error messages should follow this structure:
- Action: What operation failed
- Context: Relevant parameters or conditions
- Suggestion: Actionable remediation steps
Examples
#![allow(unused)]
fn main() {
// ✅ Good: Clear action, context, and suggestion
"Failed to generate 5000 VLAN configurations: VLAN ID pool exhausted (max: 4085). Reduce count or use CSV format for duplicates."
// ✅ Good: File operation with path context
"Failed to write CSV to '/nonexistent/path/test.csv': Permission denied. Check directory permissions and try again."
// ❌ Bad: Vague error message
"Error occurred during processing."
}
Network Configuration Specific
For network-related errors, include technical details:
#![allow(unused)]
fn main() {
// VLAN ID errors
"Invalid VLAN ID: 5000. Must be between 1 and 4094. Use --base-id with a valid range."
// Network range conflicts
"Network range conflict: 192.168.1.0/24 overlaps with 192.168.1.0/25. Use --network-base to specify a different range."
// XML schema validation
"XML schema validation failed: Missing required element 'vlan' at path '/opnsense/vlans'. Check base configuration template."
}
Error Testing
Unit Tests
Test error conditions in unit tests:
#![allow(unused)]
fn main() {
#[test]
fn test_vlan_id_validation() {
let result = VlanConfig::new(
0,
"Test".to_string(),
"em0".to_string(),
"192.168.1.0/24".parse().unwrap(),
);
assert!(matches!(
result,
Err(ConfigError::InvalidVlanId { id: 0, .. })
));
}
}
Integration Tests
Test error handling in CLI commands:
#![allow(unused)]
fn main() {
#[test]
fn test_cli_error_context() {
let mut cmd = Command::cargo_bin("opnsense-config-faker").unwrap();
cmd.arg("generate").arg("--count").arg("99999");
cmd.assert()
.failure()
.stderr(predicate::str::contains(
"Failed to generate configurations",
))
.stderr(predicate::str::contains("99999"));
}
}
Property-Based Tests
Use property-based testing for error edge cases:
#![allow(unused)]
fn main() {
proptest! {
#[test]
fn test_invalid_vlan_id_range(id in 0u16..1u16) {
let result = VlanConfig::new(id, "Test".to_string(), "em0".to_string(), "192.168.1.0/24".parse().unwrap());
prop_assert!(result.is_err());
}
}
}
Error Logging
Error Output
Use eprintln! for error output to stderr:
#![allow(unused)]
fn main() {
// Option 1: Using the log crate
use log::{error, info};
error!("Failed to generate VLAN configurations: count={}, base_id={}, error={}",
args.count, args.base_id, e);
// Option 2: Using tracing crate
use tracing::{error, info};
error!(count = args.count, base_id = args.base_id, error = ?e,
"Failed to generate VLAN configurations");
}
Console Styling for User-Facing Messages
Use console::style for styled error messages in CLI output:
#![allow(unused)]
fn main() {
use console::style;
// Styled error message for user display
eprintln!(
"{} {}",
style("❌ Error:").red().bold(),
style("Failed to generate VLAN configurations").red()
);
// Styled warning message
eprintln!(
"{} {}",
style("⚠️ Warning:").yellow().bold(),
style("Some configurations may be invalid").yellow()
);
}
Error Chain Preservation
Preserve the full error chain for debugging:
#![allow(unused)]
fn main() {
// The error chain is automatically preserved by anyhow
// Users can access the full chain with .chain()
for error in e.chain() {
eprintln!(" Caused by: {}", error);
}
}
Common Error Patterns
File Operations
#![allow(unused)]
fn main() {
// File reading with context
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read file: {:?}", path))?;
}
Network Configuration Validation
#![allow(unused)]
fn main() {
vlan.validate()
.with_context(|| format!("VLAN {} validation failed", vlan.id))?
}
Argument Validation
#![allow(unused)]
fn main() {
args.validate()
.map_err(|e| CliError::invalid_argument(e))?
}
Progress Indicator Creation
#![allow(unused)]
fn main() {
// ProgressBar::new is infallible - no error handling needed
let pb = ProgressBar::new(count);
}
Error Recovery Strategies
Graceful Degradation
When possible, provide fallback behavior:
#![allow(unused)]
fn main() {
// Detect dumb terminal and provide appropriate fallback
// Note: unwrap_or_default() is applied to env::var(), not ProgressBar::new()
let pb = if std::env::var("TERM").unwrap_or_default() == "dumb" {
ProgressBar::hidden() // Hidden for dumb terminals
} else {
ProgressBar::new(count) // Visible progress bar for interactive terminals
};
// Alternative: More comprehensive terminal detection
use std::env;
let pb = if env::var("NO_COLOR").is_ok()
|| env::var("TERM").unwrap_or_default() == "dumb"
|| !atty::is(atty::Stream::Stderr) {
ProgressBar::hidden()
} else {
ProgressBar::new(count)
};
// Alternative: Use isatty/atty crate for robust terminal detection
use atty::Stream;
let pb = if atty::is(Stream::Stdout) && atty::is(Stream::Stderr) {
ProgressBar::new(count) // Interactive terminal
} else {
ProgressBar::hidden() // Non-interactive (pipes, redirects, etc.)
};
}
User-Friendly Messages
Convert technical errors to user-friendly messages:
#![allow(unused)]
fn main() {
match error {
ConfigError::InvalidVlanId { id, max } => {
format!("VLAN ID {} is invalid. Use a value between 1 and {}.", id, max)
}
ConfigError::NetworkRangeConflict { range1, range2 } => {
format!("Network ranges {} and {} conflict. Use different ranges.", range1, range2)
}
_ => error.to_string(),
}
}
Best Practices
Do’s
- ✅ Use
thiserrorfor all error types - ✅ Include relevant context in error messages
- ✅ Preserve error chains with
#[from]attributes - ✅ Add
.context()to error operations in CLI code - ✅ Test error conditions comprehensively
- ✅ Provide actionable error messages
- ✅ Use structured logging for debugging
Don’ts
- ❌ Use
.unwrap()in production code - ❌ Ignore error conditions
- ❌ Provide vague error messages
- ❌ Lose error context in conversions
- ❌ Skip error testing
- ❌ Use generic error types when specific ones are available
Migration Guide
From Library Result to anyhow Result
#![allow(unused)]
fn main() {
// Before: Library Result
pub fn execute(args: GenerateArgs) -> crate::Result<()> {
let configs = generate_vlan_configurations(args.count, args.seed, None)?;
write_csv(&configs, &args.output)?;
Ok(())
}
// After: anyhow Result with context
pub fn execute(args: GenerateArgs) -> anyhow::Result<()> {
let configs = generate_vlan_configurations(args.count, args.seed, None)
.with_context(|| format!("Failed to generate {} VLAN configurations", args.count))?;
write_csv(&configs, &args.output)
.with_context(|| format!("Failed to write CSV to {:?}", args.output))?;
Ok(())
}
}
Adding Error Context
#![allow(unused)]
fn main() {
// Before: Basic error propagation
let content = fs::read_to_string(&path)?;
// After: Rich error context
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read configuration file: {:?}", path))?;
}
This comprehensive error handling framework ensures that users receive clear, actionable error messages while maintaining full error context for debugging and development.