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#[derive(Debug)]
221#[cfg_attr(feature = "serde", derive(serde::Serialize))]
222pub struct SanthError {
223 code: &'static str,
224 title: String,
225 fix: String,
226 context: Vec<(Cow<'static, str>, String)>,
227 #[cfg_attr(feature = "serde", serde(skip))]
228 source: Option<Box<dyn std::error::Error + Send + Sync>>,
229 location: Option<ErrorLocation>,
230}
231
232impl SanthError {
233 #[allow(clippy::new_ret_no_self)]
241 pub fn new(code: &'static str, title: impl Into<String>) -> SanthErrorBuilder<NoFix> {
242 SanthErrorBuilder {
243 code,
244 title: title.into(),
245 fix: None,
246 context: Vec::new(),
247 source: None,
248 location: None,
249 _state: std::marker::PhantomData,
250 }
251 }
252
253 pub fn code(&self) -> &'static str {
255 self.code
256 }
257
258 pub fn title(&self) -> &str {
260 &self.title
261 }
262
263 pub fn fix_hint(&self) -> &str {
265 &self.fix
266 }
267
268 pub fn actionable_message(&self) -> String {
278 compose_message(
279 &self.title,
280 &self.fix,
281 &self.context,
282 self.location.as_ref(),
283 self.source
284 .as_ref()
285 .map(|s| s.as_ref() as &dyn std::error::Error),
286 )
287 }
288
289 impl_diagnostic_mutators!();
290}
291
292const MAX_SOURCE_CHAIN: usize = 64;
306
307pub(crate) fn compose_message(
308 title: &str,
309 fix: &str,
310 context: &[(Cow<'static, str>, String)],
311 location: Option<&ErrorLocation>,
312 source: Option<&dyn std::error::Error>,
313) -> String {
314 let mut msg = String::with_capacity(256);
315 msg.push_str(title);
316 msg.push('\n');
317 msg.push('\n');
318 msg.push_str(fix);
319
320 if !context.is_empty() {
321 msg.push('\n');
322 msg.push('\n');
323 msg.push_str("Context:");
324 for (k, v) in context {
325 msg.push('\n');
326 msg.push_str(" ");
327 msg.push_str(k);
328 msg.push_str(": ");
329 msg.push_str(v);
330 }
331 }
332
333 if let Some(loc) = location {
334 msg.push('\n');
335 msg.push('\n');
336 msg.push_str("Location: ");
337 msg.push_str(&loc.file);
338 msg.push(':');
339 msg.push_str(&loc.line.map_or_else(|| "?".to_string(), |l| l.to_string()));
340 msg.push(':');
341 msg.push_str(
342 &loc.column
343 .map_or_else(|| "?".to_string(), |c| c.to_string()),
344 );
345 }
346
347 if let Some(source) = source {
348 msg.push('\n');
349 msg.push('\n');
350 msg.push_str("Caused by:");
351 let mut current: Option<&dyn std::error::Error> = Some(source);
352 let mut links = 0usize;
353 while let Some(err) = current {
354 if links == MAX_SOURCE_CHAIN {
355 msg.push('\n');
356 msg.push_str(" - ... (source chain truncated after 64 links)");
357 break;
358 }
359 msg.push('\n');
360 msg.push_str(" - ");
361 msg.push_str(&err.to_string());
362 current = err.source();
363 links += 1;
364 }
365 }
366
367 redact_secrets(&msg)
368}
369
370impl PartialEq for SanthError {
371 fn eq(&self, other: &Self) -> bool {
372 self.code == other.code
373 && self.title == other.title
374 && self.fix == other.fix
375 && self.context == other.context
376 && self.location == other.location
377 }
378}
379
380impl Eq for SanthError {}
381
382impl fmt::Display for SanthError {
383 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
384 write!(f, "{}", self.actionable_message())
385 }
386}
387
388impl std::error::Error for SanthError {
389 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
390 self.source
391 .as_ref()
392 .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
393 }
394}
395
396impl From<std::io::Error> for SanthError {
397 fn from(err: std::io::Error) -> Self {
398 let (code, title, fix): (&'static str, &'static str, &'static str) = match err.kind() {
399 std::io::ErrorKind::NotFound => (
400 "SANTH-IO-NOTFOUND",
401 "File or resource not found",
402 "Fix: Verify the path exists and check for typos. If the file should be created automatically, ensure the parent directory exists.",
403 ),
404 std::io::ErrorKind::PermissionDenied => (
405 "SANTH-IO-PERM",
406 "Permission denied",
407 "Fix: Check that the current user has read/write/execute permissions on the file or directory. On Unix, verify with `ls -la`.",
408 ),
409 std::io::ErrorKind::ConnectionRefused => (
410 "SANTH-IO-CONNREF",
411 "Connection refused",
412 "Fix: Ensure the target service is running and listening on the expected port. Verify firewall rules and network connectivity.",
413 ),
414 std::io::ErrorKind::ConnectionReset
415 | std::io::ErrorKind::ConnectionAborted
416 | std::io::ErrorKind::BrokenPipe => (
417 "SANTH-IO-CONNRESET",
418 "Connection reset or broken pipe",
419 "Fix: The remote peer closed the connection. Retry the operation and verify the remote service is stable.",
420 ),
421 std::io::ErrorKind::TimedOut => (
422 "SANTH-IO-TIMEOUT",
423 "I/O operation timed out",
424 "Fix: Increase the timeout duration, check network latency, or verify the remote service is responsive.",
425 ),
426 std::io::ErrorKind::AlreadyExists => (
427 "SANTH-IO-EXISTS",
428 "File or resource already exists",
429 "Fix: Remove the existing file, choose a different name, or open with overwrite/truncate flags if intended.",
430 ),
431 std::io::ErrorKind::InvalidInput => (
432 "SANTH-IO-INVAL",
433 "Invalid input parameter",
434 "Fix: Check that all arguments to the I/O operation are valid and within supported ranges.",
435 ),
436 std::io::ErrorKind::UnexpectedEof => (
437 "SANTH-IO-EOF",
438 "Unexpected end of file",
439 "Fix: The file is shorter than expected. Verify the file was written completely and was not truncated.",
440 ),
441 std::io::ErrorKind::OutOfMemory => (
442 "SANTH-IO-NOMEM",
443 "Out of memory",
444 "Fix: Reduce memory usage, process data in smaller chunks, or allocate more RAM to the process.",
445 ),
446 _ => (
447 "SANTH-IO-01",
448 "I/O operation failed",
449 "Fix: Check that the file or resource exists and that you have the correct permissions.",
450 ),
451 };
452
453 Self::new(code, title).fix(fix).with_source(err).build()
454 }
455}
456
457impl From<std::fmt::Error> for SanthError {
458 fn from(err: std::fmt::Error) -> Self {
459 Self::new("SANTH-FMT-01", "Formatting failed")
460 .fix("Fix: Ensure all format arguments implement the required Display/Debug traits and match the format string.")
461 .with_source(err)
462 .build()
463 }
464}
465
466impl From<std::string::FromUtf8Error> for SanthError {
467 fn from(err: std::string::FromUtf8Error) -> Self {
468 Self::new("SANTH-UTF8-01", "Invalid UTF-8 sequence")
469 .fix("Fix: Ensure the input is valid UTF-8, or use String::from_utf8_lossy for lossy conversion.")
470 .with_source(err)
471 .build()
472 }
473}
474
475impl From<regex::Error> for SanthError {
476 fn from(err: regex::Error) -> Self {
477 Self::new("SANTH-REGEX-01", "Regex compilation failed")
478 .fix("Fix: Verify the regex pattern syntax and ensure all special characters are properly escaped.")
479 .with_source(err)
480 .build()
481 }
482}