use crate::{BuildError, ErrorBuilder, Event, json_error};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorSpec {
code: String,
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
hint: Option<String>,
#[serde(default)]
retryable: bool,
}
impl ErrorSpec {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
hint: None,
retryable: false,
}
}
pub fn hint(mut self, hint: impl Into<String>) -> Self {
let hint = hint.into();
self.hint = (!hint.is_empty()).then_some(hint);
self
}
pub const fn retryable(mut self, retryable: bool) -> Self {
self.retryable = retryable;
self
}
pub fn code(&self) -> &str {
&self.code
}
pub fn message(&self) -> &str {
&self.message
}
pub fn hint_value(&self) -> Option<&str> {
self.hint.as_deref()
}
pub const fn is_retryable(&self) -> bool {
self.retryable
}
pub fn builder(&self) -> ErrorBuilder {
json_error(&self.code, &self.message)
.hint_if_some(self.hint.as_deref())
.retryable_if(self.retryable)
}
pub fn event(&self) -> Result<Event, BuildError> {
self.builder().build()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ErrorCatalogError {
DuplicateCode(String),
UnknownCode(String),
InvalidSpec { code: String, source: BuildError },
}
impl fmt::Display for ErrorCatalogError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateCode(code) => write!(formatter, "duplicate error code {code:?}"),
Self::UnknownCode(code) => write!(formatter, "unknown error code {code:?}"),
Self::InvalidSpec { code, source } => {
write!(formatter, "invalid error spec {code:?}: {source}")
}
}
}
}
impl std::error::Error for ErrorCatalogError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::InvalidSpec { source, .. } => Some(source),
Self::DuplicateCode(_) | Self::UnknownCode(_) => None,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ErrorCatalog {
specs: BTreeMap<String, ErrorSpec>,
}
impl ErrorCatalog {
pub fn new() -> Self {
Self::default()
}
pub fn from_specs<I>(specs: I) -> Result<Self, ErrorCatalogError>
where
I: IntoIterator<Item = ErrorSpec>,
{
let mut catalog = Self::new();
for spec in specs {
catalog.insert(spec)?;
}
Ok(catalog)
}
pub fn insert(&mut self, spec: ErrorSpec) -> Result<(), ErrorCatalogError> {
let code = spec.code.clone();
if self.specs.contains_key(&code) {
return Err(ErrorCatalogError::DuplicateCode(code));
}
spec.event()
.map_err(|source| ErrorCatalogError::InvalidSpec {
code: code.clone(),
source,
})?;
self.specs.insert(code, spec);
Ok(())
}
pub fn get(&self, code: &str) -> Option<&ErrorSpec> {
self.specs.get(code)
}
pub fn builder(&self, code: &str) -> Result<ErrorBuilder, ErrorCatalogError> {
self.get(code)
.map(ErrorSpec::builder)
.ok_or_else(|| ErrorCatalogError::UnknownCode(code.to_string()))
}
pub fn event(&self, code: &str) -> Result<Event, ErrorCatalogError> {
self.builder(code)?
.build()
.map_err(|source| ErrorCatalogError::InvalidSpec {
code: code.to_string(),
source,
})
}
pub fn len(&self) -> usize {
self.specs.len()
}
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::{ErrorCatalog, ErrorCatalogError, ErrorSpec};
use crate::validate_protocol_event;
#[test]
fn catalog_builds_only_declared_public_fields() {
let catalog = ErrorCatalog::from_specs([ErrorSpec::new(
"config_load_failed",
"Failed to load configuration",
)
.hint("inspect the configuration and retry")
.retryable(false)])
.unwrap_or_else(|error| panic!("{error}"));
let event = catalog
.event("config_load_failed")
.unwrap_or_else(|error| panic!("{error}"));
validate_protocol_event(event.as_value(), true).unwrap_or_else(|error| panic!("{error}"));
assert_eq!(
event.as_value()["error"]["message"],
"Failed to load configuration"
);
assert!(!event.to_string().contains("database diagnostic"));
}
#[test]
fn catalog_rejects_invalid_duplicate_and_unknown_codes() {
let invalid = ErrorCatalog::from_specs([ErrorSpec::new("", "message")]);
assert!(matches!(
invalid,
Err(ErrorCatalogError::InvalidSpec { .. })
));
let duplicate = ErrorCatalog::from_specs([
ErrorSpec::new("same", "first"),
ErrorSpec::new("same", "second"),
]);
assert!(matches!(
duplicate,
Err(ErrorCatalogError::DuplicateCode(code)) if code == "same"
));
let empty = ErrorCatalog::new();
assert!(matches!(
empty.event("missing"),
Err(ErrorCatalogError::UnknownCode(code)) if code == "missing"
));
}
}