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