agent_first_data/document/error.rs
1//! Error types with context and helpful hints.
2
3use 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 /// A non-numeric segment addressed an array that nothing claims: no
17 /// [`KeyedList`](crate::document::KeyedList) registration covers it and its
18 /// format states no rule of its own.
19 ///
20 /// The message names the two ways out rather than the internal type that
21 /// happens to be missing — a caller can supply a rule or use an index, and
22 /// neither is discoverable from the name of a Rust struct.
23 UnregisteredArray {
24 path: String,
25 },
26 SlugNotFound {
27 prefix: String,
28 slug: String,
29 },
30 /// A non-numeric segment matched several elements of the array at `prefix`.
31 ///
32 /// Substring matching can produce this, as can an explicit keyed-list field
33 /// when an externally authored document contains duplicate identities.
34 /// Naming several things at once is not an address, and picking the first
35 /// would silently answer a different question than the one asked, so it is
36 /// refused with structural candidate indices.
37 ///
38 /// Candidate text is deliberately absent: an error must not become a route
39 /// for document content to bypass normal output redaction.
40 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 /// A parser rejected the source. `detail` is the parser's own text, which
68 /// quotes the offending line — see [`Self::redacted_message`].
69 ParseError {
70 format: String,
71 detail: String,
72 },
73 /// A dot-path is malformed: a bad escape, a trailing `\`, a bare `*`, an
74 /// index past the platform's range.
75 ///
76 /// Distinct from [`Self::ParseError`] because nothing here came from the
77 /// document — the caller's own address is what failed to parse, and
78 /// `detail` is afdata's own words about it. Sharing a code with a rejected
79 /// *file* sent readers to inspect the wrong thing.
80 PathSyntax {
81 detail: String,
82 },
83 /// afdata declines to read a source its parser would accept, because it
84 /// cannot answer honestly about it.
85 ///
86 /// `detail` is authored here and names the way out; it holds no document
87 /// text, so [`Self::redacted_message`] keeps it. Distinct from
88 /// [`Self::ParseError`] because the file is not malformed — reporting it as
89 /// a parse failure sends the reader hunting for a syntax error that is not
90 /// there.
91 SourceRefused {
92 format: String,
93 detail: String,
94 },
95 /// A caller argument contradicts itself or the document. `detail` is
96 /// afdata's own words about the argument, never document content.
97 InvalidArgument {
98 detail: String,
99 },
100 /// A staged edit rendered source this format's own parser rejects, caught
101 /// by the read-back in `save_atomic` before any bytes reached disk.
102 ///
103 /// `detail` is already redacted: it comes from
104 /// [`Self::redacted_message`] of the rejection, not from its `Display`.
105 WriteWouldCorrupt {
106 format: String,
107 detail: String,
108 },
109 /// No format could be inferred for `path`, so nothing was parsed at all.
110 ///
111 /// Distinct from [`Self::ParseError`] because it is about the file's name,
112 /// never its contents: it carries no document text, and dropping its detail
113 /// as a precaution would throw away the only actionable thing it says.
114 FormatUnknown {
115 path: String,
116 },
117 /// A create-only commit found an existing target. Kept separate from
118 /// [`Self::IoError`] so callers can safely implement idempotent create
119 /// workflows without parsing platform-specific I/O messages.
120 AlreadyExists {
121 path: String,
122 },
123 /// A capped read found more bytes than the caller allowed. Kept separate
124 /// from [`Self::IoError`] for the same reason as [`Self::AlreadyExists`]: a
125 /// caller enforcing a size budget has to tell "too big" from "missing" or
126 /// "unreadable", and should not have to match on a message to do it.
127 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 /// Stable, program-decidable error code for this failure category.
259 ///
260 /// Multiple variants share a code only when callers should handle them in
261 /// the same way. An ordinary missing path is `document_path_not_found`;
262 /// named array lookup distinguishes a missing slug from an ambiguous one.
263 #[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 /// Best-effort, content-free source location for a parse failure.
290 ///
291 /// Returns e.g. `"line 5 column 12"` (or `"line 5"`) for a
292 /// [`DocumentError::ParseError`], and `None` for every other variant or
293 /// when the underlying parser reported no position. The returned string is
294 /// derived from the parser's position only and never contains document
295 /// content, so it is safe to surface even when the parsed file may hold
296 /// secrets.
297 #[must_use]
298 pub fn location(&self) -> Option<String> {
299 let Self::ParseError { detail, .. } = self else {
300 return None;
301 };
302 // A parser diagnostic opens with its own position and echoes the
303 // offending source on the lines below it:
304 //
305 // TOML parse error at line 2, column 5
306 // |
307 // 2 | note = "see at line 999 for details" bad
308 //
309 // So the position is on the first line and document content never is.
310 // Searching the whole detail — from either end — can read the echo:
311 // from the end it finds `at line 999`, which is the file's own text.
312 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 /// A display message with any potentially content-bearing detail removed —
337 /// safe to surface when the document may hold secrets.
338 ///
339 /// Two variants can quote material that originates in the document and are
340 /// rewritten here:
341 ///
342 /// - [`DocumentError::ParseError`] renders as `failed to parse {format}`
343 /// (with the [`location`](Self::location) appended when known), dropping
344 /// the parser detail, which echoes a snippet of the source.
345 ///
346 /// [`DocumentError::PathSyntax`], [`DocumentError::SourceRefused`] and
347 /// [`DocumentError::InvalidArgument`] keep their detail in full. It is the
348 /// reason they exist as separate variants: their text is written here, about
349 /// the caller's address, argument, or file *encoding* — never lifted from
350 /// document content — and it is the only part that says what to do next.
351 /// Dropping it as a precaution against a leak that cannot happen turned an
352 /// actionable refusal into `failed to parse Markdown`.
353 /// - [`DocumentError::TypeMismatch`] drops `got` and `hint`. When built by
354 /// [`Self::from_serde`] those carry serde's rendering of the offending
355 /// value, which is document content.
356 ///
357 /// Every other variant renders the same as its [`std::fmt::Display`], carrying only
358 /// structural context: paths, requested slugs, indices, and type or format
359 /// names. In particular, [`Self::AmbiguousMatch`] carries candidate indices
360 /// rather than matched field values.
361 /// [`DocumentError::NotTraversable`] belongs to that group because `got` is
362 /// a [`Value::kind_name`](crate::document::Value::kind_name), not a value.
363 #[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 /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
382 /// do a read-modify-write cycle (set_path → serde round-trip) surface a
383 /// consistent error style rather than a raw serde message.
384 pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
385 let msg = err.to_string();
386 // serde messages look like "invalid type: string \"x\", expected u16 at …"
387 // Strip the trailing " at line N column M" to keep the hint concise.
388 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 // The real layout a parser produces: its own position first, then the
529 // offending source echoed below. The echo here carries `at line 999`
530 // out of the document, and a search from the end reads exactly that —
531 // reporting a wrong line and leaking a document-derived number through
532 // `redacted_message`, which promises never to surface file content.
533 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 // No position, and non-parse variants, carry no location.
550 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 // Structural variants pass through unchanged.
578 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 // `from_serde` keeps serde's rendering, which quotes the value that
587 // failed the type check — document content, and a secret as often as not.
588 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 // This one is safe by construction rather than by redaction: `got` is a
615 // kind name, so even `Display` cannot echo the leaf.
616 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}