saddle-core 0.3.22

Shared contracts for Saddle components
Documentation
use std::{error::Error, fmt};

/// Stable error categories used at component and Service boundaries.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ErrorKind {
    InvalidArgument,
    NotFound,
    Conflict,
    Business,
    Unavailable,
    Infrastructure,
    Internal,
}

/// An error safe to propagate across Saddle component boundaries.
///
/// `message` must not contain credentials, SQL parameters, request payloads or
/// other sensitive implementation details.
pub struct SaddleError {
    kind: ErrorKind,
    code: &'static str,
    message: String,
    diagnostic: Option<Box<crate::Diagnostic>>,
}

impl SaddleError {
    pub fn new(kind: ErrorKind, code: &'static str, message: impl Into<String>) -> Self {
        Self {
            kind,
            code,
            message: message.into(),
            diagnostic: None,
        }
    }

    pub const fn kind(&self) -> ErrorKind {
        self.kind
    }

    pub const fn code(&self) -> &'static str {
        self.code
    }

    pub fn message(&self) -> &str {
        &self.message
    }

    pub fn with_diagnostic(mut self, diagnostic: crate::Diagnostic) -> Self {
        self.diagnostic = Some(Box::new(diagnostic));
        self
    }

    pub fn diagnostic(&self) -> Option<&crate::Diagnostic> {
        self.diagnostic.as_deref()
    }
    /// Preserve this cleanup occurrence and link it to the independent primary.
    pub fn during_cleanup_of(mut self, primary: &Self) -> Self {
        if let Some(parent) = primary.diagnostic()
            && let Some(diagnostic) = self.diagnostic.take()
        {
            self.diagnostic = Some(Box::new(diagnostic.during_cleanup_of(parent)));
        }
        self
    }
    /// Propagate existing diagnosis without changing its origin or error semantics.
    /// An uninstrumented upstream error remains uninstrumented, never fabricated.
    pub fn wrap_diagnostic(mut self, cause: crate::DiagnosticCause) -> Self {
        if let Some(diagnostic) = self.diagnostic.take() {
            self.diagnostic = Some(Box::new(diagnostic.wrap(cause)));
        }
        self
    }
}

impl fmt::Display for SaddleError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(diagnostic) = &self.diagnostic {
            // Do not reintroduce an arbitrary upstream message into the safe projection.
            fmt::Display::fmt(diagnostic, formatter)
        } else {
            write!(formatter, "{}: {}", self.code, self.message)
        }
    }
}

impl fmt::Debug for SaddleError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        if self.diagnostic.is_some() {
            fmt::Display::fmt(self, formatter)
        } else {
            formatter
                .debug_struct("SaddleError")
                .field("kind", &self.kind)
                .field("code", &self.code)
                .field("message", &self.message)
                .finish()
        }
    }
}

impl Error for SaddleError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.diagnostic
            .as_deref()
            .map(|d| d as &(dyn Error + 'static))
    }
}

pub type Result<T> = std::result::Result<T, SaddleError>;

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

    #[test]
    fn cleanup_link_keeps_existing_origin_and_missing_primary() {
        use crate::{
            CaptureSite, Diagnostic, DiagnosticCategory, DiagnosticCause, DiagnosticCode,
            DiagnosticStage,
        };
        fn error(code: &'static str) -> SaddleError {
            SaddleError::new(ErrorKind::Internal, code, "safe").with_diagnostic(
                Diagnostic::capture(
                    DiagnosticCategory::UnexpectedError,
                    CaptureSite::FirstObserved,
                    DiagnosticCause::new(
                        DiagnosticStage::FinalizerResource,
                        DiagnosticCode::new(code).unwrap(),
                    ),
                ),
            )
        }
        let primary = error("test.primary");
        let cleanup = error("test.cleanup");
        let id = cleanup.diagnostic().unwrap().id();
        let cleanup = cleanup.during_cleanup_of(&SaddleError::new(
            ErrorKind::Internal,
            "test.no_diagnostic",
            "safe",
        ));
        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
        let before = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
        let cleanup = cleanup.during_cleanup_of(&primary);
        let after = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
        assert_eq!(after["origin"], before["origin"]);
        assert_eq!(after["causes"], before["causes"]);
        assert_eq!(
            after["primary_diagnostic_id"],
            primary.diagnostic().unwrap().id()
        );
        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
    }

    #[test]
    fn error_exposes_stable_classification() {
        let error = SaddleError::new(ErrorKind::NotFound, "user.not_found", "user not found");

        assert_eq!(error.kind(), ErrorKind::NotFound);
        assert_eq!(error.code(), "user.not_found");
        assert_eq!(error.to_string(), "user.not_found: user not found");
    }
}