Nazara Error Handling Guide

Philosophy

Nazara follows a "fail gracefully" philosophy. We prefer returning descriptive errors over panicking, reserving panics only for truly catastrophic and unfixable situations.

Core Principles:

  • Single Source of Truth: Error messages are defined once in NazaraError variants and displayed consistently
  • Immediate Feedback: Errors are logged immediately using the failure! macro for user visibility
  • Clean Propagation: Most functions use the ? operator without logging noise
  • Context When Needed: Optional context prefixes indicate which module or operation triggered the error

The NazaraError Enum

All errors in Nazara are represented by the NazaraError enum in src/error.rs:

#![allow(unused)]
fn main() {
pub enum NazaraError {
    /// Something went wrong trying to parse DMI tables.
    Dmi(dmidecode::InvalidEntryPointError),
    /// Used to indicate that the collection of system data failed.
    UnableToCollectData(String),
    /// Used for handling errors during file operations.
    FileOpError(std::io::Error),
    /// Indicates that a required config option is missing from the config file.
    MissingConfigOptionError(String),
    /// An error occurred while accessing data returned by NetBox.
    NetBoxApiError(String),
    /// Expects a `String` message. Used for edge cases and general purpose error cases.
    Other(String),
    // ... other variants
}
}

Display Trait Implementation

Every NazaraError variant implements std::fmt::Display to provide user-friendly error messages:

#![allow(unused)]
fn main() {
impl std::fmt::Display for NazaraError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NazaraError::FileOpError(err) => {
                write!(f, "File operation failed: {err}")
            }
            NazaraError::NetBoxApiError(msg) => {
                write!(f, "NetBox API Error: {msg}")
            }
            NazaraError::Other(msg) => f.write_str(&msg),
            // ... other variants
        }
    }
}
}

Important

The Display implementation should never call failure! or any other logging macro. It should only format the error message.

Convenience Methods

The NazaraError enum provides two convenience methods for common error handling patterns:

fail() - Log and Return

#![allow(unused)]
fn main() {
impl NazaraError {
    /// Log this error with failure! and return it wrapped in Err(...)
    pub fn fail<T>(self) -> NazaraResult<T> {
        failure!("{}", self);
        Err(self)
    }
}
}

Purpose: Combines logging and returning into a single operation.

Example: Using the fail-function to log and return an error

#![allow(unused)]
fn main() {
return NazaraError::Other("Config file contains invalid entries".to_owned()).fail();
}

log() - Log with Context

#![allow(unused)]
fn main() {
impl NazaraError {
    /// Log this error with additional context prefix.
    /// Used when we need to log multiple errors but continue processing to fail in the end
    /// either with the calling function or some other higher instance.
    ///
    /// The optional context in this case refers to the module or program part that the error
    /// occurred in. For example: [DHCP-Mode].
    pub fn log(&self, context: Option<&str>) {
        if let Some(ctx) = context {
            failure!("[{}] {}", ctx, self);
            return;
        }
        failure!("{}", self);
    }
}
}

Purpose: Log errors with optional context when you need to accumulate multiple errors before failing.

Example: Logging an error but continue processing

#![allow(unused)]
fn main() {
// Accumulate errors, fail at the end
for validation in validations {
    if !validation.is_valid() {
        let err = NazaraError::Other(format!("Validation '{}' failed", validation.name()));
        err.log(None);
        error_count += 1;
    }
}

if error_count > 0 {
    return NazaraError::Other("Multiple validations failed".to_owned()).fail();
}
}

Example: Logging an error with context

context indicates where or why this error has occurred. For example when working with the ip-mode flags, errors rooted in the changes of IP addresses by DHCP or something similar have the context "DHCP-Mode".

#![allow(unused)]
fn main() {
let err = NazaraError::NetBoxApiError("IPv4 not found".to_owned());
err.log(Some("DHCP-Mode"));
return Err(err);
}

Examples

Example: Logging with Return

Use when an error is terminal and should be returned immediately.

#![allow(unused)]
fn main() {
return NazaraError::Other("Config file contains invalid entries".to_owned()).fail();
}

Example: Log with Context, Then Return

Use when the error needs additional module/operation context.

#![allow(unused)]
fn main() {
let err = NazaraError::NetBoxApiError(
    format!("IPv4 address \"{}\" was not registered in NetBox", ip)
);
err.log(Some("DHCP-Mode"));
return Err(err);
}

Or in a closure (for use with ok_or_else):

#![allow(unused)]
fn main() {
let ipv4_id = search_ip(client, &ip.to_string(), None)?.ok_or_else(|| {
    let err = NazaraError::NetBoxApiError(format!("IPv4 \"{}\" not found", ip));
    err.log(Some("DHCP-Mode"));
    err
})?;
}

Example: Accumulate Errors, Fail Later

Use when processing multiple items and collecting all errors before failing.

#![allow(unused)]
fn main() {
let mut config_errors = Vec::new();

for field in required_fields {
    if config.get(field).is_none() {
        let err = NazaraError::MissingConfigOptionError(field.to_string());
        err.log(None);
        config_errors.push(err);
    }
}

if !config_errors.is_empty() {
    return NazaraError::Other("Missing required config fields".to_owned()).fail();
}
}

Example: Custom User-Facing Messages

When you need to provide specific user guidance, use failure! directly.

#![allow(unused)]
fn main() {
failure!(
    "Tag '{}' does not exist. Use --prepare-environment to create it.",
    tag_name
);
}

This is clearer than trying to fit the guidance into a NazaraError variant's display message.

When to Use What

Use .fail() when:

  • The error is terminal (nothing else can be done)
  • The error message is sufficient (no additional context needed)
  • You want clean, single-line error handling
  • This is the 90% common case

Use .log() when:

  • You need to accumulate multiple errors before failing
  • You want to add module/operation context
  • The error should be logged but processing should continue
  • You need to track multiple issues

Use failure! directly when:

  • You need a custom message with user guidance
  • The message is temporary/debugging (will be replaced later)
  • You're providing feedback before returning a different error

Don't use either when:

  • You can simply return the error with ? (no logging needed)
  • The caller will handle the error (low-level utilities)
  • The error will be logged by the caller anyway

Best Practices

1. Choose the Right Error Variant

Prefer specific variants over Other when possible:

#![allow(unused)]
fn main() {
// GOOD
return NazaraError::MissingConfigOptionError("netbox_uri".to_owned()).fail();

// OK (when no specific variant fits)
return NazaraError::Other("Custom error message".to_owned()).fail();
}

2. Include Context in Error Messages

When using Other, make the message descriptive:

#![allow(unused)]
fn main() {
// GOOD
return NazaraError::Other("Config file contains invalid entries".to_owned()).fail();

// BAD
return NazaraError::Other("Error".to_owned()).fail();
}

3. Use Context for Module Identification

When logging with context, use module or operation names:

#![allow(unused)]
fn main() {
err.log(Some("DHCP-Mode"));
err.log(Some("Config-Parser"));
}

4. Don't Duplicate Error Messages

The error message should be defined once—in the Display implementation or the error construction:

#![allow(unused)]
fn main() {
// GOOD
let msg = "Config file contains invalid entries".to_owned();
failure!("{}", msg);
return Err(NazaraError::Other(msg));

// REDUNDANT (duplicates the message)
failure!("Config file contains invalid entries");
return NazaraError::Other("Config file contains invalid entries".to_owned()).fail();
}

5. Let Errors Propagate

For low-level utility functions, just return errors. Let the caller decide whether to log:

#![allow(unused)]
fn main() {
// In a low-level utility
pub fn read_config_file() -> NazaraResult<ConfigData> {
    let mut file = File::open(get_config_path(true))?;  // Just propagate
    // ...
}

// In the calling function
let config = read_config_file()
    .map_err(|e| e.log(None))?;  // Log here
}

Error Flow

[Deep Function] → Creates/Returns NazaraError
        ↓ (via ? operator)
[Middle Function] → Propagates with ?
        ↓ (via ? operator)
[Application Logic] → Logs with .fail() or .log()
        ↓
[main.rs] → Catches error, logs with failure!
        ↓
[stderr] → User sees formatted error message

Example: Complete Error Handling Flow

Here's how error handling works in a real scenario:

// 1. Low-level function (no logging, just propagation)
pub fn read_config_file() -> NazaraResult<ConfigData> {
    let mut file = File::open(get_config_path(true))?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    toml::from_str(&contents).map_err(NazaraError::DeserializationError)
}

// 2. Mid-level function (propagates errors)
pub fn validate_config(config: &ConfigData) -> NazaraResult<()> {
    if config.netbox_uri.is_empty() {
        return Err(NazaraError::MissingConfigOptionError("netbox_uri".to_owned()));
    }
    Ok(())
}

// 3. Application logic (logs and returns)
pub fn setup_configuration() -> NazaraResult<ConfigData> {
    let config = read_config_file()?;
    validate_config(&config)?;
    
    if config.is_invalid() {
        return NazaraError::Other("Config validation failed".to_owned()).fail();
    }
    
    Ok(config)
}

// 4. Main entry point (logs final error)
fn main() {
    match nazara::run() {
        Ok(_) => success!("All done!"),
        Err(e) => failure!("{}", e),  // Uses failure! for consistency
    }
}

Remember: Errors should be logged once, at the appropriate level, with sufficient context.