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    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 [`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}