agent_first_data/
error_catalog.rs1use crate::{BuildError, ErrorBuilder, Event, json_error};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, btree_map::Entry};
10use std::fmt;
11
12#[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 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 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 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 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 pub fn event(&self) -> Result<Event, BuildError> {
75 self.builder().build()
76 }
77}
78
79#[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#[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 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 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 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 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}