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
119impl<State> SanthErrorBuilder<State> {
120    /// Add a key-value diagnostic context entry.
121    #[must_use]
122    pub fn with_context(mut self, key: impl Into<Cow<'static, str>>, value: impl Display) -> Self {
123        self.context.push((key.into(), value.to_string()));
124        self
125    }
126
127    /// Attach a source error to the cause chain.
128    #[must_use]
129    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
130        self.source = Some(Box::new(source));
131        self
132    }
133
134    /// Attach a location to the error.
135    #[must_use]
136    pub fn with_location(mut self, location: ErrorLocation) -> Self {
137        self.location = Some(location);
138        self
139    }
140}
141
142impl SanthErrorBuilder<NoFix> {
143    /// Provide the mandatory fix hint.
144    ///
145    /// Transitions the builder to the [`HasFix`] state, allowing `.build()`
146    /// to be called.
147    pub fn fix(self, fix: impl Into<String>) -> SanthErrorBuilder<HasFix> {
148        SanthErrorBuilder {
149            code: self.code,
150            title: self.title,
151            fix: Some(fix.into()),
152            context: self.context,
153            source: self.source,
154            location: self.location,
155            _state: std::marker::PhantomData,
156        }
157    }
158}
159
160impl SanthErrorBuilder<HasFix> {
161    /// Build the [`SanthError`].
162    ///
163    /// # Panics
164    ///
165    /// Panics in debug builds if the fix hint does not start with `"Fix: "`.
166    /// In release builds, a malformed hint is prefixed automatically to
167    /// uphold the contract without crashing production.
168    pub fn build(self) -> SanthError {
169        // `fix` is always `Some` here: the `HasFix` typestate is only reachable
170        // through `.fix()`, which sets it. `unwrap_or_default()` keeps the
171        // impossible branch panic-free; an empty hint is normalised to the
172        // "Fix: " contract prefix just below.
173        let fix = self.fix.unwrap_or_default();
174        let fix = if fix.starts_with("Fix: ") {
175            fix
176        } else {
177            debug_assert!(
178                fix.starts_with("Fix: "),
179                "Fix: hint must start with 'Fix: ', got: {fix}"
180            );
181            format!("Fix: {fix}")
182        };
183        SanthError {
184            code: self.code,
185            title: self.title,
186            fix,
187            context: self.context,
188            source: self.source,
189            location: self.location,
190        }
191    }
192}
193
194/// The shared error type for the Santh ecosystem.
195///
196/// Every error carries:
197/// - A stable error `code`.
198/// - A human-readable `title`.
199/// - A **mandatory** `fix` hint starting with `"Fix: "`.
200/// - Optional diagnostic `context`.
201/// - An optional source error chain.
202/// - An optional `location` in source or configuration.
203#[derive(Debug)]
204#[cfg_attr(feature = "serde", derive(serde::Serialize))]
205pub struct SanthError {
206    code: &'static str,
207    title: String,
208    fix: String,
209    context: Vec<(Cow<'static, str>, String)>,
210    #[cfg_attr(feature = "serde", serde(skip))]
211    source: Option<Box<dyn std::error::Error + Send + Sync>>,
212    location: Option<ErrorLocation>,
213}
214
215impl SanthError {
216    /// Start building a new [`SanthError`].
217    ///
218    /// The returned builder enforces at compile time that `.fix()` is called
219    /// before `.build()`.
220    // Intentionally returns a typestate builder, not `Self`: the `.fix()`
221    // step is mandatory and enforced at compile time, so `new` cannot return
222    // a finished `SanthError`. Renaming would break the public API (LAW 2).
223    #[allow(clippy::new_ret_no_self)]
224    pub fn new(code: &'static str, title: impl Into<String>) -> SanthErrorBuilder<NoFix> {
225        SanthErrorBuilder {
226            code,
227            title: title.into(),
228            fix: None,
229            context: Vec::new(),
230            source: None,
231            location: None,
232            _state: std::marker::PhantomData,
233        }
234    }
235
236    /// Returns the stable error code, e.g. `"KEYHOG-E001"`.
237    pub fn code(&self) -> &'static str {
238        self.code
239    }
240
241    /// Returns the one-line title.
242    pub fn title(&self) -> &str {
243        &self.title
244    }
245
246    /// Returns the fix hint (including the `"Fix: "` prefix).
247    pub fn fix_hint(&self) -> &str {
248        &self.fix
249    }
250
251    /// Returns an actionable, human-readable message with all diagnostic details.
252    ///
253    /// Secrets are redacted from the output. The source chain is included
254    /// so that causal information is never lost.
255    ///
256    /// Delegates to [`compose_message`], the single formatter shared with the
257    /// default [`SanthErrorContract::actionable_message`], so the canonical
258    /// type and any domain error enum that implements the contract render
259    /// byte-identically.
260    pub fn actionable_message(&self) -> String {
261        compose_message(
262            &self.title,
263            &self.fix,
264            &self.context,
265            self.location.as_ref(),
266            self.source
267                .as_ref()
268                .map(|s| s.as_ref() as &dyn std::error::Error),
269        )
270    }
271
272    /// Add a key-value diagnostic context entry.
273    #[must_use]
274    pub fn with_context(mut self, key: impl Into<Cow<'static, str>>, value: impl Display) -> Self {
275        self.context.push((key.into(), value.to_string()));
276        self
277    }
278
279    /// Attach a source error to the cause chain.
280    #[must_use]
281    pub fn with_source(mut self, source: impl std::error::Error + Send + Sync + 'static) -> Self {
282        self.source = Some(Box::new(source));
283        self
284    }
285
286    /// Attach a location to the error.
287    #[must_use]
288    pub fn with_location(mut self, location: ErrorLocation) -> Self {
289        self.location = Some(location);
290        self
291    }
292}
293
294/// Render the canonical Santh actionable message from its parts.
295///
296/// This is the single formatter shared by [`SanthError::actionable_message`]
297/// and the default [`SanthErrorContract::actionable_message`], so the concrete
298/// error type and any domain error enum that implements the contract produce
299/// byte-identical output. Secrets are redacted last, after the full message
300/// (title, fix, context, location, and source chain) is assembled.
301pub(crate) fn compose_message(
302    title: &str,
303    fix: &str,
304    context: &[(Cow<'static, str>, String)],
305    location: Option<&ErrorLocation>,
306    source: Option<&dyn std::error::Error>,
307) -> String {
308    let mut msg = String::with_capacity(256);
309    msg.push_str(title);
310    msg.push('\n');
311    msg.push('\n');
312    msg.push_str(fix);
313
314    if !context.is_empty() {
315        msg.push('\n');
316        msg.push('\n');
317        msg.push_str("Context:");
318        for (k, v) in context {
319            msg.push('\n');
320            msg.push_str("  ");
321            msg.push_str(k);
322            msg.push_str(": ");
323            msg.push_str(v);
324        }
325    }
326
327    if let Some(loc) = location {
328        msg.push('\n');
329        msg.push('\n');
330        msg.push_str("Location: ");
331        msg.push_str(&loc.file);
332        msg.push(':');
333        msg.push_str(&loc.line.map_or_else(|| "?".to_string(), |l| l.to_string()));
334        msg.push(':');
335        msg.push_str(
336            &loc.column
337                .map_or_else(|| "?".to_string(), |c| c.to_string()),
338        );
339    }
340
341    if let Some(source) = source {
342        msg.push('\n');
343        msg.push('\n');
344        msg.push_str("Caused by:");
345        let mut current: Option<&dyn std::error::Error> = Some(source);
346        while let Some(err) = current {
347            msg.push('\n');
348            msg.push_str("  - ");
349            msg.push_str(&err.to_string());
350            current = err.source();
351        }
352    }
353
354    redact_secrets(&msg)
355}
356
357impl PartialEq for SanthError {
358    fn eq(&self, other: &Self) -> bool {
359        self.code == other.code
360            && self.title == other.title
361            && self.fix == other.fix
362            && self.context == other.context
363            && self.location == other.location
364    }
365}
366
367impl Eq for SanthError {}
368
369impl fmt::Display for SanthError {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        write!(f, "{}", self.actionable_message())
372    }
373}
374
375impl std::error::Error for SanthError {
376    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
377        self.source
378            .as_ref()
379            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
380    }
381}
382
383impl From<std::io::Error> for SanthError {
384    fn from(err: std::io::Error) -> Self {
385        let (code, title, fix): (&'static str, &'static str, &'static str) = match err.kind() {
386            std::io::ErrorKind::NotFound => (
387                "SANTH-IO-NOTFOUND",
388                "File or resource not found",
389                "Fix: Verify the path exists and check for typos. If the file should be created automatically, ensure the parent directory exists.",
390            ),
391            std::io::ErrorKind::PermissionDenied => (
392                "SANTH-IO-PERM",
393                "Permission denied",
394                "Fix: Check that the current user has read/write/execute permissions on the file or directory. On Unix, verify with `ls -la`.",
395            ),
396            std::io::ErrorKind::ConnectionRefused => (
397                "SANTH-IO-CONNREF",
398                "Connection refused",
399                "Fix: Ensure the target service is running and listening on the expected port. Verify firewall rules and network connectivity.",
400            ),
401            std::io::ErrorKind::ConnectionReset
402            | std::io::ErrorKind::ConnectionAborted
403            | std::io::ErrorKind::BrokenPipe => (
404                "SANTH-IO-CONNRESET",
405                "Connection reset or broken pipe",
406                "Fix: The remote peer closed the connection. Retry the operation and verify the remote service is stable.",
407            ),
408            std::io::ErrorKind::TimedOut => (
409                "SANTH-IO-TIMEOUT",
410                "I/O operation timed out",
411                "Fix: Increase the timeout duration, check network latency, or verify the remote service is responsive.",
412            ),
413            std::io::ErrorKind::AlreadyExists => (
414                "SANTH-IO-EXISTS",
415                "File or resource already exists",
416                "Fix: Remove the existing file, choose a different name, or open with overwrite/truncate flags if intended.",
417            ),
418            std::io::ErrorKind::InvalidInput => (
419                "SANTH-IO-INVAL",
420                "Invalid input parameter",
421                "Fix: Check that all arguments to the I/O operation are valid and within supported ranges.",
422            ),
423            std::io::ErrorKind::UnexpectedEof => (
424                "SANTH-IO-EOF",
425                "Unexpected end of file",
426                "Fix: The file is shorter than expected. Verify the file was written completely and was not truncated.",
427            ),
428            std::io::ErrorKind::OutOfMemory => (
429                "SANTH-IO-NOMEM",
430                "Out of memory",
431                "Fix: Reduce memory usage, process data in smaller chunks, or allocate more RAM to the process.",
432            ),
433            _ => (
434                "SANTH-IO-01",
435                "I/O operation failed",
436                "Fix: Check that the file or resource exists and that you have the correct permissions.",
437            ),
438        };
439
440        Self::new(code, title).fix(fix).with_source(err).build()
441    }
442}
443
444impl From<std::fmt::Error> for SanthError {
445    fn from(err: std::fmt::Error) -> Self {
446        Self::new("SANTH-FMT-01", "Formatting failed")
447            .fix("Fix: Ensure all format arguments implement the required Display/Debug traits and match the format string.")
448            .with_source(err)
449            .build()
450    }
451}
452
453impl From<std::string::FromUtf8Error> for SanthError {
454    fn from(err: std::string::FromUtf8Error) -> Self {
455        Self::new("SANTH-UTF8-01", "Invalid UTF-8 sequence")
456            .fix("Fix: Ensure the input is valid UTF-8, or use String::from_utf8_lossy for lossy conversion.")
457            .with_source(err)
458            .build()
459    }
460}
461
462impl From<regex::Error> for SanthError {
463    fn from(err: regex::Error) -> Self {
464        Self::new("SANTH-REGEX-01", "Regex compilation failed")
465            .fix("Fix: Verify the regex pattern syntax and ensure all special characters are properly escaped.")
466            .with_source(err)
467            .build()
468    }
469}