#![forbid(unsafe_code)]
#![warn(missing_docs)]
#![warn(clippy::pedantic)]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::todo,
clippy::unimplemented,
clippy::panic
)
)]
#![allow(
clippy::module_name_repetitions,
clippy::must_use_candidate,
clippy::missing_errors_doc
)]
use std::borrow::Cow;
use std::fmt;
use std::fmt::Display;
mod contract;
mod redact;
pub use contract::SanthErrorContract;
pub use redact::redact_secrets;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ErrorLocation {
pub file: String,
pub line: Option<u32>,
pub column: Option<u32>,
}
impl ErrorLocation {
pub fn new(file: impl Into<String>) -> Self {
Self {
file: file.into(),
line: None,
column: None,
}
}
#[must_use]
pub fn with_line(mut self, line: u32) -> Self {
self.line = Some(line);
self
}
#[must_use]
pub fn with_column(mut self, column: u32) -> Self {
self.column = Some(column);
self
}
}
#[derive(Debug)]
pub struct NoFix;
#[derive(Debug)]
pub struct HasFix;
#[derive(Debug)]
pub struct SanthErrorBuilder<State = NoFix> {
code: &'static str,
title: String,
fix: Option<String>,
context: Vec<(Cow<'static, str>, String)>,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
location: Option<ErrorLocation>,
_state: std::marker::PhantomData<State>,
}
macro_rules! impl_diagnostic_mutators {
() => {
#[must_use]
pub fn with_context(
mut self,
key: impl Into<Cow<'static, str>>,
value: impl Display,
) -> Self {
self.context.push((key.into(), value.to_string()));
self
}
#[must_use]
pub fn with_source(
mut self,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
self.source = Some(Box::new(source));
self
}
#[must_use]
pub fn with_location(mut self, location: ErrorLocation) -> Self {
self.location = Some(location);
self
}
};
}
impl<State> SanthErrorBuilder<State> {
impl_diagnostic_mutators!();
}
impl SanthErrorBuilder<NoFix> {
pub fn fix(self, fix: impl Into<String>) -> SanthErrorBuilder<HasFix> {
SanthErrorBuilder {
code: self.code,
title: self.title,
fix: Some(fix.into()),
context: self.context,
source: self.source,
location: self.location,
_state: std::marker::PhantomData,
}
}
}
impl SanthErrorBuilder<HasFix> {
pub fn build(self) -> SanthError {
let fix = self.fix.unwrap_or_default();
let fix = if fix.starts_with("Fix: ") {
fix
} else {
format!("Fix: {fix}")
};
SanthError {
code: self.code,
title: self.title,
fix,
context: self.context,
source: self.source,
location: self.location,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SanthError {
code: &'static str,
title: String,
fix: String,
context: Vec<(Cow<'static, str>, String)>,
#[cfg_attr(feature = "serde", serde(skip))]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
location: Option<ErrorLocation>,
}
impl fmt::Debug for SanthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let redacted_title = redact_secrets(&self.title);
let redacted_fix = redact_secrets(&self.fix);
let redacted_context: Vec<(Cow<'static, str>, String)> = self
.context
.iter()
.map(|(k, v)| (k.clone(), redact_secrets(v)))
.collect();
let redacted_location = self.location.as_ref().map(|loc| ErrorLocation {
file: redact_secrets(&loc.file),
line: loc.line,
column: loc.column,
});
let redacted_source = self
.source
.as_ref()
.map(|src| redact_secrets(&format!("{src:?}")));
let mut ds = f.debug_struct("SanthError");
ds.field("code", &self.code);
ds.field("title", &redacted_title);
ds.field("fix", &redacted_fix);
ds.field("context", &redacted_context);
ds.field("source", &redacted_source);
ds.field("location", &redacted_location);
ds.finish()
}
}
impl SanthError {
#[allow(clippy::new_ret_no_self)]
pub fn new(code: &'static str, title: impl Into<String>) -> SanthErrorBuilder<NoFix> {
SanthErrorBuilder {
code,
title: title.into(),
fix: None,
context: Vec::new(),
source: None,
location: None,
_state: std::marker::PhantomData,
}
}
pub fn code(&self) -> &'static str {
self.code
}
pub fn title(&self) -> &str {
&self.title
}
pub fn fix_hint(&self) -> &str {
&self.fix
}
pub fn actionable_message(&self) -> String {
compose_message(
&self.title,
&self.fix,
&self.context,
self.location.as_ref(),
self.source
.as_ref()
.map(|s| s.as_ref() as &dyn std::error::Error),
)
}
impl_diagnostic_mutators!();
}
const MAX_SOURCE_CHAIN: usize = 64;
pub(crate) fn compose_message(
title: &str,
fix: &str,
context: &[(Cow<'static, str>, String)],
location: Option<&ErrorLocation>,
source: Option<&dyn std::error::Error>,
) -> String {
let mut msg = String::with_capacity(256);
msg.push_str(title);
msg.push('\n');
msg.push('\n');
let fix_normalised;
let fix_str = if fix.starts_with("Fix: ") {
fix
} else {
fix_normalised = format!("Fix: {fix}");
&fix_normalised
};
msg.push_str(fix_str);
if !context.is_empty() {
msg.push('\n');
msg.push('\n');
msg.push_str("Context:");
for (k, v) in context {
msg.push('\n');
msg.push_str(" ");
msg.push_str(k);
msg.push_str(": ");
msg.push_str(v);
}
}
if let Some(loc) = location {
msg.push('\n');
msg.push('\n');
msg.push_str("Location: ");
msg.push_str(&loc.file);
msg.push(':');
msg.push_str(&loc.line.map_or_else(|| "?".to_string(), |l| l.to_string()));
msg.push(':');
msg.push_str(
&loc.column
.map_or_else(|| "?".to_string(), |c| c.to_string()),
);
}
if let Some(source) = source {
msg.push('\n');
msg.push('\n');
msg.push_str("Caused by:");
let mut current: Option<&dyn std::error::Error> = Some(source);
let mut links = 0usize;
while let Some(err) = current {
if links == MAX_SOURCE_CHAIN {
msg.push('\n');
msg.push_str(" - ... (source chain truncated after 64 links)");
break;
}
msg.push('\n');
msg.push_str(" - ");
let err_str = err.to_string();
if err_str.trim().is_empty() {
msg.push_str("(empty error message)");
} else {
msg.push_str(&err_str.replace('\n', "\n "));
}
current = err.source();
links += 1;
}
}
redact_secrets(&msg)
}
impl PartialEq for SanthError {
fn eq(&self, other: &Self) -> bool {
self.code == other.code
&& self.title == other.title
&& self.fix == other.fix
&& self.context == other.context
&& self.location == other.location
}
}
impl Eq for SanthError {}
impl fmt::Display for SanthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.actionable_message())
}
}
impl std::error::Error for SanthError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
}
}
impl From<std::io::Error> for SanthError {
fn from(err: std::io::Error) -> Self {
let (code, title, fix): (&'static str, &'static str, &'static str) = match err.kind() {
std::io::ErrorKind::NotFound => (
"SANTH-IO-NOTFOUND",
"File or resource not found",
"Fix: Verify the path exists and check for typos. If the file should be created automatically, ensure the parent directory exists.",
),
std::io::ErrorKind::PermissionDenied => (
"SANTH-IO-PERM",
"Permission denied",
"Fix: Check that the current user has read/write/execute permissions on the file or directory. On Unix, verify with `ls -la`.",
),
std::io::ErrorKind::ConnectionRefused => (
"SANTH-IO-CONNREF",
"Connection refused",
"Fix: Ensure the target service is running and listening on the expected port. Verify firewall rules and network connectivity.",
),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::BrokenPipe => (
"SANTH-IO-CONNRESET",
"Connection reset or broken pipe",
"Fix: The remote peer closed the connection. Retry the operation and verify the remote service is stable.",
),
std::io::ErrorKind::TimedOut => (
"SANTH-IO-TIMEOUT",
"I/O operation timed out",
"Fix: Increase the timeout duration, check network latency, or verify the remote service is responsive.",
),
std::io::ErrorKind::AlreadyExists => (
"SANTH-IO-EXISTS",
"File or resource already exists",
"Fix: Remove the existing file, choose a different name, or open with overwrite/truncate flags if intended.",
),
std::io::ErrorKind::InvalidInput => (
"SANTH-IO-INVAL",
"Invalid input parameter",
"Fix: Check that all arguments to the I/O operation are valid and within supported ranges.",
),
std::io::ErrorKind::UnexpectedEof => (
"SANTH-IO-EOF",
"Unexpected end of file",
"Fix: The file is shorter than expected. Verify the file was written completely and was not truncated.",
),
std::io::ErrorKind::OutOfMemory => (
"SANTH-IO-NOMEM",
"Out of memory",
"Fix: Reduce memory usage, process data in smaller chunks, or allocate more RAM to the process.",
),
_ => (
"SANTH-IO-01",
"I/O operation failed",
"Fix: Check that the file or resource exists and that you have the correct permissions.",
),
};
Self::new(code, title).fix(fix).with_source(err).build()
}
}
impl From<std::fmt::Error> for SanthError {
fn from(err: std::fmt::Error) -> Self {
Self::new("SANTH-FMT-01", "Formatting failed")
.fix("Fix: Ensure all format arguments implement the required Display/Debug traits and match the format string.")
.with_source(err)
.build()
}
}
impl From<std::string::FromUtf8Error> for SanthError {
fn from(err: std::string::FromUtf8Error) -> Self {
Self::new("SANTH-UTF8-01", "Invalid UTF-8 sequence")
.fix("Fix: Ensure the input is valid UTF-8, or use String::from_utf8_lossy for lossy conversion.")
.with_source(err)
.build()
}
}
impl From<regex::Error> for SanthError {
fn from(err: regex::Error) -> Self {
Self::new("SANTH-REGEX-01", "Regex compilation failed")
.fix("Fix: Verify the regex pattern syntax and ensure all special characters are properly escaped.")
.with_source(err)
.build()
}
}
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
mod readme {}