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
119impl<State> SanthErrorBuilder<State> {
120 #[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 #[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 #[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 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 pub fn build(self) -> SanthError {
169 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#[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 #[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 pub fn code(&self) -> &'static str {
238 self.code
239 }
240
241 pub fn title(&self) -> &str {
243 &self.title
244 }
245
246 pub fn fix_hint(&self) -> &str {
248 &self.fix
249 }
250
251 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 #[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 #[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 #[must_use]
288 pub fn with_location(mut self, location: ErrorLocation) -> Self {
289 self.location = Some(location);
290 self
291 }
292}
293
294pub(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}