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 {
48 format: String,
49 detail: String,
50 },
51 WriteWouldCorrupt {
57 format: String,
58 detail: String,
59 },
60 FormatUnknown {
66 path: String,
67 },
68 IoError {
69 detail: String,
70 },
71 UnsupportedOperation {
72 format: String,
73 operation: String,
74 detail: String,
75 },
76}
77
78impl fmt::Display for DocumentError {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 DocumentError::EmptyPath => {
82 write!(f, "empty path provided")
83 }
84 DocumentError::EmptyValues => {
85 write!(f, "at least one value required")
86 }
87 DocumentError::UnknownSegment { path, segment } => {
88 write!(f, "path `{}` segment `{}` not found", path, segment)
89 }
90 DocumentError::UnregisteredArray { path } => {
91 write!(f, "array at `{}` not registered in KeyedList", path)
92 }
93 DocumentError::SlugNotFound { prefix, slug } => {
94 write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
95 }
96 DocumentError::SlugAlreadyExists { prefix, slug } => {
97 write!(f, "slug `{}` already exists in `{}`", slug, prefix)
98 }
99 DocumentError::NotTraversable { path, got } => {
100 write!(f, "path `{}` is {}, cannot traverse further", path, got)
101 }
102 DocumentError::TypeMismatch {
103 path,
104 expected,
105 got,
106 hint,
107 } => {
108 write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
109 if let Some(h) = hint {
110 write!(f, "\n hint: {}", h)?;
111 }
112 Ok(())
113 }
114 DocumentError::PathNotFound { path } => {
115 write!(f, "path `{}` not found in document", path)
116 }
117 DocumentError::IndexOutOfBounds { path, index, len } => {
118 write!(
119 f,
120 "index {} out of bounds at `{}` (len {})",
121 index, path, len
122 )
123 }
124 DocumentError::ParseError { format, detail } => {
125 write!(f, "failed to parse {}: {}", format, detail)
126 }
127 DocumentError::WriteWouldCorrupt { format, detail } => {
128 write!(
129 f,
130 "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
131 format, detail
132 )
133 }
134 DocumentError::FormatUnknown { path } => {
135 write!(
136 f,
137 "cannot detect format from file extension `{}`; pass an explicit format",
138 path
139 )
140 }
141 DocumentError::IoError { detail } => {
142 write!(f, "io error: {}", detail)
143 }
144 DocumentError::UnsupportedOperation {
145 format,
146 operation,
147 detail,
148 } => write!(f, "{} does not support {}: {}", format, operation, detail),
149 }
150 }
151}
152
153impl std::error::Error for DocumentError {}
154
155impl DocumentError {
156 #[must_use]
162 pub const fn code(&self) -> &'static str {
163 match self {
164 Self::ParseError { .. } => "document_parse_failed",
165 Self::FormatUnknown { .. } => "document_format_unknown",
166 Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
167 Self::PathNotFound { .. }
168 | Self::UnknownSegment { .. }
169 | Self::IndexOutOfBounds { .. }
170 | Self::UnregisteredArray { .. } => "document_path_not_found",
171 Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
172 Self::SlugNotFound { .. } => "document_slug_not_found",
173 Self::SlugAlreadyExists { .. } => "document_slug_exists",
174 Self::IoError { .. } => "document_io_failed",
175 Self::UnsupportedOperation { .. } => "document_unsupported_operation",
176 Self::EmptyPath | Self::EmptyValues => "document_invalid_argument",
177 }
178 }
179
180 #[must_use]
189 pub fn location(&self) -> Option<String> {
190 let Self::ParseError { detail, .. } = self else {
191 return None;
192 };
193 let rest = &detail[detail.find("line ")? + 5..];
194 let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
195 if line.is_empty() {
196 return None;
197 }
198 let column = rest
199 .find("column ")
200 .map(|start| &rest[start + 7..])
201 .map(|tail| {
202 tail.chars()
203 .take_while(char::is_ascii_digit)
204 .collect::<String>()
205 })
206 .filter(|value| !value.is_empty());
207 Some(match column {
208 Some(column) => format!("line {line} column {column}"),
209 None => format!("line {line}"),
210 })
211 }
212
213 #[must_use]
231 pub fn redacted_message(&self) -> String {
232 match self {
233 Self::ParseError { format, .. } => match self.location() {
234 Some(location) => format!("failed to parse {format} at {location}"),
235 None => format!("failed to parse {format}"),
236 },
237 Self::TypeMismatch { path, expected, .. } => {
238 if expected.is_empty() {
239 format!("field `{path}` has the wrong type")
240 } else {
241 format!("field `{path}` expects {expected}")
242 }
243 }
244 other => other.to_string(),
245 }
246 }
247
248 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
252 let msg = err.to_string();
253 let hint = msg
256 .split(" at line ")
257 .next()
258 .unwrap_or(&msg)
259 .trim()
260 .to_string();
261 DocumentError::TypeMismatch {
262 path: path.into(),
263 expected: String::new(),
264 got: hint,
265 hint: None,
266 }
267 }
268}
269
270impl From<io::Error> for DocumentError {
271 fn from(err: io::Error) -> Self {
272 DocumentError::IoError {
273 detail: err.to_string(),
274 }
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::DocumentError;
281
282 #[test]
283 fn document_error_codes_are_stable() {
284 let cases = [
285 (DocumentError::EmptyPath, "document_invalid_argument"),
286 (DocumentError::EmptyValues, "document_invalid_argument"),
287 (
288 DocumentError::UnknownSegment {
289 path: "root.key".to_string(),
290 segment: "key".to_string(),
291 },
292 "document_path_not_found",
293 ),
294 (
295 DocumentError::UnregisteredArray {
296 path: "items".to_string(),
297 },
298 "document_path_not_found",
299 ),
300 (
301 DocumentError::SlugNotFound {
302 prefix: "items".to_string(),
303 slug: "missing".to_string(),
304 },
305 "document_slug_not_found",
306 ),
307 (
308 DocumentError::SlugAlreadyExists {
309 prefix: "items".to_string(),
310 slug: "existing".to_string(),
311 },
312 "document_slug_exists",
313 ),
314 (
315 DocumentError::NotTraversable {
316 path: "root".to_string(),
317 got: "string".to_string(),
318 },
319 "document_type_mismatch",
320 ),
321 (
322 DocumentError::TypeMismatch {
323 path: "root.key".to_string(),
324 expected: "integer".to_string(),
325 got: "string".to_string(),
326 hint: None,
327 },
328 "document_type_mismatch",
329 ),
330 (
331 DocumentError::PathNotFound {
332 path: "root.key".to_string(),
333 },
334 "document_path_not_found",
335 ),
336 (
337 DocumentError::IndexOutOfBounds {
338 path: "items".to_string(),
339 index: 2,
340 len: 1,
341 },
342 "document_path_not_found",
343 ),
344 (
345 DocumentError::ParseError {
346 format: "JSON".to_string(),
347 detail: "invalid input".to_string(),
348 },
349 "document_parse_failed",
350 ),
351 (
352 DocumentError::IoError {
353 detail: "unreadable".to_string(),
354 },
355 "document_io_failed",
356 ),
357 (
358 DocumentError::UnsupportedOperation {
359 format: "INI".to_string(),
360 operation: "set".to_string(),
361 detail: "unsupported".to_string(),
362 },
363 "document_unsupported_operation",
364 ),
365 ];
366
367 for (error, expected) in cases {
368 assert_eq!(error.code(), expected);
369 }
370 }
371
372 #[test]
373 fn location_extracts_position_without_content() {
374 let err = DocumentError::ParseError {
375 format: "YAML".to_string(),
376 detail: "secret: [ TOPSECRET at line 5 column 12".to_string(),
377 };
378 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
379
380 let no_column = DocumentError::ParseError {
381 format: "JSON".to_string(),
382 detail: "boom at line 3".to_string(),
383 };
384 assert_eq!(no_column.location().as_deref(), Some("line 3"));
385
386 assert!(
388 DocumentError::ParseError {
389 format: "INI".to_string(),
390 detail: "sensitive value".to_string(),
391 }
392 .location()
393 .is_none()
394 );
395 assert!(
396 DocumentError::PathNotFound {
397 path: "a.b".to_string(),
398 }
399 .location()
400 .is_none()
401 );
402 }
403
404 #[test]
405 fn redacted_message_drops_parser_detail() {
406 let err = DocumentError::ParseError {
407 format: "YAML".to_string(),
408 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
409 };
410 let redacted = err.redacted_message();
411 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
412 assert!(!redacted.contains("TOPSECRET"));
413
414 let path_err = DocumentError::PathNotFound {
416 path: "database.url".to_string(),
417 };
418 assert_eq!(path_err.redacted_message(), path_err.to_string());
419 }
420
421 #[test]
422 fn redacted_message_drops_the_offending_value() {
423 let err = DocumentError::from_serde(
426 "credentials.token",
427 "invalid type: string \"sk-live-TOPSECRET\", expected u16",
428 );
429 assert!(err.to_string().contains("sk-live-TOPSECRET"));
430 let redacted = err.redacted_message();
431 assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
432 assert!(redacted.contains("credentials.token"), "{redacted}");
433 }
434
435 #[test]
436 fn not_traversable_names_the_type_not_the_value() {
437 let err = DocumentError::NotTraversable {
440 path: "token_secret.inner".to_string(),
441 got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
442 .kind_name()
443 .to_string(),
444 };
445 assert_eq!(
446 err.to_string(),
447 "path `token_secret.inner` is string, cannot traverse further"
448 );
449 assert_eq!(err.redacted_message(), err.to_string());
450 }
451}