Skip to main content

saddle_core/
error.rs

1use std::{any::Any, error::Error, fmt};
2
3/// Stable error categories used at component and Service boundaries.
4#[derive(Clone, Copy, Debug, Eq, PartialEq)]
5#[non_exhaustive]
6pub enum ErrorKind {
7    InvalidArgument,
8    NotFound,
9    Conflict,
10    Business,
11    Unavailable,
12    Infrastructure,
13    Internal,
14}
15
16/// An error safe to propagate across Saddle component boundaries.
17///
18/// `message` must not contain credentials, SQL parameters, request payloads or
19/// other sensitive implementation details.
20pub struct SaddleError {
21    kind: ErrorKind,
22    code: &'static str,
23    message: String,
24    diagnostic: Option<Box<crate::Diagnostic>>,
25    source_receipt: Option<Box<dyn Any + Send + Sync>>,
26    cleanup_diagnostic: Option<Box<crate::Diagnostic>>,
27    cleanup_source_receipt: Option<Box<dyn Any + Send + Sync>>,
28    unconfirmed_original: Option<Box<dyn Error + Send + Sync>>,
29    source_unavailable: bool,
30    cleanup_source_unavailable: bool,
31}
32
33impl SaddleError {
34    pub fn new(kind: ErrorKind, code: &'static str, message: impl Into<String>) -> Self {
35        Self {
36            kind,
37            code,
38            message: message.into(),
39            diagnostic: None,
40            source_receipt: None,
41            cleanup_diagnostic: None,
42            cleanup_source_receipt: None,
43            unconfirmed_original: None,
44            source_unavailable: false,
45            cleanup_source_unavailable: false,
46        }
47    }
48
49    pub const fn kind(&self) -> ErrorKind {
50        self.kind
51    }
52
53    pub const fn code(&self) -> &'static str {
54        self.code
55    }
56
57    pub fn message(&self) -> &str {
58        &self.message
59    }
60
61    pub fn with_diagnostic(mut self, diagnostic: crate::Diagnostic) -> Self {
62        self.diagnostic = Some(Box::new(diagnostic));
63        self
64    }
65
66    pub fn diagnostic(&self) -> Option<&crate::Diagnostic> {
67        self.diagnostic.as_deref()
68    }
69    /// Carry the writer's concrete receipt across a component boundary.
70    /// This slot alone does not claim that the original source was written.
71    pub fn with_source_receipt<T: Any + Send + Sync>(mut self, receipt: T) -> Self {
72        self.source_receipt = Some(Box::new(receipt));
73        self
74    }
75    pub fn source_receipt<T: Any + Send + Sync>(&self) -> Option<&T> {
76        self.source_receipt.as_ref()?.downcast_ref::<T>()
77    }
78    /// Retain an original whose controlled write was not confirmed. This is
79    /// negative evidence and cannot be consumed as a source receipt.
80    pub fn with_unconfirmed_original(
81        mut self, original: Box<dyn Error + Send + Sync>,
82    ) -> Self {
83        self.source_unavailable = true;
84        self.unconfirmed_original = Some(original);
85        self
86    }
87    pub fn unconfirmed_original(&self) -> Option<&(dyn Error + Send + Sync)> {
88        self.unconfirmed_original.as_deref()
89    }
90    pub fn take_unconfirmed_original(&mut self) -> Option<Box<dyn Error + Send + Sync>> {
91        self.unconfirmed_original.take()
92    }
93    /// Keep an independent cleanup occurrence alongside the unchanged primary.
94    /// Only the concrete writer receipt can establish that its original was written.
95    pub fn with_cleanup_source_record<T: Any + Send + Sync>(
96        mut self,
97        diagnostic: crate::Diagnostic,
98        receipt: T,
99    ) -> Self {
100        self.cleanup_diagnostic = Some(Box::new(diagnostic));
101        self.cleanup_source_receipt = Some(Box::new(receipt));
102        self
103    }
104    pub fn cleanup_diagnostic(&self) -> Option<&crate::Diagnostic> {
105        self.cleanup_diagnostic.as_deref()
106    }
107    pub fn cleanup_source_receipt<T: Any + Send + Sync>(&self) -> Option<&T> {
108        self.cleanup_source_receipt.as_ref()?.downcast_ref::<T>()
109    }
110    /// Retain the independent cleanup occurrence when its original write was
111    /// unavailable. No receipt is attached to this negative fact.
112    pub fn with_unconfirmed_cleanup_diagnostic(mut self, diagnostic: crate::Diagnostic) -> Self {
113        self.cleanup_diagnostic = Some(Box::new(diagnostic));
114        self.cleanup_source_receipt = None;
115        self.cleanup_source_unavailable = true;
116        self
117    }
118    /// The primary error's original source record could not be confirmed.
119    pub fn with_unconfirmed_source(mut self) -> Self {
120        self.source_unavailable = true;
121        self
122    }
123    pub const fn source_unavailable(&self) -> bool {
124        self.source_unavailable
125    }
126    /// A separate cleanup failure could not obtain a written source record.
127    /// This negative fact never claims the primary or cleanup was recorded.
128    pub fn with_unconfirmed_cleanup_source(mut self) -> Self {
129        self.cleanup_source_unavailable = true;
130        self
131    }
132    pub const fn cleanup_source_unavailable(&self) -> bool {
133        self.cleanup_source_unavailable
134    }
135    /// Preserve this cleanup occurrence and link it to the independent primary.
136    pub fn during_cleanup_of(mut self, primary: &Self) -> Self {
137        if let Some(parent) = primary.diagnostic()
138            && let Some(diagnostic) = self.diagnostic.take()
139        {
140            self.diagnostic = Some(Box::new(diagnostic.during_cleanup_of(parent)));
141        }
142        self
143    }
144    /// Link a later cleanup failure to a captured primary occurrence.
145    pub fn during_cleanup_of_occurrence(mut self, primary: &crate::DiagnosticOccurrence) -> Self {
146        if let Some(diagnostic) = self.diagnostic.take() {
147            self.diagnostic = Some(Box::new(diagnostic.during_cleanup_of_occurrence(primary)));
148        }
149        self
150    }
151    /// Propagate existing diagnosis without changing its origin or error semantics.
152    /// An uninstrumented upstream error remains uninstrumented, never fabricated.
153    pub fn wrap_diagnostic(mut self, cause: crate::DiagnosticCause) -> Self {
154        if let Some(diagnostic) = self.diagnostic.take() {
155            self.diagnostic = Some(Box::new(diagnostic.wrap(cause)));
156        }
157        self
158    }
159}
160
161impl fmt::Display for SaddleError {
162    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163        if let Some(diagnostic) = &self.diagnostic {
164            // Do not reintroduce an arbitrary upstream message into the safe projection.
165            fmt::Display::fmt(diagnostic, formatter)?;
166        } else {
167            write!(formatter, "{}: {}", self.code, self.message)?;
168        }
169        if self.source_unavailable {
170            write!(formatter, "; source=unavailable")?;
171        }
172        if self.cleanup_source_unavailable {
173            write!(formatter, "; cleanup_source=unavailable")?;
174        }
175        Ok(())
176    }
177}
178
179impl fmt::Debug for SaddleError {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        if self.diagnostic.is_some() {
182            fmt::Display::fmt(self, formatter)
183        } else {
184            formatter
185                .debug_struct("SaddleError")
186                .field("kind", &self.kind)
187                .field("code", &self.code)
188                .field("message", &self.message)
189                .field("source_unavailable", &self.source_unavailable)
190                .field("cleanup_source_unavailable", &self.cleanup_source_unavailable)
191                .finish()
192        }
193    }
194}
195
196impl Error for SaddleError {
197    fn source(&self) -> Option<&(dyn Error + 'static)> {
198        self.diagnostic
199            .as_deref()
200            .map(|d| d as &(dyn Error + 'static))
201    }
202}
203
204pub type Result<T> = std::result::Result<T, SaddleError>;
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210
211    #[test]
212    fn cleanup_link_keeps_existing_origin_and_missing_primary() {
213        use crate::{
214            CaptureSite, Diagnostic, DiagnosticCategory, DiagnosticCause, DiagnosticCode,
215            DiagnosticStage,
216        };
217        fn error(code: &'static str) -> SaddleError {
218            SaddleError::new(ErrorKind::Internal, code, "safe").with_diagnostic(
219                Diagnostic::capture(
220                    DiagnosticCategory::UnexpectedError,
221                    CaptureSite::FirstObserved,
222                    DiagnosticCause::new(
223                        DiagnosticStage::FinalizerResource,
224                        DiagnosticCode::new(code).unwrap(),
225                    ),
226                ),
227            )
228        }
229        let primary = error("test.primary");
230        let cleanup = error("test.cleanup");
231        let id = cleanup.diagnostic().unwrap().id();
232        let cleanup = cleanup.during_cleanup_of(&SaddleError::new(
233            ErrorKind::Internal,
234            "test.no_diagnostic",
235            "safe",
236        ));
237        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
238        let before = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
239        let cleanup = cleanup.during_cleanup_of(&primary);
240        let after = serde_json::to_value(cleanup.diagnostic().unwrap()).unwrap();
241        assert_eq!(after["origin"], before["origin"]);
242        assert_eq!(after["causes"], before["causes"]);
243        assert_eq!(
244            after["primary_diagnostic_id"],
245            primary.diagnostic().unwrap().id()
246        );
247        assert_eq!(cleanup.diagnostic().unwrap().id(), id);
248    }
249
250    #[test]
251    fn error_exposes_stable_classification() {
252        let error = SaddleError::new(ErrorKind::NotFound, "user.not_found", "user not found");
253
254        assert_eq!(error.kind(), ErrorKind::NotFound);
255        assert_eq!(error.code(), "user.not_found");
256        assert_eq!(error.to_string(), "user.not_found: user not found");
257    }
258}