saddle-core 0.3.29

Shared contracts for Saddle components
Documentation
use std::{any::Any, 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>>,
    source_receipt: Option<Box<dyn Any + Send + Sync>>,
    cleanup_diagnostic: Option<Box<crate::Diagnostic>>,
    cleanup_source_receipt: Option<Box<dyn Any + Send + Sync>>,
    unconfirmed_original: Option<Box<dyn Error + Send + Sync>>,
    source_unavailable: bool,
    cleanup_source_unavailable: bool,
}

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

    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()
    }
    /// Carry the writer's concrete receipt across a component boundary.
    /// This slot alone does not claim that the original source was written.
    pub fn with_source_receipt<T: Any + Send + Sync>(mut self, receipt: T) -> Self {
        self.source_receipt = Some(Box::new(receipt));
        self
    }
    pub fn source_receipt<T: Any + Send + Sync>(&self) -> Option<&T> {
        self.source_receipt.as_ref()?.downcast_ref::<T>()
    }
    /// Retain an original whose controlled write was not confirmed. This is
    /// negative evidence and cannot be consumed as a source receipt.
    pub fn with_unconfirmed_original(
        mut self, original: Box<dyn Error + Send + Sync>,
    ) -> Self {
        self.source_unavailable = true;
        self.unconfirmed_original = Some(original);
        self
    }
    pub fn unconfirmed_original(&self) -> Option<&(dyn Error + Send + Sync)> {
        self.unconfirmed_original.as_deref()
    }
    pub fn take_unconfirmed_original(&mut self) -> Option<Box<dyn Error + Send + Sync>> {
        self.unconfirmed_original.take()
    }
    /// Keep an independent cleanup occurrence alongside the unchanged primary.
    /// Only the concrete writer receipt can establish that its original was written.
    pub fn with_cleanup_source_record<T: Any + Send + Sync>(
        mut self,
        diagnostic: crate::Diagnostic,
        receipt: T,
    ) -> Self {
        self.cleanup_diagnostic = Some(Box::new(diagnostic));
        self.cleanup_source_receipt = Some(Box::new(receipt));
        self
    }
    pub fn cleanup_diagnostic(&self) -> Option<&crate::Diagnostic> {
        self.cleanup_diagnostic.as_deref()
    }
    pub fn cleanup_source_receipt<T: Any + Send + Sync>(&self) -> Option<&T> {
        self.cleanup_source_receipt.as_ref()?.downcast_ref::<T>()
    }
    /// Retain the independent cleanup occurrence when its original write was
    /// unavailable. No receipt is attached to this negative fact.
    pub fn with_unconfirmed_cleanup_diagnostic(mut self, diagnostic: crate::Diagnostic) -> Self {
        self.cleanup_diagnostic = Some(Box::new(diagnostic));
        self.cleanup_source_receipt = None;
        self.cleanup_source_unavailable = true;
        self
    }
    /// The primary error's original source record could not be confirmed.
    pub fn with_unconfirmed_source(mut self) -> Self {
        self.source_unavailable = true;
        self
    }
    pub const fn source_unavailable(&self) -> bool {
        self.source_unavailable
    }
    /// A separate cleanup failure could not obtain a written source record.
    /// This negative fact never claims the primary or cleanup was recorded.
    pub fn with_unconfirmed_cleanup_source(mut self) -> Self {
        self.cleanup_source_unavailable = true;
        self
    }
    pub const fn cleanup_source_unavailable(&self) -> bool {
        self.cleanup_source_unavailable
    }
    /// 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
    }
    /// Link a later cleanup failure to a captured primary occurrence.
    pub fn during_cleanup_of_occurrence(mut self, primary: &crate::DiagnosticOccurrence) -> Self {
        if let Some(diagnostic) = self.diagnostic.take() {
            self.diagnostic = Some(Box::new(diagnostic.during_cleanup_of_occurrence(primary)));
        }
        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)?;
        }
        if self.source_unavailable {
            write!(formatter, "; source=unavailable")?;
        }
        if self.cleanup_source_unavailable {
            write!(formatter, "; cleanup_source=unavailable")?;
        }
        Ok(())
    }
}

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)
                .field("source_unavailable", &self.source_unavailable)
                .field("cleanup_source_unavailable", &self.cleanup_source_unavailable)
                .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");
    }
}