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 AlreadyExists {
121 path: String,
122 },
123 TooLarge {
128 path: String,
129 max_bytes: u64,
130 },
131 EmptyInput {
135 source: String,
136 },
137 IoError {
138 detail: String,
139 },
140 UnsupportedOperation {
141 format: String,
142 operation: String,
143 detail: String,
144 },
145}
146
147impl fmt::Display for DocumentError {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 match self {
150 DocumentError::EmptyPath => {
151 write!(f, "empty path provided")
152 }
153 DocumentError::EmptyValues => {
154 write!(f, "at least one value required")
155 }
156 DocumentError::UnknownSegment { path, segment } => {
157 write!(f, "path `{}` segment `{}` not found", path, segment)
158 }
159 DocumentError::UnregisteredArray { path } => {
160 write!(
161 f,
162 "array at `{}` has no rule for naming an element by its content; \
163 address an element by index, or name the field that identifies one",
164 path
165 )
166 }
167 DocumentError::SlugNotFound { prefix, slug } => {
168 write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
169 }
170 DocumentError::SlugAlreadyExists { prefix, slug } => {
171 write!(f, "slug `{}` already exists in `{}`", slug, prefix)
172 }
173 DocumentError::AmbiguousMatch {
174 prefix,
175 segment,
176 indices,
177 } => {
178 let candidates = indices
179 .iter()
180 .map(usize::to_string)
181 .collect::<Vec<_>>()
182 .join(", ");
183 write!(
184 f,
185 "segment `{}` matches {} elements of `{}` at indices {}",
186 segment,
187 indices.len(),
188 prefix,
189 candidates
190 )
191 }
192 DocumentError::NotTraversable { path, got } if path.is_empty() => {
195 if got == "null" {
196 write!(
197 f,
198 "the document is empty (its root is null), cannot traverse into it"
199 )
200 } else {
201 write!(f, "the document root is {}, cannot traverse into it", got)
202 }
203 }
204 DocumentError::NotTraversable { path, got } => {
205 write!(f, "path `{}` is {}, cannot traverse further", path, got)
206 }
207 DocumentError::TypeMismatch {
208 path,
209 expected,
210 got,
211 hint,
212 } => {
213 write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
214 if let Some(h) = hint {
215 write!(f, "\n hint: {}", h)?;
216 }
217 Ok(())
218 }
219 DocumentError::PathNotFound { path } => {
220 write!(f, "path `{}` not found in document", path)
221 }
222 DocumentError::IndexOutOfBounds { path, index, len } => {
223 write!(
224 f,
225 "index {} out of bounds at `{}` (len {})",
226 index, path, len
227 )
228 }
229 DocumentError::ParseError { format, detail } => {
230 write!(f, "failed to parse {}: {}", format, detail)
231 }
232 DocumentError::PathSyntax { detail } => {
233 write!(f, "invalid path: {}", detail)
234 }
235 DocumentError::SourceRefused { format, detail } => {
236 write!(f, "refusing to read this {}: {}", format, detail)
237 }
238 DocumentError::InvalidArgument { detail } => {
239 write!(f, "invalid argument: {}", detail)
240 }
241 DocumentError::WriteWouldCorrupt { format, detail } => {
242 write!(
243 f,
244 "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
245 format, detail
246 )
247 }
248 DocumentError::FormatUnknown { path } => {
249 write!(
250 f,
251 "cannot detect the format of `{}` from its extension; pass an explicit format",
252 path
253 )
254 }
255 DocumentError::AlreadyExists { path } => {
256 write!(f, "document target `{path}` already exists")
257 }
258 DocumentError::TooLarge { path, max_bytes } => {
259 write!(f, "`{path}` exceeds the {max_bytes}-byte read limit")
260 }
261 DocumentError::EmptyInput { source } => {
262 write!(
263 f,
264 "{source} is empty (zero bytes); there is no document to read"
265 )
266 }
267 DocumentError::IoError { detail } => {
268 write!(f, "io error: {}", detail)
269 }
270 DocumentError::UnsupportedOperation {
271 format,
272 operation,
273 detail,
274 } => write!(f, "{} does not support {}: {}", format, operation, detail),
275 }
276 }
277}
278
279impl std::error::Error for DocumentError {}
280
281impl DocumentError {
282 #[must_use]
288 pub const fn code(&self) -> &'static str {
289 match self {
290 Self::ParseError { .. } => "document_parse_failed",
291 Self::PathSyntax { .. } => "document_invalid_path",
292 Self::SourceRefused { .. } => "document_source_refused",
293 Self::FormatUnknown { .. } => "document_format_unknown",
294 Self::AlreadyExists { .. } => "document_target_exists",
295 Self::TooLarge { .. } => "document_too_large",
296 Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
297 Self::PathNotFound { .. }
298 | Self::UnknownSegment { .. }
299 | Self::IndexOutOfBounds { .. }
300 | Self::UnregisteredArray { .. } => "document_path_not_found",
301 Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
302 Self::SlugNotFound { .. } => "document_slug_not_found",
303 Self::AmbiguousMatch { .. } => "document_ambiguous_match",
304 Self::SlugAlreadyExists { .. } => "document_slug_exists",
305 Self::EmptyInput { .. } => "document_empty",
306 Self::IoError { .. } => "document_io_failed",
307 Self::UnsupportedOperation { .. } => "document_unsupported_operation",
308 Self::EmptyPath | Self::EmptyValues | Self::InvalidArgument { .. } => {
309 "document_invalid_argument"
310 }
311 }
312 }
313
314 #[must_use]
323 pub fn location(&self) -> Option<String> {
324 let Self::ParseError { detail, .. } = self else {
325 return None;
326 };
327 let head = detail.split('\n').next().unwrap_or(detail);
338 let rest = match head.split_once(" at line ") {
339 Some((_, rest)) => rest,
340 None => head.strip_prefix("line ")?,
341 };
342 let line = leading_ascii_digits(rest)?;
343 let column = rest
344 .split_once("column ")
345 .and_then(|(_, tail)| leading_ascii_digits(tail));
346 Some(match column {
347 Some(column) => format!("line {line} column {column}"),
348 None => format!("line {line}"),
349 })
350 }
351
352 #[must_use]
380 pub fn redacted_message(&self) -> String {
381 match self {
382 #[cfg(feature = "plist")]
385 Self::ParseError { detail, .. }
386 if detail == crate::document::format::plist::WRONG_FORMAT_DETAIL
387 || detail == crate::document::format::plist::WRONG_FORMAT_XML_DETAIL =>
388 {
389 self.to_string()
390 }
391 Self::ParseError { format, .. } => match self.location() {
392 Some(location) => format!("failed to parse {format} at {location}"),
393 None => format!("failed to parse {format}"),
394 },
395 Self::TypeMismatch { path, expected, .. } => {
396 if expected.is_empty() {
397 format!("field `{path}` has the wrong type")
398 } else {
399 format!("field `{path}` expects {expected}")
400 }
401 }
402 other => other.to_string(),
403 }
404 }
405
406 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
410 let msg = err.to_string();
411 let hint = msg
414 .split(" at line ")
415 .next()
416 .unwrap_or(&msg)
417 .trim()
418 .to_string();
419 DocumentError::TypeMismatch {
420 path: path.into(),
421 expected: String::new(),
422 got: hint,
423 hint: None,
424 }
425 }
426}
427
428impl From<io::Error> for DocumentError {
429 fn from(err: io::Error) -> Self {
430 DocumentError::IoError {
431 detail: err.to_string(),
432 }
433 }
434}
435
436fn leading_ascii_digits(text: &str) -> Option<&str> {
437 let end = text.bytes().take_while(u8::is_ascii_digit).count();
438 (end > 0).then_some(&text[..end])
439}
440
441#[cfg(test)]
442mod tests {
443 use super::DocumentError;
444
445 #[test]
446 fn document_error_codes_are_stable() {
447 let cases = [
448 (DocumentError::EmptyPath, "document_invalid_argument"),
449 (DocumentError::EmptyValues, "document_invalid_argument"),
450 (
451 DocumentError::UnknownSegment {
452 path: "root.key".to_string(),
453 segment: "key".to_string(),
454 },
455 "document_path_not_found",
456 ),
457 (
458 DocumentError::UnregisteredArray {
459 path: "items".to_string(),
460 },
461 "document_path_not_found",
462 ),
463 (
464 DocumentError::SlugNotFound {
465 prefix: "items".to_string(),
466 slug: "missing".to_string(),
467 },
468 "document_slug_not_found",
469 ),
470 (
471 DocumentError::SlugAlreadyExists {
472 prefix: "items".to_string(),
473 slug: "existing".to_string(),
474 },
475 "document_slug_exists",
476 ),
477 (
478 DocumentError::AmbiguousMatch {
479 prefix: "items".to_string(),
480 segment: "look".to_string(),
481 indices: vec![0, 2],
482 },
483 "document_ambiguous_match",
484 ),
485 (
486 DocumentError::NotTraversable {
487 path: "root".to_string(),
488 got: "string".to_string(),
489 },
490 "document_type_mismatch",
491 ),
492 (
493 DocumentError::TypeMismatch {
494 path: "root.key".to_string(),
495 expected: "integer".to_string(),
496 got: "string".to_string(),
497 hint: None,
498 },
499 "document_type_mismatch",
500 ),
501 (
502 DocumentError::PathNotFound {
503 path: "root.key".to_string(),
504 },
505 "document_path_not_found",
506 ),
507 (
508 DocumentError::EmptyInput {
509 source: "stdin".to_string(),
510 },
511 "document_empty",
512 ),
513 (
514 DocumentError::IndexOutOfBounds {
515 path: "items".to_string(),
516 index: 2,
517 len: 1,
518 },
519 "document_path_not_found",
520 ),
521 (
522 DocumentError::ParseError {
523 format: "JSON".to_string(),
524 detail: "invalid input".to_string(),
525 },
526 "document_parse_failed",
527 ),
528 (
529 DocumentError::IoError {
530 detail: "unreadable".to_string(),
531 },
532 "document_io_failed",
533 ),
534 (
535 DocumentError::AlreadyExists {
536 path: "config.toml".to_string(),
537 },
538 "document_target_exists",
539 ),
540 (
541 DocumentError::TooLarge {
542 path: "config.toml".to_string(),
543 max_bytes: 1024,
544 },
545 "document_too_large",
546 ),
547 (
548 DocumentError::UnsupportedOperation {
549 format: "INI".to_string(),
550 operation: "set".to_string(),
551 detail: "unsupported".to_string(),
552 },
553 "document_unsupported_operation",
554 ),
555 ];
556
557 for (error, expected) in cases {
558 assert_eq!(error.code(), expected);
559 }
560 }
561
562 #[test]
563 fn location_extracts_position_without_content() {
564 let err = DocumentError::ParseError {
570 format: "TOML".to_string(),
571 detail: "TOML parse error at line 5, column 12\n |\n\
572 5 | note = \"see at line 999 for TOPSECRET\" bad\n | ^"
573 .to_string(),
574 };
575 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
576 assert!(!err.redacted_message().contains("999"));
577 assert!(!err.redacted_message().contains("TOPSECRET"));
578
579 let no_column = DocumentError::ParseError {
580 format: "JSON".to_string(),
581 detail: "boom at line 3".to_string(),
582 };
583 assert_eq!(no_column.location().as_deref(), Some("line 3"));
584
585 assert!(
587 DocumentError::ParseError {
588 format: "INI".to_string(),
589 detail: "sensitive value".to_string(),
590 }
591 .location()
592 .is_none()
593 );
594 assert!(
595 DocumentError::PathNotFound {
596 path: "a.b".to_string(),
597 }
598 .location()
599 .is_none()
600 );
601 }
602
603 #[test]
604 fn redacted_message_drops_parser_detail() {
605 let err = DocumentError::ParseError {
606 format: "YAML".to_string(),
607 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
608 };
609 let redacted = err.redacted_message();
610 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
611 assert!(!redacted.contains("TOPSECRET"));
612
613 let path_err = DocumentError::PathNotFound {
615 path: "database.url".to_string(),
616 };
617 assert_eq!(path_err.redacted_message(), path_err.to_string());
618 }
619
620 #[test]
621 fn redacted_message_drops_the_offending_value() {
622 let err = DocumentError::from_serde(
625 "credentials.token",
626 "invalid type: string \"sk-live-TOPSECRET\", expected u16",
627 );
628 assert!(err.to_string().contains("sk-live-TOPSECRET"));
629 let redacted = err.redacted_message();
630 assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
631 assert!(redacted.contains("credentials.token"), "{redacted}");
632 }
633
634 #[test]
635 fn ambiguous_match_reports_indices_without_document_content() {
636 let err = DocumentError::AmbiguousMatch {
637 prefix: "h1.0.h2".to_string(),
638 segment: "look".to_string(),
639 indices: vec![0, 2],
640 };
641 assert_eq!(
642 err.redacted_message(),
643 "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
644 );
645 assert!(!err.redacted_message().contains("Quick look"));
646 }
647
648 #[test]
649 fn not_traversable_names_the_type_not_the_value() {
650 let err = DocumentError::NotTraversable {
653 path: "token_secret.inner".to_string(),
654 got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
655 .kind_name()
656 .to_string(),
657 };
658 assert_eq!(
659 err.to_string(),
660 "path `token_secret.inner` is string, cannot traverse further"
661 );
662 assert_eq!(err.redacted_message(), err.to_string());
663 }
664
665 #[test]
666 fn not_traversable_at_the_root_names_the_document() {
667 let empty = DocumentError::NotTraversable {
668 path: String::new(),
669 got: "null".to_string(),
670 };
671 assert_eq!(
672 empty.to_string(),
673 "the document is empty (its root is null), cannot traverse into it"
674 );
675 let scalar = DocumentError::NotTraversable {
676 path: String::new(),
677 got: "string".to_string(),
678 };
679 assert_eq!(
680 scalar.to_string(),
681 "the document root is string, cannot traverse into it"
682 );
683 }
684}