cruxi 0.1.0

Minimal, transport-agnostic hexagonal architecture framework
Documentation
//! Error types for the Cruxi framework.
//!
//! This module provides structured error types that support:
//! - Machine-readable error codes for API responses
//! - Error chaining via `source()` for debugging
//! - Field-level validation errors

use std::fmt;
use thiserror::Error;

/// Sentinel errors for common framework conditions.
#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
pub enum CruxiError {
    /// Service was not provided (nil/None).
    #[error("cruxi: nil service")]
    NilService,

    /// Provider was not provided (nil/None).
    #[error("cruxi: nil provider")]
    NilProvider,

    /// Authorization check failed.
    #[error("cruxi: unauthorized")]
    Unauthorized,
}

/// A structured error with a machine-readable code.
///
/// `CodedError` is designed for API responses where clients need stable error
/// codes for programmatic handling. The error chain is preserved via `source`.
///
/// # Example
///
/// ```
/// use cruxi::CodedError;
///
/// let err = CodedError::new("USER_NOT_FOUND")
///     .with_title("User Not Found")
///     .with_reason("No user exists with the given ID")
///     .with_instance("req-12345");
///
/// assert_eq!(err.code(), "USER_NOT_FOUND");
/// ```
#[derive(Debug, Clone)]
pub struct CodedError {
    code: String,
    instance: Option<String>,
    title: Option<String>,
    reason: Option<String>,
    source: Option<Box<CodedError>>,
}

impl CodedError {
    /// Creates a new `CodedError` with the given code.
    ///
    /// The code should be a stable, machine-readable identifier like
    /// `"VALIDATION_FAILED"` or `"USER_NOT_FOUND"`.
    #[must_use]
    pub fn new(code: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            instance: None,
            title: None,
            reason: None,
            source: None,
        }
    }

    /// Sets the instance identifier for error correlation.
    ///
    /// Typically a request ID or UUID for tracing the error back to logs.
    #[must_use]
    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
        self.instance = Some(instance.into());
        self
    }

    /// Sets a short, human-readable title.
    #[must_use]
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Sets a detailed explanation of the error.
    #[must_use]
    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
        self.reason = Some(reason.into());
        self
    }

    /// Sets the underlying cause of this error.
    #[must_use]
    pub fn with_source(mut self, source: CodedError) -> Self {
        self.source = Some(Box::new(source));
        self
    }

    /// Returns the error code.
    #[must_use]
    pub fn code(&self) -> &str {
        &self.code
    }

    /// Returns the instance identifier, if set.
    #[must_use]
    pub fn instance(&self) -> Option<&str> {
        self.instance.as_deref()
    }

    /// Returns the title, if set.
    #[must_use]
    pub fn title(&self) -> Option<&str> {
        self.title.as_deref()
    }

    /// Returns the reason, if set.
    #[must_use]
    pub fn reason(&self) -> Option<&str> {
        self.reason.as_deref()
    }
}

impl fmt::Display for CodedError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match (&self.reason, &self.title) {
            (Some(reason), _) => write!(f, "{reason}"),
            (None, Some(title)) => write!(f, "{title}"),
            (None, None) => write!(f, "cruxi: coded error [{}]", self.code),
        }
    }
}

impl std::error::Error for CodedError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source
            .as_ref()
            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
    }
}

/// A field-level validation error.
///
/// Used by handlers for transport format validation (e.g., "field required in JSON").
///
/// # Example
///
/// ```
/// use cruxi::ValidationError;
///
/// let err = ValidationError::new("email", "required");
/// assert_eq!(err.to_string(), "cruxi: validation error: email: required");
/// ```
#[derive(Debug, Clone, Error)]
#[error("cruxi: validation error: {field}: {message}")]
pub struct ValidationError {
    field: String,
    message: String,
}

impl ValidationError {
    /// Creates a new validation error for the given field.
    #[must_use]
    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            field: field.into(),
            message: message.into(),
        }
    }

    /// Returns the field name that failed validation.
    #[must_use]
    pub fn field(&self) -> &str {
        &self.field
    }

    /// Returns the validation failure message.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    #[test]
    fn coded_error_display_with_reason() {
        let err = CodedError::new("TEST")
            .with_reason("detailed reason")
            .with_title("Title");
        assert_eq!(err.to_string(), "detailed reason");
    }

    #[test]
    fn coded_error_display_with_title_only() {
        let err = CodedError::new("TEST").with_title("Title Only");
        assert_eq!(err.to_string(), "Title Only");
    }

    #[test]
    fn coded_error_display_fallback() {
        let err = CodedError::new("TEST_CODE");
        assert_eq!(err.to_string(), "cruxi: coded error [TEST_CODE]");
    }

    #[test]
    fn coded_error_chain() {
        let inner = CodedError::new("INNER").with_reason("inner cause");
        let outer = CodedError::new("OUTER")
            .with_reason("outer reason")
            .with_source(inner);

        assert!(outer.source().is_some());
    }

    #[test]
    fn validation_error_display() {
        let err = ValidationError::new("email", "required");
        assert_eq!(err.to_string(), "cruxi: validation error: email: required");
    }
}