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 {
24 path: String,
25 },
26 SlugNotFound {
27 prefix: String,
28 slug: String,
29 },
30 AmbiguousMatch {
41 prefix: String,
42 segment: String,
43 indices: Vec<usize>,
44 },
45 SlugAlreadyExists {
46 prefix: String,
47 slug: String,
48 },
49 NotTraversable {
50 path: String,
51 got: String,
52 },
53 TypeMismatch {
54 path: String,
55 expected: String,
56 got: String,
57 hint: Option<String>,
58 },
59 PathNotFound {
60 path: String,
61 },
62 IndexOutOfBounds {
63 path: String,
64 index: usize,
65 len: usize,
66 },
67 ParseError {
70 format: String,
71 detail: String,
72 },
73 PathSyntax {
81 detail: String,
82 },
83 SourceRefused {
92 format: String,
93 detail: String,
94 },
95 InvalidArgument {
98 detail: String,
99 },
100 WriteWouldCorrupt {
106 format: String,
107 detail: String,
108 },
109 FormatUnknown {
115 path: String,
116 },
117 IoError {
118 detail: String,
119 },
120 UnsupportedOperation {
121 format: String,
122 operation: String,
123 detail: String,
124 },
125}
126
127impl fmt::Display for DocumentError {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 DocumentError::EmptyPath => {
131 write!(f, "empty path provided")
132 }
133 DocumentError::EmptyValues => {
134 write!(f, "at least one value required")
135 }
136 DocumentError::UnknownSegment { path, segment } => {
137 write!(f, "path `{}` segment `{}` not found", path, segment)
138 }
139 DocumentError::UnregisteredArray { path } => {
140 write!(
141 f,
142 "array at `{}` has no rule for naming an element by its content; \
143 address an element by index, or name the field that identifies one",
144 path
145 )
146 }
147 DocumentError::SlugNotFound { prefix, slug } => {
148 write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
149 }
150 DocumentError::SlugAlreadyExists { prefix, slug } => {
151 write!(f, "slug `{}` already exists in `{}`", slug, prefix)
152 }
153 DocumentError::AmbiguousMatch {
154 prefix,
155 segment,
156 indices,
157 } => {
158 let candidates = indices
159 .iter()
160 .map(usize::to_string)
161 .collect::<Vec<_>>()
162 .join(", ");
163 write!(
164 f,
165 "segment `{}` matches {} elements of `{}` at indices {}",
166 segment,
167 indices.len(),
168 prefix,
169 candidates
170 )
171 }
172 DocumentError::NotTraversable { path, got } => {
173 write!(f, "path `{}` is {}, cannot traverse further", path, got)
174 }
175 DocumentError::TypeMismatch {
176 path,
177 expected,
178 got,
179 hint,
180 } => {
181 write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
182 if let Some(h) = hint {
183 write!(f, "\n hint: {}", h)?;
184 }
185 Ok(())
186 }
187 DocumentError::PathNotFound { path } => {
188 write!(f, "path `{}` not found in document", path)
189 }
190 DocumentError::IndexOutOfBounds { path, index, len } => {
191 write!(
192 f,
193 "index {} out of bounds at `{}` (len {})",
194 index, path, len
195 )
196 }
197 DocumentError::ParseError { format, detail } => {
198 write!(f, "failed to parse {}: {}", format, detail)
199 }
200 DocumentError::PathSyntax { detail } => {
201 write!(f, "invalid path: {}", detail)
202 }
203 DocumentError::SourceRefused { format, detail } => {
204 write!(f, "refusing to read this {}: {}", format, detail)
205 }
206 DocumentError::InvalidArgument { detail } => {
207 write!(f, "invalid argument: {}", detail)
208 }
209 DocumentError::WriteWouldCorrupt { format, detail } => {
210 write!(
211 f,
212 "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
213 format, detail
214 )
215 }
216 DocumentError::FormatUnknown { path } => {
217 write!(
218 f,
219 "cannot detect format from file extension `{}`; pass an explicit format",
220 path
221 )
222 }
223 DocumentError::IoError { detail } => {
224 write!(f, "io error: {}", detail)
225 }
226 DocumentError::UnsupportedOperation {
227 format,
228 operation,
229 detail,
230 } => write!(f, "{} does not support {}: {}", format, operation, detail),
231 }
232 }
233}
234
235impl std::error::Error for DocumentError {}
236
237impl DocumentError {
238 #[must_use]
244 pub const fn code(&self) -> &'static str {
245 match self {
246 Self::ParseError { .. } => "document_parse_failed",
247 Self::PathSyntax { .. } => "document_invalid_path",
248 Self::SourceRefused { .. } => "document_source_refused",
249 Self::FormatUnknown { .. } => "document_format_unknown",
250 Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
251 Self::PathNotFound { .. }
252 | Self::UnknownSegment { .. }
253 | Self::IndexOutOfBounds { .. }
254 | Self::UnregisteredArray { .. } => "document_path_not_found",
255 Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
256 Self::SlugNotFound { .. } => "document_slug_not_found",
257 Self::AmbiguousMatch { .. } => "document_ambiguous_match",
258 Self::SlugAlreadyExists { .. } => "document_slug_exists",
259 Self::IoError { .. } => "document_io_failed",
260 Self::UnsupportedOperation { .. } => "document_unsupported_operation",
261 Self::EmptyPath | Self::EmptyValues | Self::InvalidArgument { .. } => {
262 "document_invalid_argument"
263 }
264 }
265 }
266
267 #[must_use]
276 pub fn location(&self) -> Option<String> {
277 let Self::ParseError { detail, .. } = self else {
278 return None;
279 };
280 let head = detail.split('\n').next().unwrap_or(detail);
291 let rest = match head.find(" at line ") {
292 Some(start) => &head[start + " at line ".len()..],
293 None => head.strip_prefix("line ")?,
294 };
295 let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
296 if line.is_empty() {
297 return None;
298 }
299 let column = rest
300 .find("column ")
301 .map(|start| &rest[start + 7..])
302 .map(|tail| {
303 tail.chars()
304 .take_while(char::is_ascii_digit)
305 .collect::<String>()
306 })
307 .filter(|value| !value.is_empty());
308 Some(match column {
309 Some(column) => format!("line {line} column {column}"),
310 None => format!("line {line}"),
311 })
312 }
313
314 #[must_use]
342 pub fn redacted_message(&self) -> String {
343 match self {
344 Self::ParseError { format, .. } => match self.location() {
345 Some(location) => format!("failed to parse {format} at {location}"),
346 None => format!("failed to parse {format}"),
347 },
348 Self::TypeMismatch { path, expected, .. } => {
349 if expected.is_empty() {
350 format!("field `{path}` has the wrong type")
351 } else {
352 format!("field `{path}` expects {expected}")
353 }
354 }
355 other => other.to_string(),
356 }
357 }
358
359 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
363 let msg = err.to_string();
364 let hint = msg
367 .split(" at line ")
368 .next()
369 .unwrap_or(&msg)
370 .trim()
371 .to_string();
372 DocumentError::TypeMismatch {
373 path: path.into(),
374 expected: String::new(),
375 got: hint,
376 hint: None,
377 }
378 }
379}
380
381impl From<io::Error> for DocumentError {
382 fn from(err: io::Error) -> Self {
383 DocumentError::IoError {
384 detail: err.to_string(),
385 }
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::DocumentError;
392
393 #[test]
394 fn document_error_codes_are_stable() {
395 let cases = [
396 (DocumentError::EmptyPath, "document_invalid_argument"),
397 (DocumentError::EmptyValues, "document_invalid_argument"),
398 (
399 DocumentError::UnknownSegment {
400 path: "root.key".to_string(),
401 segment: "key".to_string(),
402 },
403 "document_path_not_found",
404 ),
405 (
406 DocumentError::UnregisteredArray {
407 path: "items".to_string(),
408 },
409 "document_path_not_found",
410 ),
411 (
412 DocumentError::SlugNotFound {
413 prefix: "items".to_string(),
414 slug: "missing".to_string(),
415 },
416 "document_slug_not_found",
417 ),
418 (
419 DocumentError::SlugAlreadyExists {
420 prefix: "items".to_string(),
421 slug: "existing".to_string(),
422 },
423 "document_slug_exists",
424 ),
425 (
426 DocumentError::AmbiguousMatch {
427 prefix: "items".to_string(),
428 segment: "look".to_string(),
429 indices: vec![0, 2],
430 },
431 "document_ambiguous_match",
432 ),
433 (
434 DocumentError::NotTraversable {
435 path: "root".to_string(),
436 got: "string".to_string(),
437 },
438 "document_type_mismatch",
439 ),
440 (
441 DocumentError::TypeMismatch {
442 path: "root.key".to_string(),
443 expected: "integer".to_string(),
444 got: "string".to_string(),
445 hint: None,
446 },
447 "document_type_mismatch",
448 ),
449 (
450 DocumentError::PathNotFound {
451 path: "root.key".to_string(),
452 },
453 "document_path_not_found",
454 ),
455 (
456 DocumentError::IndexOutOfBounds {
457 path: "items".to_string(),
458 index: 2,
459 len: 1,
460 },
461 "document_path_not_found",
462 ),
463 (
464 DocumentError::ParseError {
465 format: "JSON".to_string(),
466 detail: "invalid input".to_string(),
467 },
468 "document_parse_failed",
469 ),
470 (
471 DocumentError::IoError {
472 detail: "unreadable".to_string(),
473 },
474 "document_io_failed",
475 ),
476 (
477 DocumentError::UnsupportedOperation {
478 format: "INI".to_string(),
479 operation: "set".to_string(),
480 detail: "unsupported".to_string(),
481 },
482 "document_unsupported_operation",
483 ),
484 ];
485
486 for (error, expected) in cases {
487 assert_eq!(error.code(), expected);
488 }
489 }
490
491 #[test]
492 fn location_extracts_position_without_content() {
493 let err = DocumentError::ParseError {
499 format: "TOML".to_string(),
500 detail: "TOML parse error at line 5, column 12\n |\n\
501 5 | note = \"see at line 999 for TOPSECRET\" bad\n | ^"
502 .to_string(),
503 };
504 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
505 assert!(!err.redacted_message().contains("999"));
506 assert!(!err.redacted_message().contains("TOPSECRET"));
507
508 let no_column = DocumentError::ParseError {
509 format: "JSON".to_string(),
510 detail: "boom at line 3".to_string(),
511 };
512 assert_eq!(no_column.location().as_deref(), Some("line 3"));
513
514 assert!(
516 DocumentError::ParseError {
517 format: "INI".to_string(),
518 detail: "sensitive value".to_string(),
519 }
520 .location()
521 .is_none()
522 );
523 assert!(
524 DocumentError::PathNotFound {
525 path: "a.b".to_string(),
526 }
527 .location()
528 .is_none()
529 );
530 }
531
532 #[test]
533 fn redacted_message_drops_parser_detail() {
534 let err = DocumentError::ParseError {
535 format: "YAML".to_string(),
536 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
537 };
538 let redacted = err.redacted_message();
539 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
540 assert!(!redacted.contains("TOPSECRET"));
541
542 let path_err = DocumentError::PathNotFound {
544 path: "database.url".to_string(),
545 };
546 assert_eq!(path_err.redacted_message(), path_err.to_string());
547 }
548
549 #[test]
550 fn redacted_message_drops_the_offending_value() {
551 let err = DocumentError::from_serde(
554 "credentials.token",
555 "invalid type: string \"sk-live-TOPSECRET\", expected u16",
556 );
557 assert!(err.to_string().contains("sk-live-TOPSECRET"));
558 let redacted = err.redacted_message();
559 assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
560 assert!(redacted.contains("credentials.token"), "{redacted}");
561 }
562
563 #[test]
564 fn ambiguous_match_reports_indices_without_document_content() {
565 let err = DocumentError::AmbiguousMatch {
566 prefix: "h1.0.h2".to_string(),
567 segment: "look".to_string(),
568 indices: vec![0, 2],
569 };
570 assert_eq!(
571 err.redacted_message(),
572 "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
573 );
574 assert!(!err.redacted_message().contains("Quick look"));
575 }
576
577 #[test]
578 fn not_traversable_names_the_type_not_the_value() {
579 let err = DocumentError::NotTraversable {
582 path: "token_secret.inner".to_string(),
583 got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
584 .kind_name()
585 .to_string(),
586 };
587 assert_eq!(
588 err.to_string(),
589 "path `token_secret.inner` is string, cannot traverse further"
590 );
591 assert_eq!(err.redacted_message(), err.to_string());
592 }
593}