1use std::fmt;
4use std::io;
5
6pub type DocumentResult<T> = Result<T, DocumentError>;
7
8#[derive(Debug, Clone)]
9pub enum DocumentError {
10 EmptyPath,
11 EmptyValues,
12 UnknownSegment {
13 path: String,
14 segment: String,
15 },
16 UnregisteredArray {
17 path: String,
18 },
19 SlugNotFound {
20 prefix: String,
21 slug: String,
22 },
23 SlugAlreadyExists {
24 prefix: String,
25 slug: String,
26 },
27 NotTraversable {
28 path: String,
29 got: String,
30 },
31 TypeMismatch {
32 path: String,
33 expected: String,
34 got: String,
35 hint: Option<String>,
36 },
37 PathNotFound {
38 path: String,
39 },
40 IndexOutOfBounds {
41 path: String,
42 index: usize,
43 len: usize,
44 },
45 ParseError {
46 format: String,
47 detail: String,
48 },
49 IoError {
50 detail: String,
51 },
52 UnsupportedOperation {
53 format: String,
54 operation: String,
55 detail: String,
56 },
57}
58
59impl fmt::Display for DocumentError {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 match self {
62 DocumentError::EmptyPath => {
63 write!(f, "empty path provided")
64 }
65 DocumentError::EmptyValues => {
66 write!(f, "at least one value required")
67 }
68 DocumentError::UnknownSegment { path, segment } => {
69 write!(f, "path `{}` segment `{}` not found", path, segment)
70 }
71 DocumentError::UnregisteredArray { path } => {
72 write!(f, "array at `{}` not registered in KeyedList", path)
73 }
74 DocumentError::SlugNotFound { prefix, slug } => {
75 write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
76 }
77 DocumentError::SlugAlreadyExists { prefix, slug } => {
78 write!(f, "slug `{}` already exists in `{}`", slug, prefix)
79 }
80 DocumentError::NotTraversable { path, got } => {
81 write!(f, "path `{}` is {}, cannot traverse further", path, got)
82 }
83 DocumentError::TypeMismatch {
84 path,
85 expected,
86 got,
87 hint,
88 } => {
89 write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
90 if let Some(h) = hint {
91 write!(f, "\n hint: {}", h)?;
92 }
93 Ok(())
94 }
95 DocumentError::PathNotFound { path } => {
96 write!(f, "path `{}` not found in document", path)
97 }
98 DocumentError::IndexOutOfBounds { path, index, len } => {
99 write!(
100 f,
101 "index {} out of bounds at `{}` (len {})",
102 index, path, len
103 )
104 }
105 DocumentError::ParseError { format, detail } => {
106 write!(f, "failed to parse {}: {}", format, detail)
107 }
108 DocumentError::IoError { detail } => {
109 write!(f, "io error: {}", detail)
110 }
111 DocumentError::UnsupportedOperation {
112 format,
113 operation,
114 detail,
115 } => write!(f, "{} does not support {}: {}", format, operation, detail),
116 }
117 }
118}
119
120impl std::error::Error for DocumentError {}
121
122impl DocumentError {
123 #[must_use]
129 pub const fn code(&self) -> &'static str {
130 match self {
131 Self::ParseError { .. } => "document_parse_failed",
132 Self::PathNotFound { .. }
133 | Self::UnknownSegment { .. }
134 | Self::IndexOutOfBounds { .. }
135 | Self::UnregisteredArray { .. } => "document_path_not_found",
136 Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
137 Self::SlugNotFound { .. } => "document_slug_not_found",
138 Self::SlugAlreadyExists { .. } => "document_slug_exists",
139 Self::IoError { .. } => "document_io_failed",
140 Self::UnsupportedOperation { .. } => "document_unsupported_operation",
141 Self::EmptyPath | Self::EmptyValues => "document_invalid_argument",
142 }
143 }
144
145 #[must_use]
154 pub fn location(&self) -> Option<String> {
155 let Self::ParseError { detail, .. } = self else {
156 return None;
157 };
158 let rest = &detail[detail.find("line ")? + 5..];
159 let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
160 if line.is_empty() {
161 return None;
162 }
163 let column = rest
164 .find("column ")
165 .map(|start| &rest[start + 7..])
166 .map(|tail| {
167 tail.chars()
168 .take_while(char::is_ascii_digit)
169 .collect::<String>()
170 })
171 .filter(|value| !value.is_empty());
172 Some(match column {
173 Some(column) => format!("line {line} column {column}"),
174 None => format!("line {line}"),
175 })
176 }
177
178 #[must_use]
187 pub fn redacted_message(&self) -> String {
188 match self {
189 Self::ParseError { format, .. } => match self.location() {
190 Some(location) => format!("failed to parse {format} at {location}"),
191 None => format!("failed to parse {format}"),
192 },
193 other => other.to_string(),
194 }
195 }
196
197 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
201 let msg = err.to_string();
202 let hint = msg
205 .split(" at line ")
206 .next()
207 .unwrap_or(&msg)
208 .trim()
209 .to_string();
210 DocumentError::TypeMismatch {
211 path: path.into(),
212 expected: String::new(),
213 got: hint,
214 hint: None,
215 }
216 }
217}
218
219impl From<io::Error> for DocumentError {
220 fn from(err: io::Error) -> Self {
221 DocumentError::IoError {
222 detail: err.to_string(),
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::DocumentError;
230
231 #[test]
232 fn document_error_codes_are_stable() {
233 let cases = [
234 (DocumentError::EmptyPath, "document_invalid_argument"),
235 (DocumentError::EmptyValues, "document_invalid_argument"),
236 (
237 DocumentError::UnknownSegment {
238 path: "root.key".to_string(),
239 segment: "key".to_string(),
240 },
241 "document_path_not_found",
242 ),
243 (
244 DocumentError::UnregisteredArray {
245 path: "items".to_string(),
246 },
247 "document_path_not_found",
248 ),
249 (
250 DocumentError::SlugNotFound {
251 prefix: "items".to_string(),
252 slug: "missing".to_string(),
253 },
254 "document_slug_not_found",
255 ),
256 (
257 DocumentError::SlugAlreadyExists {
258 prefix: "items".to_string(),
259 slug: "existing".to_string(),
260 },
261 "document_slug_exists",
262 ),
263 (
264 DocumentError::NotTraversable {
265 path: "root".to_string(),
266 got: "string".to_string(),
267 },
268 "document_type_mismatch",
269 ),
270 (
271 DocumentError::TypeMismatch {
272 path: "root.key".to_string(),
273 expected: "integer".to_string(),
274 got: "string".to_string(),
275 hint: None,
276 },
277 "document_type_mismatch",
278 ),
279 (
280 DocumentError::PathNotFound {
281 path: "root.key".to_string(),
282 },
283 "document_path_not_found",
284 ),
285 (
286 DocumentError::IndexOutOfBounds {
287 path: "items".to_string(),
288 index: 2,
289 len: 1,
290 },
291 "document_path_not_found",
292 ),
293 (
294 DocumentError::ParseError {
295 format: "JSON".to_string(),
296 detail: "invalid input".to_string(),
297 },
298 "document_parse_failed",
299 ),
300 (
301 DocumentError::IoError {
302 detail: "unreadable".to_string(),
303 },
304 "document_io_failed",
305 ),
306 (
307 DocumentError::UnsupportedOperation {
308 format: "INI".to_string(),
309 operation: "set".to_string(),
310 detail: "unsupported".to_string(),
311 },
312 "document_unsupported_operation",
313 ),
314 ];
315
316 for (error, expected) in cases {
317 assert_eq!(error.code(), expected);
318 }
319 }
320
321 #[test]
322 fn location_extracts_position_without_content() {
323 let err = DocumentError::ParseError {
324 format: "YAML".to_string(),
325 detail: "secret: [ TOPSECRET at line 5 column 12".to_string(),
326 };
327 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
328
329 let no_column = DocumentError::ParseError {
330 format: "JSON".to_string(),
331 detail: "boom at line 3".to_string(),
332 };
333 assert_eq!(no_column.location().as_deref(), Some("line 3"));
334
335 assert!(
337 DocumentError::ParseError {
338 format: "INI".to_string(),
339 detail: "sensitive value".to_string(),
340 }
341 .location()
342 .is_none()
343 );
344 assert!(
345 DocumentError::PathNotFound {
346 path: "a.b".to_string(),
347 }
348 .location()
349 .is_none()
350 );
351 }
352
353 #[test]
354 fn redacted_message_drops_parser_detail() {
355 let err = DocumentError::ParseError {
356 format: "YAML".to_string(),
357 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
358 };
359 let redacted = err.redacted_message();
360 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
361 assert!(!redacted.contains("TOPSECRET"));
362
363 let path_err = DocumentError::PathNotFound {
365 path: "database.url".to_string(),
366 };
367 assert_eq!(path_err.redacted_message(), path_err.to_string());
368 }
369}