Skip to main content

agent_first_data/
error_catalog.rs

1//! Stable, public-facing domain error declarations.
2//!
3//! Runtime diagnostics are deliberately absent from these types. A parser,
4//! database, or network error may be logged separately, but it cannot drift
5//! into a protocol message merely because an application formats `source`.
6
7use crate::{BuildError, ErrorBuilder, Event, json_error};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, btree_map::Entry};
10use std::fmt;
11
12/// Stable fields that are safe to return to a caller.
13#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ErrorSpec {
15    code: String,
16    message: String,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    hint: Option<String>,
19    #[serde(default)]
20    retryable: bool,
21}
22
23impl ErrorSpec {
24    /// Declare a stable error code and public message.
25    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
26        Self {
27            code: code.into(),
28            message: message.into(),
29            hint: None,
30            retryable: false,
31        }
32    }
33
34    /// Add a stable caller-facing recovery hint.
35    pub fn hint(mut self, hint: impl Into<String>) -> Self {
36        let hint = hint.into();
37        self.hint = (!hint.is_empty()).then_some(hint);
38        self
39    }
40
41    /// Declare whether retrying may succeed without changing the request.
42    pub const fn retryable(mut self, retryable: bool) -> Self {
43        self.retryable = retryable;
44        self
45    }
46
47    pub fn code(&self) -> &str {
48        &self.code
49    }
50
51    pub fn message(&self) -> &str {
52        &self.message
53    }
54
55    pub fn hint_value(&self) -> Option<&str> {
56        self.hint.as_deref()
57    }
58
59    pub const fn is_retryable(&self) -> bool {
60        self.retryable
61    }
62
63    /// Start an error builder using only catalogued public fields.
64    ///
65    /// Applications may add explicitly selected structured fields to the
66    /// returned builder. No runtime diagnostic is accepted by this API.
67    pub fn builder(&self) -> ErrorBuilder {
68        json_error(&self.code, &self.message)
69            .hint_if_some(self.hint.as_deref())
70            .retryable_if(self.retryable)
71    }
72
73    /// Build the strict protocol error event.
74    pub fn event(&self) -> Result<Event, BuildError> {
75        self.builder().build()
76    }
77}
78
79/// Failure to construct or query an [`ErrorCatalog`].
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum ErrorCatalogError {
82    DuplicateCode(String),
83    UnknownCode(String),
84    InvalidSpec { code: String, source: BuildError },
85}
86
87impl fmt::Display for ErrorCatalogError {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::DuplicateCode(code) => write!(formatter, "duplicate error code {code:?}"),
91            Self::UnknownCode(code) => write!(formatter, "unknown error code {code:?}"),
92            Self::InvalidSpec { code, source } => {
93                write!(formatter, "invalid error spec {code:?}: {source}")
94            }
95        }
96    }
97}
98
99impl std::error::Error for ErrorCatalogError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::InvalidSpec { source, .. } => Some(source),
103            Self::DuplicateCode(_) | Self::UnknownCode(_) => None,
104        }
105    }
106}
107
108/// Validated lookup table of stable domain errors.
109#[derive(Clone, Debug, Default)]
110pub struct ErrorCatalog {
111    specs: BTreeMap<String, ErrorSpec>,
112}
113
114impl ErrorCatalog {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// Validate and collect a finite set of declarations.
120    pub fn from_specs<I>(specs: I) -> Result<Self, ErrorCatalogError>
121    where
122        I: IntoIterator<Item = ErrorSpec>,
123    {
124        let mut catalog = Self::new();
125        for spec in specs {
126            catalog.insert(spec)?;
127        }
128        Ok(catalog)
129    }
130
131    /// Validate and insert one declaration.
132    pub fn insert(&mut self, spec: ErrorSpec) -> Result<(), ErrorCatalogError> {
133        let slot = match self.specs.entry(spec.code.clone()) {
134            Entry::Occupied(slot) => {
135                return Err(ErrorCatalogError::DuplicateCode(slot.key().clone()));
136            }
137            Entry::Vacant(slot) => slot,
138        };
139        spec.event()
140            .map_err(|source| ErrorCatalogError::InvalidSpec {
141                code: slot.key().clone(),
142                source,
143            })?;
144        slot.insert(spec);
145        Ok(())
146    }
147
148    pub fn get(&self, code: &str) -> Option<&ErrorSpec> {
149        self.specs.get(code)
150    }
151
152    /// Start a builder for a catalogued code.
153    pub fn builder(&self, code: &str) -> Result<ErrorBuilder, ErrorCatalogError> {
154        self.get(code)
155            .map(ErrorSpec::builder)
156            .ok_or_else(|| ErrorCatalogError::UnknownCode(code.to_string()))
157    }
158
159    /// Build a strict error event for a catalogued code.
160    pub fn event(&self, code: &str) -> Result<Event, ErrorCatalogError> {
161        self.builder(code)?
162            .build()
163            .map_err(|source| ErrorCatalogError::InvalidSpec {
164                code: code.to_string(),
165                source,
166            })
167    }
168
169    pub fn len(&self) -> usize {
170        self.specs.len()
171    }
172
173    pub fn is_empty(&self) -> bool {
174        self.specs.is_empty()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::{ErrorCatalog, ErrorCatalogError, ErrorSpec};
181    use crate::validate_protocol_event;
182
183    #[test]
184    fn catalog_builds_only_declared_public_fields() {
185        let catalog = ErrorCatalog::from_specs([ErrorSpec::new(
186            "config_load_failed",
187            "Failed to load configuration",
188        )
189        .hint("inspect the configuration and retry")
190        .retryable(false)])
191        .unwrap_or_else(|error| panic!("{error}"));
192
193        let event = catalog
194            .event("config_load_failed")
195            .unwrap_or_else(|error| panic!("{error}"));
196        validate_protocol_event(event.as_value(), true).unwrap_or_else(|error| panic!("{error}"));
197        assert_eq!(
198            event.as_value()["error"]["message"],
199            "Failed to load configuration"
200        );
201        assert!(!event.to_string().contains("database diagnostic"));
202    }
203
204    #[test]
205    fn catalog_rejects_invalid_duplicate_and_unknown_codes() {
206        let invalid = ErrorCatalog::from_specs([ErrorSpec::new("", "message")]);
207        assert!(matches!(
208            invalid,
209            Err(ErrorCatalogError::InvalidSpec { .. })
210        ));
211
212        let duplicate = ErrorCatalog::from_specs([
213            ErrorSpec::new("same", "first"),
214            ErrorSpec::new("same", "second"),
215        ]);
216        assert!(matches!(
217            duplicate,
218            Err(ErrorCatalogError::DuplicateCode(code)) if code == "same"
219        ));
220
221        let empty = ErrorCatalog::new();
222        assert!(matches!(
223            empty.event("missing"),
224            Err(ErrorCatalogError::UnknownCode(code)) if code == "missing"
225        ));
226    }
227}