Skip to main content

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    /// The source held zero bytes and its format has no empty document (an
132    /// empty JSON text is not JSON). `source` names where the bytes came from
133    /// (`stdin` or a path) so the reader looks at the producer, not at syntax.
134    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            // An empty prefix is the document itself: name it, and say what an empty
193            // document parses to, rather than print an empty pair of backticks.
194            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    /// Stable, program-decidable error code for this failure category.
283    ///
284    /// Multiple variants share a code only when callers should handle them in
285    /// the same way. An ordinary missing path is `document_path_not_found`;
286    /// named array lookup distinguishes a missing slug from an ambiguous one.
287    #[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    /// Best-effort, content-free source location for a parse failure.
315    ///
316    /// Returns e.g. `"line 5 column 12"` (or `"line 5"`) for a
317    /// [`DocumentError::ParseError`], and `None` for every other variant or
318    /// when the underlying parser reported no position. The returned string is
319    /// derived from the parser's position only and never contains document
320    /// content, so it is safe to surface even when the parsed file may hold
321    /// secrets.
322    #[must_use]
323    pub fn location(&self) -> Option<String> {
324        let Self::ParseError { detail, .. } = self else {
325            return None;
326        };
327        // A parser diagnostic opens with its own position and echoes the
328        // offending source on the lines below it:
329        //
330        //     TOML parse error at line 2, column 5
331        //       |
332        //     2 | note = "see at line 999 for details" bad
333        //
334        // So the position is on the first line and document content never is.
335        // Searching the whole detail — from either end — can read the echo:
336        // from the end it finds `at line 999`, which is the file's own text.
337        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    /// A display message with any potentially content-bearing detail removed —
353    /// safe to surface when the document may hold secrets.
354    ///
355    /// Two variants can quote material that originates in the document and are
356    /// rewritten here:
357    ///
358    /// - [`DocumentError::ParseError`] renders as `failed to parse {format}`
359    ///   (with the [`location`](Self::location) appended when known), dropping
360    ///   the parser detail, which echoes a snippet of the source.
361    ///
362    /// [`DocumentError::PathSyntax`], [`DocumentError::SourceRefused`] and
363    /// [`DocumentError::InvalidArgument`] keep their detail in full. It is the
364    /// reason they exist as separate variants: their text is written here, about
365    /// the caller's address, argument, or file *encoding* — never lifted from
366    /// document content — and it is the only part that says what to do next.
367    /// Dropping it as a precaution against a leak that cannot happen turned an
368    /// actionable refusal into `failed to parse Markdown`.
369    /// - [`DocumentError::TypeMismatch`] drops `got` and `hint`. When built by
370    ///   [`Self::from_serde`] those carry serde's rendering of the offending
371    ///   value, which is document content.
372    ///
373    /// Every other variant renders the same as its [`std::fmt::Display`], carrying only
374    /// structural context: paths, requested slugs, indices, and type or format
375    /// names. In particular, [`Self::AmbiguousMatch`] carries candidate indices
376    /// rather than matched field values.
377    /// [`DocumentError::NotTraversable`] belongs to that group because `got` is
378    /// a [`Value::kind_name`](crate::document::Value::kind_name), not a value.
379    #[must_use]
380    pub fn redacted_message(&self) -> String {
381        match self {
382            // afdata's own sentence about a binary property list read as the
383            // wrong format: it names the flag and quotes nothing from the file.
384            #[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    /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
407    /// do a read-modify-write cycle (set_path → serde round-trip) surface a
408    /// consistent error style rather than a raw serde message.
409    pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
410        let msg = err.to_string();
411        // serde messages look like "invalid type: string \"x\", expected u16 at …"
412        // Strip the trailing " at line N column M" to keep the hint concise.
413        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        // The real layout a parser produces: its own position first, then the
565        // offending source echoed below. The echo here carries `at line 999`
566        // out of the document, and a search from the end reads exactly that —
567        // reporting a wrong line and leaking a document-derived number through
568        // `redacted_message`, which promises never to surface file content.
569        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        // No position, and non-parse variants, carry no location.
586        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        // Structural variants pass through unchanged.
614        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        // `from_serde` keeps serde's rendering, which quotes the value that
623        // failed the type check — document content, and a secret as often as not.
624        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        // This one is safe by construction rather than by redaction: `got` is a
651        // kind name, so even `Display` cannot echo the leaf.
652        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}