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 IoError {
132 detail: String,
133 },
134 UnsupportedOperation {
135 format: String,
136 operation: String,
137 detail: String,
138 },
139}
140
141impl fmt::Display for DocumentError {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 DocumentError::EmptyPath => {
145 write!(f, "empty path provided")
146 }
147 DocumentError::EmptyValues => {
148 write!(f, "at least one value required")
149 }
150 DocumentError::UnknownSegment { path, segment } => {
151 write!(f, "path `{}` segment `{}` not found", path, segment)
152 }
153 DocumentError::UnregisteredArray { path } => {
154 write!(
155 f,
156 "array at `{}` has no rule for naming an element by its content; \
157 address an element by index, or name the field that identifies one",
158 path
159 )
160 }
161 DocumentError::SlugNotFound { prefix, slug } => {
162 write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
163 }
164 DocumentError::SlugAlreadyExists { prefix, slug } => {
165 write!(f, "slug `{}` already exists in `{}`", slug, prefix)
166 }
167 DocumentError::AmbiguousMatch {
168 prefix,
169 segment,
170 indices,
171 } => {
172 let candidates = indices
173 .iter()
174 .map(usize::to_string)
175 .collect::<Vec<_>>()
176 .join(", ");
177 write!(
178 f,
179 "segment `{}` matches {} elements of `{}` at indices {}",
180 segment,
181 indices.len(),
182 prefix,
183 candidates
184 )
185 }
186 DocumentError::NotTraversable { path, got } => {
187 write!(f, "path `{}` is {}, cannot traverse further", path, got)
188 }
189 DocumentError::TypeMismatch {
190 path,
191 expected,
192 got,
193 hint,
194 } => {
195 write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
196 if let Some(h) = hint {
197 write!(f, "\n hint: {}", h)?;
198 }
199 Ok(())
200 }
201 DocumentError::PathNotFound { path } => {
202 write!(f, "path `{}` not found in document", path)
203 }
204 DocumentError::IndexOutOfBounds { path, index, len } => {
205 write!(
206 f,
207 "index {} out of bounds at `{}` (len {})",
208 index, path, len
209 )
210 }
211 DocumentError::ParseError { format, detail } => {
212 write!(f, "failed to parse {}: {}", format, detail)
213 }
214 DocumentError::PathSyntax { detail } => {
215 write!(f, "invalid path: {}", detail)
216 }
217 DocumentError::SourceRefused { format, detail } => {
218 write!(f, "refusing to read this {}: {}", format, detail)
219 }
220 DocumentError::InvalidArgument { detail } => {
221 write!(f, "invalid argument: {}", detail)
222 }
223 DocumentError::WriteWouldCorrupt { format, detail } => {
224 write!(
225 f,
226 "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
227 format, detail
228 )
229 }
230 DocumentError::FormatUnknown { path } => {
231 write!(
232 f,
233 "cannot detect the format of `{}` from its extension; pass an explicit format",
234 path
235 )
236 }
237 DocumentError::AlreadyExists { path } => {
238 write!(f, "document target `{path}` already exists")
239 }
240 DocumentError::TooLarge { path, max_bytes } => {
241 write!(f, "`{path}` exceeds the {max_bytes}-byte read limit")
242 }
243 DocumentError::IoError { detail } => {
244 write!(f, "io error: {}", detail)
245 }
246 DocumentError::UnsupportedOperation {
247 format,
248 operation,
249 detail,
250 } => write!(f, "{} does not support {}: {}", format, operation, detail),
251 }
252 }
253}
254
255impl std::error::Error for DocumentError {}
256
257impl DocumentError {
258 #[must_use]
264 pub const fn code(&self) -> &'static str {
265 match self {
266 Self::ParseError { .. } => "document_parse_failed",
267 Self::PathSyntax { .. } => "document_invalid_path",
268 Self::SourceRefused { .. } => "document_source_refused",
269 Self::FormatUnknown { .. } => "document_format_unknown",
270 Self::AlreadyExists { .. } => "document_target_exists",
271 Self::TooLarge { .. } => "document_too_large",
272 Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
273 Self::PathNotFound { .. }
274 | Self::UnknownSegment { .. }
275 | Self::IndexOutOfBounds { .. }
276 | Self::UnregisteredArray { .. } => "document_path_not_found",
277 Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
278 Self::SlugNotFound { .. } => "document_slug_not_found",
279 Self::AmbiguousMatch { .. } => "document_ambiguous_match",
280 Self::SlugAlreadyExists { .. } => "document_slug_exists",
281 Self::IoError { .. } => "document_io_failed",
282 Self::UnsupportedOperation { .. } => "document_unsupported_operation",
283 Self::EmptyPath | Self::EmptyValues | Self::InvalidArgument { .. } => {
284 "document_invalid_argument"
285 }
286 }
287 }
288
289 #[must_use]
298 pub fn location(&self) -> Option<String> {
299 let Self::ParseError { detail, .. } = self else {
300 return None;
301 };
302 let head = detail.split('\n').next().unwrap_or(detail);
313 let rest = match head.split_once(" at line ") {
314 Some((_, rest)) => rest,
315 None => head.strip_prefix("line ")?,
316 };
317 let line = leading_ascii_digits(rest)?;
318 let column = rest
319 .split_once("column ")
320 .and_then(|(_, tail)| leading_ascii_digits(tail));
321 Some(match column {
322 Some(column) => format!("line {line} column {column}"),
323 None => format!("line {line}"),
324 })
325 }
326
327 #[must_use]
355 pub fn redacted_message(&self) -> String {
356 match self {
357 Self::ParseError { format, .. } => match self.location() {
358 Some(location) => format!("failed to parse {format} at {location}"),
359 None => format!("failed to parse {format}"),
360 },
361 Self::TypeMismatch { path, expected, .. } => {
362 if expected.is_empty() {
363 format!("field `{path}` has the wrong type")
364 } else {
365 format!("field `{path}` expects {expected}")
366 }
367 }
368 other => other.to_string(),
369 }
370 }
371
372 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
376 let msg = err.to_string();
377 let hint = msg
380 .split(" at line ")
381 .next()
382 .unwrap_or(&msg)
383 .trim()
384 .to_string();
385 DocumentError::TypeMismatch {
386 path: path.into(),
387 expected: String::new(),
388 got: hint,
389 hint: None,
390 }
391 }
392}
393
394impl From<io::Error> for DocumentError {
395 fn from(err: io::Error) -> Self {
396 DocumentError::IoError {
397 detail: err.to_string(),
398 }
399 }
400}
401
402fn leading_ascii_digits(text: &str) -> Option<&str> {
403 let end = text.bytes().take_while(u8::is_ascii_digit).count();
404 (end > 0).then_some(&text[..end])
405}
406
407#[cfg(test)]
408mod tests {
409 use super::DocumentError;
410
411 #[test]
412 fn document_error_codes_are_stable() {
413 let cases = [
414 (DocumentError::EmptyPath, "document_invalid_argument"),
415 (DocumentError::EmptyValues, "document_invalid_argument"),
416 (
417 DocumentError::UnknownSegment {
418 path: "root.key".to_string(),
419 segment: "key".to_string(),
420 },
421 "document_path_not_found",
422 ),
423 (
424 DocumentError::UnregisteredArray {
425 path: "items".to_string(),
426 },
427 "document_path_not_found",
428 ),
429 (
430 DocumentError::SlugNotFound {
431 prefix: "items".to_string(),
432 slug: "missing".to_string(),
433 },
434 "document_slug_not_found",
435 ),
436 (
437 DocumentError::SlugAlreadyExists {
438 prefix: "items".to_string(),
439 slug: "existing".to_string(),
440 },
441 "document_slug_exists",
442 ),
443 (
444 DocumentError::AmbiguousMatch {
445 prefix: "items".to_string(),
446 segment: "look".to_string(),
447 indices: vec![0, 2],
448 },
449 "document_ambiguous_match",
450 ),
451 (
452 DocumentError::NotTraversable {
453 path: "root".to_string(),
454 got: "string".to_string(),
455 },
456 "document_type_mismatch",
457 ),
458 (
459 DocumentError::TypeMismatch {
460 path: "root.key".to_string(),
461 expected: "integer".to_string(),
462 got: "string".to_string(),
463 hint: None,
464 },
465 "document_type_mismatch",
466 ),
467 (
468 DocumentError::PathNotFound {
469 path: "root.key".to_string(),
470 },
471 "document_path_not_found",
472 ),
473 (
474 DocumentError::IndexOutOfBounds {
475 path: "items".to_string(),
476 index: 2,
477 len: 1,
478 },
479 "document_path_not_found",
480 ),
481 (
482 DocumentError::ParseError {
483 format: "JSON".to_string(),
484 detail: "invalid input".to_string(),
485 },
486 "document_parse_failed",
487 ),
488 (
489 DocumentError::IoError {
490 detail: "unreadable".to_string(),
491 },
492 "document_io_failed",
493 ),
494 (
495 DocumentError::AlreadyExists {
496 path: "config.toml".to_string(),
497 },
498 "document_target_exists",
499 ),
500 (
501 DocumentError::TooLarge {
502 path: "config.toml".to_string(),
503 max_bytes: 1024,
504 },
505 "document_too_large",
506 ),
507 (
508 DocumentError::UnsupportedOperation {
509 format: "INI".to_string(),
510 operation: "set".to_string(),
511 detail: "unsupported".to_string(),
512 },
513 "document_unsupported_operation",
514 ),
515 ];
516
517 for (error, expected) in cases {
518 assert_eq!(error.code(), expected);
519 }
520 }
521
522 #[test]
523 fn location_extracts_position_without_content() {
524 let err = DocumentError::ParseError {
530 format: "TOML".to_string(),
531 detail: "TOML parse error at line 5, column 12\n |\n\
532 5 | note = \"see at line 999 for TOPSECRET\" bad\n | ^"
533 .to_string(),
534 };
535 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
536 assert!(!err.redacted_message().contains("999"));
537 assert!(!err.redacted_message().contains("TOPSECRET"));
538
539 let no_column = DocumentError::ParseError {
540 format: "JSON".to_string(),
541 detail: "boom at line 3".to_string(),
542 };
543 assert_eq!(no_column.location().as_deref(), Some("line 3"));
544
545 assert!(
547 DocumentError::ParseError {
548 format: "INI".to_string(),
549 detail: "sensitive value".to_string(),
550 }
551 .location()
552 .is_none()
553 );
554 assert!(
555 DocumentError::PathNotFound {
556 path: "a.b".to_string(),
557 }
558 .location()
559 .is_none()
560 );
561 }
562
563 #[test]
564 fn redacted_message_drops_parser_detail() {
565 let err = DocumentError::ParseError {
566 format: "YAML".to_string(),
567 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
568 };
569 let redacted = err.redacted_message();
570 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
571 assert!(!redacted.contains("TOPSECRET"));
572
573 let path_err = DocumentError::PathNotFound {
575 path: "database.url".to_string(),
576 };
577 assert_eq!(path_err.redacted_message(), path_err.to_string());
578 }
579
580 #[test]
581 fn redacted_message_drops_the_offending_value() {
582 let err = DocumentError::from_serde(
585 "credentials.token",
586 "invalid type: string \"sk-live-TOPSECRET\", expected u16",
587 );
588 assert!(err.to_string().contains("sk-live-TOPSECRET"));
589 let redacted = err.redacted_message();
590 assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
591 assert!(redacted.contains("credentials.token"), "{redacted}");
592 }
593
594 #[test]
595 fn ambiguous_match_reports_indices_without_document_content() {
596 let err = DocumentError::AmbiguousMatch {
597 prefix: "h1.0.h2".to_string(),
598 segment: "look".to_string(),
599 indices: vec![0, 2],
600 };
601 assert_eq!(
602 err.redacted_message(),
603 "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
604 );
605 assert!(!err.redacted_message().contains("Quick look"));
606 }
607
608 #[test]
609 fn not_traversable_names_the_type_not_the_value() {
610 let err = DocumentError::NotTraversable {
613 path: "token_secret.inner".to_string(),
614 got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
615 .kind_name()
616 .to_string(),
617 };
618 assert_eq!(
619 err.to_string(),
620 "path `token_secret.inner` is string, cannot traverse further"
621 );
622 assert_eq!(err.redacted_message(), err.to_string());
623 }
624}