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 = normalise_fix(&fix);
196        SanthError {
197            code: self.code,
198            title: self.title,
199            fix,
200            context: self.context,
201            source: self.source,
202            location: self.location,
203        }
204    }
205}
206
207/// The shared error type for the Santh ecosystem.
208///
209/// Every error carries:
210/// - A stable error `code`.
211/// - A human-readable `title`.
212/// - A **mandatory** `fix` hint starting with `"Fix: "`.
213/// - Optional diagnostic `context`.
214/// - An optional source error chain.
215/// - An optional `location` in source or configuration.
216#[cfg_attr(feature = "serde", derive(serde::Serialize))]
217pub struct SanthError {
218    code: &'static str,
219    title: String,
220    fix: String,
221    context: Vec<(Cow<'static, str>, String)>,
222    #[cfg_attr(feature = "serde", serde(skip))]
223    source: Option<Box<dyn std::error::Error + Send + Sync>>,
224    location: Option<ErrorLocation>,
225}
226
227impl fmt::Debug for SanthError {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        let redacted_title = redact_secrets(&self.title);
230        let redacted_fix = redact_secrets(&self.fix);
231        let redacted_context: Vec<(Cow<'static, str>, String)> = self
232            .context
233            .iter()
234            .map(|(k, v)| (redact_secrets(k).into(), redact_secrets(v)))
235            .collect();
236        let redacted_location = self.location.as_ref().map(|loc| ErrorLocation {
237            file: redact_secrets(&loc.file),
238            line: loc.line,
239            column: loc.column,
240        });
241        let redacted_source = self
242            .source
243            .as_ref()
244            .map(|src| redact_secrets(&format!("{src:?}")));
245
246        let mut ds = f.debug_struct("SanthError");
247        ds.field("code", &self.code);
248        ds.field("title", &redacted_title);
249        ds.field("fix", &redacted_fix);
250        ds.field("context", &redacted_context);
251        ds.field("source", &redacted_source);
252        ds.field("location", &redacted_location);
253        ds.finish()
254    }
255}
256
257impl SanthError {
258    /// Start building a new [`SanthError`].
259    ///
260    /// The returned builder enforces at compile time that `.fix()` is called
261    /// before `.build()`.
262    // Intentionally returns a typestate builder, not `Self`: the `.fix()`
263    // step is mandatory and enforced at compile time, so `new` cannot return
264    // a finished `SanthError`. Renaming would break the public API (LAW 2).
265    #[allow(clippy::new_ret_no_self)]
266    pub fn new(code: &'static str, title: impl Into<String>) -> SanthErrorBuilder<NoFix> {
267        SanthErrorBuilder {
268            code,
269            title: title.into(),
270            fix: None,
271            context: Vec::new(),
272            source: None,
273            location: None,
274            _state: std::marker::PhantomData,
275        }
276    }
277
278    /// Returns the stable error code, e.g. `"KEYHOG-E001"`.
279    pub fn code(&self) -> &'static str {
280        self.code
281    }
282
283    /// Returns the one-line title.
284    pub fn title(&self) -> &str {
285        &self.title
286    }
287
288    /// Returns the fix hint (including the `"Fix: "` prefix).
289    pub fn fix_hint(&self) -> &str {
290        &self.fix
291    }
292
293    /// Returns an actionable, human-readable message with all diagnostic details.
294    ///
295    /// Secrets are redacted from the output. The source chain is included
296    /// so that causal information is never lost.
297    ///
298    /// Delegates to [`compose_message`], the single formatter shared with the
299    /// default [`SanthErrorContract::actionable_message`], so the canonical
300    /// type and any domain error enum that implements the contract render
301    /// byte-identically.
302    pub fn actionable_message(&self) -> String {
303        compose_message(
304            &self.title,
305            &self.fix,
306            &self.context,
307            self.location.as_ref(),
308            self.source
309                .as_ref()
310                .map(|s| s.as_ref() as &dyn std::error::Error),
311        )
312    }
313
314    impl_diagnostic_mutators!();
315}
316
317pub(crate) fn normalise_fix(fix: &str) -> String {
318    let trimmed = fix.trim();
319    if trimmed.starts_with("Fix: ") {
320        trimmed.to_string()
321    } else if let Some(rest) = trimmed.strip_prefix("Fix:") {
322        format!("Fix: {}", rest.trim_start())
323    } else if let Some(rest) = trimmed.strip_prefix("fix:") {
324        format!("Fix: {}", rest.trim_start())
325    } else if let Some(rest) = trimmed.strip_prefix("FIX:") {
326        format!("Fix: {}", rest.trim_start())
327    } else {
328        format!("Fix: {trimmed}")
329    }
330}
331
332/// Render the canonical Santh actionable message from its parts.
333///
334/// This is the single formatter shared by [`SanthError::actionable_message`]
335/// and the default [`SanthErrorContract::actionable_message`], so the concrete
336/// error type and any domain error enum that implements the contract produce
337/// byte-identical output. Secrets are redacted last, after the full message
338/// (title, fix, context, location, and source chain) is assembled.
339/// Maximum number of `source()` links rendered in a "Caused by" chain. A
340/// custom error type can return itself (or a very long chain) from
341/// [`std::error::Error::source`]; walking that without a bound would loop
342/// forever while the message string grows without limit. Past the cap the
343/// chain is cut with an explicit truncation marker, so the diagnostic loss is
344/// visible instead of silent.
345const MAX_SOURCE_CHAIN: usize = 64;
346
347pub(crate) fn compose_message(
348    title: &str,
349    fix: &str,
350    context: &[(Cow<'static, str>, String)],
351    location: Option<&ErrorLocation>,
352    source: Option<&dyn std::error::Error>,
353) -> String {
354    let mut msg = String::with_capacity(256);
355    msg.push_str(title);
356    msg.push('\n');
357    msg.push('\n');
358
359    let fix_normalised = normalise_fix(fix);
360    msg.push_str(&fix_normalised);
361
362    if !context.is_empty() {
363        msg.push('\n');
364        msg.push('\n');
365        msg.push_str("Context:");
366        for (k, v) in context {
367            msg.push('\n');
368            msg.push_str("  ");
369            msg.push_str(k);
370            msg.push_str(": ");
371            msg.push_str(v);
372        }
373    }
374
375    if let Some(loc) = location {
376        msg.push('\n');
377        msg.push('\n');
378        msg.push_str("Location: ");
379        msg.push_str(&loc.file);
380        msg.push(':');
381        msg.push_str(&loc.line.map_or_else(|| "?".to_string(), |l| l.to_string()));
382        msg.push(':');
383        msg.push_str(
384            &loc.column
385                .map_or_else(|| "?".to_string(), |c| c.to_string()),
386        );
387    }
388
389    if let Some(source) = source {
390        msg.push('\n');
391        msg.push('\n');
392        msg.push_str("Caused by:");
393        let mut current: Option<&dyn std::error::Error> = Some(source);
394        let mut links = 0usize;
395        while let Some(err) = current {
396            if links == MAX_SOURCE_CHAIN {
397                msg.push('\n');
398                msg.push_str("  - ... (source chain truncated after 64 links)");
399                break;
400            }
401            msg.push('\n');
402            msg.push_str("  - ");
403            let err_str = err.to_string();
404            if err_str.trim().is_empty() {
405                msg.push_str("(empty error message)");
406            } else {
407                msg.push_str(&err_str.replace('\n', "\n    "));
408            }
409            current = err.source();
410            links += 1;
411        }
412    }
413
414    redact_secrets(&msg)
415}
416
417impl PartialEq for SanthError {
418    fn eq(&self, other: &Self) -> bool {
419        self.code == other.code
420            && self.title == other.title
421            && self.fix == other.fix
422            && self.context == other.context
423            && self.location == other.location
424    }
425}
426
427impl Eq for SanthError {}
428
429impl fmt::Display for SanthError {
430    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
431        write!(f, "{}", self.actionable_message())
432    }
433}
434
435impl std::error::Error for SanthError {
436    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
437        self.source
438            .as_ref()
439            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
440    }
441}
442
443impl From<std::io::Error> for SanthError {
444    fn from(err: std::io::Error) -> Self {
445        let (code, title, fix): (&'static str, &'static str, &'static str) = match err.kind() {
446            std::io::ErrorKind::NotFound => (
447                "SANTH-IO-NOTFOUND",
448                "File or resource not found",
449                "Fix: Verify the path exists and check for typos. If the file should be created automatically, ensure the parent directory exists.",
450            ),
451            std::io::ErrorKind::PermissionDenied => (
452                "SANTH-IO-PERM",
453                "Permission denied",
454                "Fix: Check that the current user has read/write/execute permissions on the file or directory. On Unix, verify with `ls -la`.",
455            ),
456            std::io::ErrorKind::ConnectionRefused => (
457                "SANTH-IO-CONNREF",
458                "Connection refused",
459                "Fix: Ensure the target service is running and listening on the expected port. Verify firewall rules and network connectivity.",
460            ),
461            std::io::ErrorKind::ConnectionReset
462            | std::io::ErrorKind::ConnectionAborted
463            | std::io::ErrorKind::BrokenPipe => (
464                "SANTH-IO-CONNRESET",
465                "Connection reset or broken pipe",
466                "Fix: The remote peer closed the connection. Retry the operation and verify the remote service is stable.",
467            ),
468            std::io::ErrorKind::TimedOut => (
469                "SANTH-IO-TIMEOUT",
470                "I/O operation timed out",
471                "Fix: Increase the timeout duration, check network latency, or verify the remote service is responsive.",
472            ),
473            std::io::ErrorKind::AlreadyExists => (
474                "SANTH-IO-EXISTS",
475                "File or resource already exists",
476                "Fix: Remove the existing file, choose a different name, or open with overwrite/truncate flags if intended.",
477            ),
478            std::io::ErrorKind::InvalidInput => (
479                "SANTH-IO-INVAL",
480                "Invalid input parameter",
481                "Fix: Check that all arguments to the I/O operation are valid and within supported ranges.",
482            ),
483            std::io::ErrorKind::InvalidData => (
484                "SANTH-IO-INVALDATA",
485                "Invalid data encountered",
486                "Fix: Ensure the input data matches the expected format and schema, and is not corrupted.",
487            ),
488            std::io::ErrorKind::WouldBlock => (
489                "SANTH-IO-WOULDBLOCK",
490                "Operation would block",
491                "Fix: Retry the operation when the resource is ready or switch to asynchronous non-blocking I/O.",
492            ),
493            std::io::ErrorKind::AddrInUse => (
494                "SANTH-IO-ADDRINUSE",
495                "Address already in use",
496                "Fix: Choose a different port or network address, or terminate the process currently holding the address.",
497            ),
498            std::io::ErrorKind::AddrNotAvailable => (
499                "SANTH-IO-ADDRNOTAVAIL",
500                "Address not available",
501                "Fix: Verify the local network interface and IP address configuration.",
502            ),
503            std::io::ErrorKind::ReadOnlyFilesystem => (
504                "SANTH-IO-ROFS",
505                "Read-only file system",
506                "Fix: Remount the file system with write permissions or write the file to a writable location.",
507            ),
508            std::io::ErrorKind::UnexpectedEof => (
509                "SANTH-IO-EOF",
510                "Unexpected end of file",
511                "Fix: The file is shorter than expected. Verify the file was written completely and was not truncated.",
512            ),
513            std::io::ErrorKind::OutOfMemory => (
514                "SANTH-IO-NOMEM",
515                "Out of memory",
516                "Fix: Reduce memory usage, process data in smaller chunks, or allocate more RAM to the process.",
517            ),
518            _ => (
519                "SANTH-IO-01",
520                "I/O operation failed",
521                "Fix: Check that the file or resource exists and that you have the correct permissions.",
522            ),
523        };
524
525        Self::new(code, title).fix(fix).with_source(err).build()
526    }
527}
528
529impl From<std::fmt::Error> for SanthError {
530    fn from(err: std::fmt::Error) -> Self {
531        Self::new("SANTH-FMT-01", "Formatting failed")
532            .fix("Fix: Ensure all format arguments implement the required Display/Debug traits and match the format string.")
533            .with_source(err)
534            .build()
535    }
536}
537
538impl From<std::string::FromUtf8Error> for SanthError {
539    fn from(err: std::string::FromUtf8Error) -> Self {
540        Self::new("SANTH-UTF8-01", "Invalid UTF-8 sequence")
541            .fix("Fix: Ensure the input is valid UTF-8, or use String::from_utf8_lossy for lossy conversion.")
542            .with_source(err)
543            .build()
544    }
545}
546
547impl From<regex::Error> for SanthError {
548    fn from(err: regex::Error) -> Self {
549        Self::new("SANTH-REGEX-01", "Regex compilation failed")
550            .fix("Fix: Verify the regex pattern syntax and ensure all special characters are properly escaped.")
551            .with_source(err)
552            .build()
553    }
554}
555
556// Rung 7 (contract): the README quick-start is a doctest, so a README example
557// that drifts from the real API fails `cargo test` instead of misleading users.
558#[cfg(doctest)]
559#[doc = include_str!("../README.md")]
560mod readme {}