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 format from file 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.find(" at line ") {
314 Some(start) => &head[start + " at line ".len()..],
315 None => head.strip_prefix("line ")?,
316 };
317 let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
318 if line.is_empty() {
319 return None;
320 }
321 let column = rest
322 .find("column ")
323 .map(|start| &rest[start + 7..])
324 .map(|tail| {
325 tail.chars()
326 .take_while(char::is_ascii_digit)
327 .collect::<String>()
328 })
329 .filter(|value| !value.is_empty());
330 Some(match column {
331 Some(column) => format!("line {line} column {column}"),
332 None => format!("line {line}"),
333 })
334 }
335
336 #[must_use]
364 pub fn redacted_message(&self) -> String {
365 match self {
366 Self::ParseError { format, .. } => match self.location() {
367 Some(location) => format!("failed to parse {format} at {location}"),
368 None => format!("failed to parse {format}"),
369 },
370 Self::TypeMismatch { path, expected, .. } => {
371 if expected.is_empty() {
372 format!("field `{path}` has the wrong type")
373 } else {
374 format!("field `{path}` expects {expected}")
375 }
376 }
377 other => other.to_string(),
378 }
379 }
380
381 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
385 let msg = err.to_string();
386 let hint = msg
389 .split(" at line ")
390 .next()
391 .unwrap_or(&msg)
392 .trim()
393 .to_string();
394 DocumentError::TypeMismatch {
395 path: path.into(),
396 expected: String::new(),
397 got: hint,
398 hint: None,
399 }
400 }
401}
402
403impl From<io::Error> for DocumentError {
404 fn from(err: io::Error) -> Self {
405 DocumentError::IoError {
406 detail: err.to_string(),
407 }
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::DocumentError;
414
415 #[test]
416 fn document_error_codes_are_stable() {
417 let cases = [
418 (DocumentError::EmptyPath, "document_invalid_argument"),
419 (DocumentError::EmptyValues, "document_invalid_argument"),
420 (
421 DocumentError::UnknownSegment {
422 path: "root.key".to_string(),
423 segment: "key".to_string(),
424 },
425 "document_path_not_found",
426 ),
427 (
428 DocumentError::UnregisteredArray {
429 path: "items".to_string(),
430 },
431 "document_path_not_found",
432 ),
433 (
434 DocumentError::SlugNotFound {
435 prefix: "items".to_string(),
436 slug: "missing".to_string(),
437 },
438 "document_slug_not_found",
439 ),
440 (
441 DocumentError::SlugAlreadyExists {
442 prefix: "items".to_string(),
443 slug: "existing".to_string(),
444 },
445 "document_slug_exists",
446 ),
447 (
448 DocumentError::AmbiguousMatch {
449 prefix: "items".to_string(),
450 segment: "look".to_string(),
451 indices: vec![0, 2],
452 },
453 "document_ambiguous_match",
454 ),
455 (
456 DocumentError::NotTraversable {
457 path: "root".to_string(),
458 got: "string".to_string(),
459 },
460 "document_type_mismatch",
461 ),
462 (
463 DocumentError::TypeMismatch {
464 path: "root.key".to_string(),
465 expected: "integer".to_string(),
466 got: "string".to_string(),
467 hint: None,
468 },
469 "document_type_mismatch",
470 ),
471 (
472 DocumentError::PathNotFound {
473 path: "root.key".to_string(),
474 },
475 "document_path_not_found",
476 ),
477 (
478 DocumentError::IndexOutOfBounds {
479 path: "items".to_string(),
480 index: 2,
481 len: 1,
482 },
483 "document_path_not_found",
484 ),
485 (
486 DocumentError::ParseError {
487 format: "JSON".to_string(),
488 detail: "invalid input".to_string(),
489 },
490 "document_parse_failed",
491 ),
492 (
493 DocumentError::IoError {
494 detail: "unreadable".to_string(),
495 },
496 "document_io_failed",
497 ),
498 (
499 DocumentError::AlreadyExists {
500 path: "config.toml".to_string(),
501 },
502 "document_target_exists",
503 ),
504 (
505 DocumentError::TooLarge {
506 path: "config.toml".to_string(),
507 max_bytes: 1024,
508 },
509 "document_too_large",
510 ),
511 (
512 DocumentError::UnsupportedOperation {
513 format: "INI".to_string(),
514 operation: "set".to_string(),
515 detail: "unsupported".to_string(),
516 },
517 "document_unsupported_operation",
518 ),
519 ];
520
521 for (error, expected) in cases {
522 assert_eq!(error.code(), expected);
523 }
524 }
525
526 #[test]
527 fn location_extracts_position_without_content() {
528 let err = DocumentError::ParseError {
534 format: "TOML".to_string(),
535 detail: "TOML parse error at line 5, column 12\n |\n\
536 5 | note = \"see at line 999 for TOPSECRET\" bad\n | ^"
537 .to_string(),
538 };
539 assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
540 assert!(!err.redacted_message().contains("999"));
541 assert!(!err.redacted_message().contains("TOPSECRET"));
542
543 let no_column = DocumentError::ParseError {
544 format: "JSON".to_string(),
545 detail: "boom at line 3".to_string(),
546 };
547 assert_eq!(no_column.location().as_deref(), Some("line 3"));
548
549 assert!(
551 DocumentError::ParseError {
552 format: "INI".to_string(),
553 detail: "sensitive value".to_string(),
554 }
555 .location()
556 .is_none()
557 );
558 assert!(
559 DocumentError::PathNotFound {
560 path: "a.b".to_string(),
561 }
562 .location()
563 .is_none()
564 );
565 }
566
567 #[test]
568 fn redacted_message_drops_parser_detail() {
569 let err = DocumentError::ParseError {
570 format: "YAML".to_string(),
571 detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
572 };
573 let redacted = err.redacted_message();
574 assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
575 assert!(!redacted.contains("TOPSECRET"));
576
577 let path_err = DocumentError::PathNotFound {
579 path: "database.url".to_string(),
580 };
581 assert_eq!(path_err.redacted_message(), path_err.to_string());
582 }
583
584 #[test]
585 fn redacted_message_drops_the_offending_value() {
586 let err = DocumentError::from_serde(
589 "credentials.token",
590 "invalid type: string \"sk-live-TOPSECRET\", expected u16",
591 );
592 assert!(err.to_string().contains("sk-live-TOPSECRET"));
593 let redacted = err.redacted_message();
594 assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
595 assert!(redacted.contains("credentials.token"), "{redacted}");
596 }
597
598 #[test]
599 fn ambiguous_match_reports_indices_without_document_content() {
600 let err = DocumentError::AmbiguousMatch {
601 prefix: "h1.0.h2".to_string(),
602 segment: "look".to_string(),
603 indices: vec![0, 2],
604 };
605 assert_eq!(
606 err.redacted_message(),
607 "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
608 );
609 assert!(!err.redacted_message().contains("Quick look"));
610 }
611
612 #[test]
613 fn not_traversable_names_the_type_not_the_value() {
614 let err = DocumentError::NotTraversable {
617 path: "token_secret.inner".to_string(),
618 got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
619 .kind_name()
620 .to_string(),
621 };
622 assert_eq!(
623 err.to_string(),
624 "path `token_secret.inner` is string, cannot traverse further"
625 );
626 assert_eq!(err.redacted_message(), err.to_string());
627 }
628}