use std::fmt;
use std::io;
pub type DocumentResult<T> = Result<T, DocumentError>;
#[derive(Debug, Clone)]
pub enum DocumentError {
EmptyPath,
EmptyValues,
UnknownSegment {
path: String,
segment: String,
},
UnregisteredArray {
path: String,
},
SlugNotFound {
prefix: String,
slug: String,
},
SlugAlreadyExists {
prefix: String,
slug: String,
},
NotTraversable {
path: String,
got: String,
},
TypeMismatch {
path: String,
expected: String,
got: String,
hint: Option<String>,
},
PathNotFound {
path: String,
},
IndexOutOfBounds {
path: String,
index: usize,
len: usize,
},
ParseError {
format: String,
detail: String,
},
IoError {
detail: String,
},
UnsupportedOperation {
format: String,
operation: String,
detail: String,
},
}
impl fmt::Display for DocumentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DocumentError::EmptyPath => {
write!(f, "empty path provided")
}
DocumentError::EmptyValues => {
write!(f, "at least one value required")
}
DocumentError::UnknownSegment { path, segment } => {
write!(f, "path `{}` segment `{}` not found", path, segment)
}
DocumentError::UnregisteredArray { path } => {
write!(f, "array at `{}` not registered in KeyedList", path)
}
DocumentError::SlugNotFound { prefix, slug } => {
write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
}
DocumentError::SlugAlreadyExists { prefix, slug } => {
write!(f, "slug `{}` already exists in `{}`", slug, prefix)
}
DocumentError::NotTraversable { path, got } => {
write!(f, "path `{}` is {}, cannot traverse further", path, got)
}
DocumentError::TypeMismatch {
path,
expected,
got,
hint,
} => {
write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
if let Some(h) = hint {
write!(f, "\n hint: {}", h)?;
}
Ok(())
}
DocumentError::PathNotFound { path } => {
write!(f, "path `{}` not found in document", path)
}
DocumentError::IndexOutOfBounds { path, index, len } => {
write!(
f,
"index {} out of bounds at `{}` (len {})",
index, path, len
)
}
DocumentError::ParseError { format, detail } => {
write!(f, "failed to parse {}: {}", format, detail)
}
DocumentError::IoError { detail } => {
write!(f, "io error: {}", detail)
}
DocumentError::UnsupportedOperation {
format,
operation,
detail,
} => write!(f, "{} does not support {}: {}", format, operation, detail),
}
}
}
impl std::error::Error for DocumentError {}
impl DocumentError {
#[must_use]
pub const fn code(&self) -> &'static str {
match self {
Self::ParseError { .. } => "document_parse_failed",
Self::PathNotFound { .. }
| Self::UnknownSegment { .. }
| Self::IndexOutOfBounds { .. }
| Self::UnregisteredArray { .. } => "document_path_not_found",
Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
Self::SlugNotFound { .. } => "document_slug_not_found",
Self::SlugAlreadyExists { .. } => "document_slug_exists",
Self::IoError { .. } => "document_io_failed",
Self::UnsupportedOperation { .. } => "document_unsupported_operation",
Self::EmptyPath | Self::EmptyValues => "document_invalid_argument",
}
}
#[must_use]
pub fn location(&self) -> Option<String> {
let Self::ParseError { detail, .. } = self else {
return None;
};
let rest = &detail[detail.find("line ")? + 5..];
let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
if line.is_empty() {
return None;
}
let column = rest
.find("column ")
.map(|start| &rest[start + 7..])
.map(|tail| {
tail.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
})
.filter(|value| !value.is_empty());
Some(match column {
Some(column) => format!("line {line} column {column}"),
None => format!("line {line}"),
})
}
#[must_use]
pub fn redacted_message(&self) -> String {
match self {
Self::ParseError { format, .. } => match self.location() {
Some(location) => format!("failed to parse {format} at {location}"),
None => format!("failed to parse {format}"),
},
other => other.to_string(),
}
}
pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
let msg = err.to_string();
let hint = msg
.split(" at line ")
.next()
.unwrap_or(&msg)
.trim()
.to_string();
DocumentError::TypeMismatch {
path: path.into(),
expected: String::new(),
got: hint,
hint: None,
}
}
}
impl From<io::Error> for DocumentError {
fn from(err: io::Error) -> Self {
DocumentError::IoError {
detail: err.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::DocumentError;
#[test]
fn document_error_codes_are_stable() {
let cases = [
(DocumentError::EmptyPath, "document_invalid_argument"),
(DocumentError::EmptyValues, "document_invalid_argument"),
(
DocumentError::UnknownSegment {
path: "root.key".to_string(),
segment: "key".to_string(),
},
"document_path_not_found",
),
(
DocumentError::UnregisteredArray {
path: "items".to_string(),
},
"document_path_not_found",
),
(
DocumentError::SlugNotFound {
prefix: "items".to_string(),
slug: "missing".to_string(),
},
"document_slug_not_found",
),
(
DocumentError::SlugAlreadyExists {
prefix: "items".to_string(),
slug: "existing".to_string(),
},
"document_slug_exists",
),
(
DocumentError::NotTraversable {
path: "root".to_string(),
got: "string".to_string(),
},
"document_type_mismatch",
),
(
DocumentError::TypeMismatch {
path: "root.key".to_string(),
expected: "integer".to_string(),
got: "string".to_string(),
hint: None,
},
"document_type_mismatch",
),
(
DocumentError::PathNotFound {
path: "root.key".to_string(),
},
"document_path_not_found",
),
(
DocumentError::IndexOutOfBounds {
path: "items".to_string(),
index: 2,
len: 1,
},
"document_path_not_found",
),
(
DocumentError::ParseError {
format: "JSON".to_string(),
detail: "invalid input".to_string(),
},
"document_parse_failed",
),
(
DocumentError::IoError {
detail: "unreadable".to_string(),
},
"document_io_failed",
),
(
DocumentError::UnsupportedOperation {
format: "INI".to_string(),
operation: "set".to_string(),
detail: "unsupported".to_string(),
},
"document_unsupported_operation",
),
];
for (error, expected) in cases {
assert_eq!(error.code(), expected);
}
}
#[test]
fn location_extracts_position_without_content() {
let err = DocumentError::ParseError {
format: "YAML".to_string(),
detail: "secret: [ TOPSECRET at line 5 column 12".to_string(),
};
assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
let no_column = DocumentError::ParseError {
format: "JSON".to_string(),
detail: "boom at line 3".to_string(),
};
assert_eq!(no_column.location().as_deref(), Some("line 3"));
assert!(
DocumentError::ParseError {
format: "INI".to_string(),
detail: "sensitive value".to_string(),
}
.location()
.is_none()
);
assert!(
DocumentError::PathNotFound {
path: "a.b".to_string(),
}
.location()
.is_none()
);
}
#[test]
fn redacted_message_drops_parser_detail() {
let err = DocumentError::ParseError {
format: "YAML".to_string(),
detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
};
let redacted = err.redacted_message();
assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
assert!(!redacted.contains("TOPSECRET"));
let path_err = DocumentError::PathNotFound {
path: "database.url".to_string(),
};
assert_eq!(path_err.redacted_message(), path_err.to_string());
}
}