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)]
19use 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#[derive(Debug, Clone, PartialEq, Eq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct ErrorLocation {
63 pub file: String,
65 pub line: Option<u32>,
67 pub column: Option<u32>,
69}
70
71impl ErrorLocation {
72 pub fn new(file: impl Into<String>) -> Self {
74 Self {
75 file: file.into(),
76 line: None,
77 column: None,
78 }
79 }
80
81 #[must_use]
83 pub fn with_line(mut self, line: u32) -> Self {
84 self.line = Some(line);
85 self
86 }
87
88 #[must_use]
90 pub fn with_column(mut self, column: u32) -> Self {
91 self.column = Some(column);
92 self
93 }
94}
95
96#[derive(Debug)]
98pub struct NoFix;
99
100#[derive(Debug)]
102pub struct HasFix;
103
104#[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
119macro_rules! impl_diagnostic_mutators {
130 () => {
131 #[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 #[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 #[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 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 pub fn build(self) -> SanthError {
190 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#[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 #[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 pub fn code(&self) -> &'static str {
280 self.code
281 }
282
283 pub fn title(&self) -> &str {
285 &self.title
286 }
287
288 pub fn fix_hint(&self) -> &str {
290 &self.fix
291 }
292
293 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
332const 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#[cfg(doctest)]
559#[doc = include_str!("../README.md")]
560mod readme {}