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 = 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#[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 #[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 pub fn code(&self) -> &'static str {
284 self.code
285 }
286
287 pub fn title(&self) -> &str {
289 &self.title
290 }
291
292 pub fn fix_hint(&self) -> &str {
294 &self.fix
295 }
296
297 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
321const 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#[cfg(doctest)]
529#[doc = include_str!("../README.md")]
530mod readme {}