Skip to main content

santh_error/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![warn(clippy::pedantic)]
4#![cfg_attr(
5    not(test),
6    deny(
7        clippy::unwrap_used,
8        clippy::expect_used,
9        clippy::todo,
10        clippy::unimplemented,
11        clippy::panic
12    )
13)]
14#![allow(
15    clippy::module_name_repetitions,
16    clippy::must_use_candidate,
17    clippy::missing_errors_doc
18)]
19//! `santh-error` - the shared error type for the Santh ecosystem.
20//!
21//! The single rule: **every error has a `Fix:` hint**.
22//! No bare "parse error". No "something went wrong". Every error tells the
23//! user what to do.
24//!
25//! # Quick start
26//!
27//! ```
28//! use santh_error::SanthError;
29//!
30//! let err = SanthError::new("CFG-E001", "config file not found")
31//!     .fix("Fix: create config.toml or pass --config")
32//!     .build();
33//! assert!(err.actionable_message().contains("Fix:"));
34//! ```
35//!
36//! # Safe-defaults answers
37//!
38//! - Input size: operates only on in-memory strings supplied by the caller; it
39//!   opens no files and imposes no size cap of its own, so memory use tracks
40//!   the caller's message length.
41//! - Recursion depth: error source chains are walked iteratively, never
42//!   recursively, so a deep `source()` chain cannot overflow the stack.
43//! - Outbound network: none. The crate performs no network access.
44//! - Process spawning: none. The crate spawns no child processes.
45//! - Filesystem writes: none. The crate reads and writes no files.
46//! - Credential exposure: every rendered message is passed through
47//!   [`redact_secrets`], so tokens, JWTs, and private keys are masked as
48//!   `[REDACTED]` before they reach a log, error string, or temp file.
49
50use std::borrow::Cow;
51use std::fmt;
52use std::fmt::Display;
53
54mod contract;
55mod redact;
56pub use contract::SanthErrorContract;
57pub use redact::redact_secrets;
58
59/// Location in source or configuration where an error occurred.
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct ErrorLocation {
63    /// File or resource path.
64    pub file: String,
65    /// Line number, if known.
66    pub line: Option<u32>,
67    /// Column number, if known.
68    pub column: Option<u32>,
69}
70
71impl ErrorLocation {
72    /// Create a new [`ErrorLocation`] for the given file.
73    pub fn new(file: impl Into<String>) -> Self {
74        Self {
75            file: file.into(),
76            line: None,
77            column: None,
78        }
79    }
80
81    /// Set the line number.
82    #[must_use]
83    pub fn with_line(mut self, line: u32) -> Self {
84        self.line = Some(line);
85        self
86    }
87
88    /// Set the column number.
89    #[must_use]
90    pub fn with_column(mut self, column: u32) -> Self {
91        self.column = Some(column);
92        self
93    }
94}
95
96/// Marker type indicating the builder has not yet received a fix hint.
97#[derive(Debug)]
98pub struct NoFix;
99
100/// Marker type indicating the builder has received a fix hint.
101#[derive(Debug)]
102pub struct HasFix;
103
104/// Builder for [`SanthError`].
105///
106/// Uses a typestate pattern to enforce at compile time that `.fix()` is
107/// called before `.build()`.
108#[derive(Debug)]
109pub struct SanthErrorBuilder<State = NoFix> {
110    code: &'static str,
111    title: String,
112    fix: Option<String>,
113    context: Vec<(Cow<'static, str>, String)>,
114    source: Option<Box<dyn std::error::Error + Send + Sync>>,
115    location: Option<ErrorLocation>,
116    _state: std::marker::PhantomData<State>,
117}
118
119/// Single owner of the `with_context` / `with_source` / `with_location`
120/// diagnostic-mutator methods.
121///
122/// Both [`SanthErrorBuilder`] (during construction) and [`SanthError`]
123/// (post-build enrichment) expose the identical builder-style mutators over
124/// the same `context` / `source` / `location` fields. Rather than copy the
125/// three bodies into both impl blocks (where they could silently drift), this
126/// macro is the ONE place the semantics live; each impl invokes it once. Any
127/// change to how context is pushed, how a source is boxed, or how a location
128/// is attached happens here and propagates to both types.
129macro_rules! impl_diagnostic_mutators {
130    () => {
131        /// Add a key-value diagnostic context entry.
132        #[must_use]
133        pub fn with_context(
134            mut self,
135            key: impl Into<Cow<'static, str>>,
136            value: impl Display,
137        ) -> Self {
138            self.context.push((key.into(), value.to_string()));
139            self
140        }
141
142        /// Attach a source error to the cause chain.
143        #[must_use]
144        pub fn with_source(
145            mut self,
146            source: impl std::error::Error + Send + Sync + 'static,
147        ) -> Self {
148            self.source = Some(Box::new(source));
149            self
150        }
151
152        /// Attach a location to the error.
153        #[must_use]
154        pub fn with_location(mut self, location: ErrorLocation) -> Self {
155            self.location = Some(location);
156            self
157        }
158    };
159}
160
161impl<State> SanthErrorBuilder<State> {
162    impl_diagnostic_mutators!();
163}
164
165impl SanthErrorBuilder<NoFix> {
166    /// Provide the mandatory fix hint.
167    ///
168    /// Transitions the builder to the [`HasFix`] state, allowing `.build()`
169    /// to be called.
170    pub fn fix(self, fix: impl Into<String>) -> SanthErrorBuilder<HasFix> {
171        SanthErrorBuilder {
172            code: self.code,
173            title: self.title,
174            fix: Some(fix.into()),
175            context: self.context,
176            source: self.source,
177            location: self.location,
178            _state: std::marker::PhantomData,
179        }
180    }
181}
182
183impl SanthErrorBuilder<HasFix> {
184    /// Build the [`SanthError`].
185    ///
186    /// A fix hint that does not already start with `"Fix: "` is normalised by
187    /// prefixing it, so the built error always upholds the `"Fix: "` contract.
188    /// This never panics.
189    pub fn build(self) -> SanthError {
190        // `fix` is always `Some` here: the `HasFix` typestate is only reachable
191        // through `.fix()`, which sets it. `unwrap_or_default()` keeps the
192        // impossible branch panic-free; a hint missing the contract prefix is
193        // normalised to the "Fix: " prefix just below.
194        let fix = self.fix.unwrap_or_default();
195        let fix = if fix.starts_with("Fix: ") {
196            fix
197        } else {
198            format!("Fix: {fix}")
199        };
200        SanthError {
201            code: self.code,
202            title: self.title,
203            fix,
204            context: self.context,
205            source: self.source,
206            location: self.location,
207        }
208    }
209}
210
211/// The shared error type for the Santh ecosystem.
212///
213/// Every error carries:
214/// - A stable error `code`.
215/// - A human-readable `title`.
216/// - A **mandatory** `fix` hint starting with `"Fix: "`.
217/// - Optional diagnostic `context`.
218/// - An optional source error chain.
219/// - An optional `location` in source or configuration.
220#[derive(Debug)]
221#[cfg_attr(feature = "serde", derive(serde::Serialize))]
222pub struct SanthError {
223    code: &'static str,
224    title: String,
225    fix: String,
226    context: Vec<(Cow<'static, str>, String)>,
227    #[cfg_attr(feature = "serde", serde(skip))]
228    source: Option<Box<dyn std::error::Error + Send + Sync>>,
229    location: Option<ErrorLocation>,
230}
231
232impl SanthError {
233    /// Start building a new [`SanthError`].
234    ///
235    /// The returned builder enforces at compile time that `.fix()` is called
236    /// before `.build()`.
237    // Intentionally returns a typestate builder, not `Self`: the `.fix()`
238    // step is mandatory and enforced at compile time, so `new` cannot return
239    // a finished `SanthError`. Renaming would break the public API (LAW 2).
240    #[allow(clippy::new_ret_no_self)]
241    pub fn new(code: &'static str, title: impl Into<String>) -> SanthErrorBuilder<NoFix> {
242        SanthErrorBuilder {
243            code,
244            title: title.into(),
245            fix: None,
246            context: Vec::new(),
247            source: None,
248            location: None,
249            _state: std::marker::PhantomData,
250        }
251    }
252
253    /// Returns the stable error code, e.g. `"KEYHOG-E001"`.
254    pub fn code(&self) -> &'static str {
255        self.code
256    }
257
258    /// Returns the one-line title.
259    pub fn title(&self) -> &str {
260        &self.title
261    }
262
263    /// Returns the fix hint (including the `"Fix: "` prefix).
264    pub fn fix_hint(&self) -> &str {
265        &self.fix
266    }
267
268    /// Returns an actionable, human-readable message with all diagnostic details.
269    ///
270    /// Secrets are redacted from the output. The source chain is included
271    /// so that causal information is never lost.
272    ///
273    /// Delegates to [`compose_message`], the single formatter shared with the
274    /// default [`SanthErrorContract::actionable_message`], so the canonical
275    /// type and any domain error enum that implements the contract render
276    /// byte-identically.
277    pub fn actionable_message(&self) -> String {
278        compose_message(
279            &self.title,
280            &self.fix,
281            &self.context,
282            self.location.as_ref(),
283            self.source
284                .as_ref()
285                .map(|s| s.as_ref() as &dyn std::error::Error),
286        )
287    }
288
289    impl_diagnostic_mutators!();
290}
291
292/// Render the canonical Santh actionable message from its parts.
293///
294/// This is the single formatter shared by [`SanthError::actionable_message`]
295/// and the default [`SanthErrorContract::actionable_message`], so the concrete
296/// error type and any domain error enum that implements the contract produce
297/// byte-identical output. Secrets are redacted last, after the full message
298/// (title, fix, context, location, and source chain) is assembled.
299/// Maximum number of `source()` links rendered in a "Caused by" chain. A
300/// custom error type can return itself (or a very long chain) from
301/// [`std::error::Error::source`]; walking that without a bound would loop
302/// forever while the message string grows without limit. Past the cap the
303/// chain is cut with an explicit truncation marker, so the diagnostic loss is
304/// visible instead of silent.
305const MAX_SOURCE_CHAIN: usize = 64;
306
307pub(crate) fn compose_message(
308    title: &str,
309    fix: &str,
310    context: &[(Cow<'static, str>, String)],
311    location: Option<&ErrorLocation>,
312    source: Option<&dyn std::error::Error>,
313) -> String {
314    let mut msg = String::with_capacity(256);
315    msg.push_str(title);
316    msg.push('\n');
317    msg.push('\n');
318    msg.push_str(fix);
319
320    if !context.is_empty() {
321        msg.push('\n');
322        msg.push('\n');
323        msg.push_str("Context:");
324        for (k, v) in context {
325            msg.push('\n');
326            msg.push_str("  ");
327            msg.push_str(k);
328            msg.push_str(": ");
329            msg.push_str(v);
330        }
331    }
332
333    if let Some(loc) = location {
334        msg.push('\n');
335        msg.push('\n');
336        msg.push_str("Location: ");
337        msg.push_str(&loc.file);
338        msg.push(':');
339        msg.push_str(&loc.line.map_or_else(|| "?".to_string(), |l| l.to_string()));
340        msg.push(':');
341        msg.push_str(
342            &loc.column
343                .map_or_else(|| "?".to_string(), |c| c.to_string()),
344        );
345    }
346
347    if let Some(source) = source {
348        msg.push('\n');
349        msg.push('\n');
350        msg.push_str("Caused by:");
351        let mut current: Option<&dyn std::error::Error> = Some(source);
352        let mut links = 0usize;
353        while let Some(err) = current {
354            if links == MAX_SOURCE_CHAIN {
355                msg.push('\n');
356                msg.push_str("  - ... (source chain truncated after 64 links)");
357                break;
358            }
359            msg.push('\n');
360            msg.push_str("  - ");
361            msg.push_str(&err.to_string());
362            current = err.source();
363            links += 1;
364        }
365    }
366
367    redact_secrets(&msg)
368}
369
370impl PartialEq for SanthError {
371    fn eq(&self, other: &Self) -> bool {
372        self.code == other.code
373            && self.title == other.title
374            && self.fix == other.fix
375            && self.context == other.context
376            && self.location == other.location
377    }
378}
379
380impl Eq for SanthError {}
381
382impl fmt::Display for SanthError {
383    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384        write!(f, "{}", self.actionable_message())
385    }
386}
387
388impl std::error::Error for SanthError {
389    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
390        self.source
391            .as_ref()
392            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
393    }
394}
395
396impl From<std::io::Error> for SanthError {
397    fn from(err: std::io::Error) -> Self {
398        let (code, title, fix): (&'static str, &'static str, &'static str) = match err.kind() {
399            std::io::ErrorKind::NotFound => (
400                "SANTH-IO-NOTFOUND",
401                "File or resource not found",
402                "Fix: Verify the path exists and check for typos. If the file should be created automatically, ensure the parent directory exists.",
403            ),
404            std::io::ErrorKind::PermissionDenied => (
405                "SANTH-IO-PERM",
406                "Permission denied",
407                "Fix: Check that the current user has read/write/execute permissions on the file or directory. On Unix, verify with `ls -la`.",
408            ),
409            std::io::ErrorKind::ConnectionRefused => (
410                "SANTH-IO-CONNREF",
411                "Connection refused",
412                "Fix: Ensure the target service is running and listening on the expected port. Verify firewall rules and network connectivity.",
413            ),
414            std::io::ErrorKind::ConnectionReset
415            | std::io::ErrorKind::ConnectionAborted
416            | std::io::ErrorKind::BrokenPipe => (
417                "SANTH-IO-CONNRESET",
418                "Connection reset or broken pipe",
419                "Fix: The remote peer closed the connection. Retry the operation and verify the remote service is stable.",
420            ),
421            std::io::ErrorKind::TimedOut => (
422                "SANTH-IO-TIMEOUT",
423                "I/O operation timed out",
424                "Fix: Increase the timeout duration, check network latency, or verify the remote service is responsive.",
425            ),
426            std::io::ErrorKind::AlreadyExists => (
427                "SANTH-IO-EXISTS",
428                "File or resource already exists",
429                "Fix: Remove the existing file, choose a different name, or open with overwrite/truncate flags if intended.",
430            ),
431            std::io::ErrorKind::InvalidInput => (
432                "SANTH-IO-INVAL",
433                "Invalid input parameter",
434                "Fix: Check that all arguments to the I/O operation are valid and within supported ranges.",
435            ),
436            std::io::ErrorKind::UnexpectedEof => (
437                "SANTH-IO-EOF",
438                "Unexpected end of file",
439                "Fix: The file is shorter than expected. Verify the file was written completely and was not truncated.",
440            ),
441            std::io::ErrorKind::OutOfMemory => (
442                "SANTH-IO-NOMEM",
443                "Out of memory",
444                "Fix: Reduce memory usage, process data in smaller chunks, or allocate more RAM to the process.",
445            ),
446            _ => (
447                "SANTH-IO-01",
448                "I/O operation failed",
449                "Fix: Check that the file or resource exists and that you have the correct permissions.",
450            ),
451        };
452
453        Self::new(code, title).fix(fix).with_source(err).build()
454    }
455}
456
457impl From<std::fmt::Error> for SanthError {
458    fn from(err: std::fmt::Error) -> Self {
459        Self::new("SANTH-FMT-01", "Formatting failed")
460            .fix("Fix: Ensure all format arguments implement the required Display/Debug traits and match the format string.")
461            .with_source(err)
462            .build()
463    }
464}
465
466impl From<std::string::FromUtf8Error> for SanthError {
467    fn from(err: std::string::FromUtf8Error) -> Self {
468        Self::new("SANTH-UTF8-01", "Invalid UTF-8 sequence")
469            .fix("Fix: Ensure the input is valid UTF-8, or use String::from_utf8_lossy for lossy conversion.")
470            .with_source(err)
471            .build()
472    }
473}
474
475impl From<regex::Error> for SanthError {
476    fn from(err: regex::Error) -> Self {
477        Self::new("SANTH-REGEX-01", "Regex compilation failed")
478            .fix("Fix: Verify the regex pattern syntax and ensure all special characters are properly escaped.")
479            .with_source(err)
480            .build()
481    }
482}