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    IoError {
118        detail: String,
119    },
120    UnsupportedOperation {
121        format: String,
122        operation: String,
123        detail: String,
124    },
125}
126
127impl fmt::Display for DocumentError {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match self {
130            DocumentError::EmptyPath => {
131                write!(f, "empty path provided")
132            }
133            DocumentError::EmptyValues => {
134                write!(f, "at least one value required")
135            }
136            DocumentError::UnknownSegment { path, segment } => {
137                write!(f, "path `{}` segment `{}` not found", path, segment)
138            }
139            DocumentError::UnregisteredArray { path } => {
140                write!(
141                    f,
142                    "array at `{}` has no rule for naming an element by its content; \
143                     address an element by index, or name the field that identifies one",
144                    path
145                )
146            }
147            DocumentError::SlugNotFound { prefix, slug } => {
148                write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
149            }
150            DocumentError::SlugAlreadyExists { prefix, slug } => {
151                write!(f, "slug `{}` already exists in `{}`", slug, prefix)
152            }
153            DocumentError::AmbiguousMatch {
154                prefix,
155                segment,
156                indices,
157            } => {
158                let candidates = indices
159                    .iter()
160                    .map(usize::to_string)
161                    .collect::<Vec<_>>()
162                    .join(", ");
163                write!(
164                    f,
165                    "segment `{}` matches {} elements of `{}` at indices {}",
166                    segment,
167                    indices.len(),
168                    prefix,
169                    candidates
170                )
171            }
172            DocumentError::NotTraversable { path, got } => {
173                write!(f, "path `{}` is {}, cannot traverse further", path, got)
174            }
175            DocumentError::TypeMismatch {
176                path,
177                expected,
178                got,
179                hint,
180            } => {
181                write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
182                if let Some(h) = hint {
183                    write!(f, "\n  hint: {}", h)?;
184                }
185                Ok(())
186            }
187            DocumentError::PathNotFound { path } => {
188                write!(f, "path `{}` not found in document", path)
189            }
190            DocumentError::IndexOutOfBounds { path, index, len } => {
191                write!(
192                    f,
193                    "index {} out of bounds at `{}` (len {})",
194                    index, path, len
195                )
196            }
197            DocumentError::ParseError { format, detail } => {
198                write!(f, "failed to parse {}: {}", format, detail)
199            }
200            DocumentError::PathSyntax { detail } => {
201                write!(f, "invalid path: {}", detail)
202            }
203            DocumentError::SourceRefused { format, detail } => {
204                write!(f, "refusing to read this {}: {}", format, detail)
205            }
206            DocumentError::InvalidArgument { detail } => {
207                write!(f, "invalid argument: {}", detail)
208            }
209            DocumentError::WriteWouldCorrupt { format, detail } => {
210                write!(
211                    f,
212                    "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
213                    format, detail
214                )
215            }
216            DocumentError::FormatUnknown { path } => {
217                write!(
218                    f,
219                    "cannot detect format from file extension `{}`; pass an explicit format",
220                    path
221                )
222            }
223            DocumentError::IoError { detail } => {
224                write!(f, "io error: {}", detail)
225            }
226            DocumentError::UnsupportedOperation {
227                format,
228                operation,
229                detail,
230            } => write!(f, "{} does not support {}: {}", format, operation, detail),
231        }
232    }
233}
234
235impl std::error::Error for DocumentError {}
236
237impl DocumentError {
238    /// Stable, program-decidable error code for this failure category.
239    ///
240    /// Multiple variants share a code only when callers should handle them in
241    /// the same way. An ordinary missing path is `document_path_not_found`;
242    /// named array lookup distinguishes a missing slug from an ambiguous one.
243    #[must_use]
244    pub const fn code(&self) -> &'static str {
245        match self {
246            Self::ParseError { .. } => "document_parse_failed",
247            Self::PathSyntax { .. } => "document_invalid_path",
248            Self::SourceRefused { .. } => "document_source_refused",
249            Self::FormatUnknown { .. } => "document_format_unknown",
250            Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
251            Self::PathNotFound { .. }
252            | Self::UnknownSegment { .. }
253            | Self::IndexOutOfBounds { .. }
254            | Self::UnregisteredArray { .. } => "document_path_not_found",
255            Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
256            Self::SlugNotFound { .. } => "document_slug_not_found",
257            Self::AmbiguousMatch { .. } => "document_ambiguous_match",
258            Self::SlugAlreadyExists { .. } => "document_slug_exists",
259            Self::IoError { .. } => "document_io_failed",
260            Self::UnsupportedOperation { .. } => "document_unsupported_operation",
261            Self::EmptyPath | Self::EmptyValues | Self::InvalidArgument { .. } => {
262                "document_invalid_argument"
263            }
264        }
265    }
266
267    /// Best-effort, content-free source location for a parse failure.
268    ///
269    /// Returns e.g. `"line 5 column 12"` (or `"line 5"`) for a
270    /// [`DocumentError::ParseError`], and `None` for every other variant or
271    /// when the underlying parser reported no position. The returned string is
272    /// derived from the parser's position only and never contains document
273    /// content, so it is safe to surface even when the parsed file may hold
274    /// secrets.
275    #[must_use]
276    pub fn location(&self) -> Option<String> {
277        let Self::ParseError { detail, .. } = self else {
278            return None;
279        };
280        // A parser diagnostic opens with its own position and echoes the
281        // offending source on the lines below it:
282        //
283        //     TOML parse error at line 2, column 5
284        //       |
285        //     2 | note = "see at line 999 for details" bad
286        //
287        // So the position is on the first line and document content never is.
288        // Searching the whole detail — from either end — can read the echo:
289        // from the end it finds `at line 999`, which is the file's own text.
290        let head = detail.split('\n').next().unwrap_or(detail);
291        let rest = match head.find(" at line ") {
292            Some(start) => &head[start + " at line ".len()..],
293            None => head.strip_prefix("line ")?,
294        };
295        let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
296        if line.is_empty() {
297            return None;
298        }
299        let column = rest
300            .find("column ")
301            .map(|start| &rest[start + 7..])
302            .map(|tail| {
303                tail.chars()
304                    .take_while(char::is_ascii_digit)
305                    .collect::<String>()
306            })
307            .filter(|value| !value.is_empty());
308        Some(match column {
309            Some(column) => format!("line {line} column {column}"),
310            None => format!("line {line}"),
311        })
312    }
313
314    /// A display message with any potentially content-bearing detail removed —
315    /// safe to surface when the document may hold secrets.
316    ///
317    /// Two variants can quote material that originates in the document and are
318    /// rewritten here:
319    ///
320    /// - [`DocumentError::ParseError`] renders as `failed to parse {format}`
321    ///   (with the [`location`](Self::location) appended when known), dropping
322    ///   the parser detail, which echoes a snippet of the source.
323    ///
324    /// [`DocumentError::PathSyntax`], [`DocumentError::SourceRefused`] and
325    /// [`DocumentError::InvalidArgument`] keep their detail in full. It is the
326    /// reason they exist as separate variants: their text is written here, about
327    /// the caller's address, argument, or file *encoding* — never lifted from
328    /// document content — and it is the only part that says what to do next.
329    /// Dropping it as a precaution against a leak that cannot happen turned an
330    /// actionable refusal into `failed to parse Markdown`.
331    /// - [`DocumentError::TypeMismatch`] drops `got` and `hint`. When built by
332    ///   [`Self::from_serde`] those carry serde's rendering of the offending
333    ///   value, which is document content.
334    ///
335    /// Every other variant renders the same as its [`Display`], carrying only
336    /// structural context: paths, requested slugs, indices, and type or format
337    /// names. In particular, [`Self::AmbiguousMatch`] carries candidate indices
338    /// rather than matched field values.
339    /// [`DocumentError::NotTraversable`] belongs to that group because `got` is
340    /// a [`Value::kind_name`](crate::document::Value::kind_name), not a value.
341    #[must_use]
342    pub fn redacted_message(&self) -> String {
343        match self {
344            Self::ParseError { format, .. } => match self.location() {
345                Some(location) => format!("failed to parse {format} at {location}"),
346                None => format!("failed to parse {format}"),
347            },
348            Self::TypeMismatch { path, expected, .. } => {
349                if expected.is_empty() {
350                    format!("field `{path}` has the wrong type")
351                } else {
352                    format!("field `{path}` expects {expected}")
353                }
354            }
355            other => other.to_string(),
356        }
357    }
358
359    /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
360    /// do a read-modify-write cycle (set_path → serde round-trip) surface a
361    /// consistent error style rather than a raw serde message.
362    pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
363        let msg = err.to_string();
364        // serde messages look like "invalid type: string \"x\", expected u16 at …"
365        // Strip the trailing " at line N column M" to keep the hint concise.
366        let hint = msg
367            .split(" at line ")
368            .next()
369            .unwrap_or(&msg)
370            .trim()
371            .to_string();
372        DocumentError::TypeMismatch {
373            path: path.into(),
374            expected: String::new(),
375            got: hint,
376            hint: None,
377        }
378    }
379}
380
381impl From<io::Error> for DocumentError {
382    fn from(err: io::Error) -> Self {
383        DocumentError::IoError {
384            detail: err.to_string(),
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::DocumentError;
392
393    #[test]
394    fn document_error_codes_are_stable() {
395        let cases = [
396            (DocumentError::EmptyPath, "document_invalid_argument"),
397            (DocumentError::EmptyValues, "document_invalid_argument"),
398            (
399                DocumentError::UnknownSegment {
400                    path: "root.key".to_string(),
401                    segment: "key".to_string(),
402                },
403                "document_path_not_found",
404            ),
405            (
406                DocumentError::UnregisteredArray {
407                    path: "items".to_string(),
408                },
409                "document_path_not_found",
410            ),
411            (
412                DocumentError::SlugNotFound {
413                    prefix: "items".to_string(),
414                    slug: "missing".to_string(),
415                },
416                "document_slug_not_found",
417            ),
418            (
419                DocumentError::SlugAlreadyExists {
420                    prefix: "items".to_string(),
421                    slug: "existing".to_string(),
422                },
423                "document_slug_exists",
424            ),
425            (
426                DocumentError::AmbiguousMatch {
427                    prefix: "items".to_string(),
428                    segment: "look".to_string(),
429                    indices: vec![0, 2],
430                },
431                "document_ambiguous_match",
432            ),
433            (
434                DocumentError::NotTraversable {
435                    path: "root".to_string(),
436                    got: "string".to_string(),
437                },
438                "document_type_mismatch",
439            ),
440            (
441                DocumentError::TypeMismatch {
442                    path: "root.key".to_string(),
443                    expected: "integer".to_string(),
444                    got: "string".to_string(),
445                    hint: None,
446                },
447                "document_type_mismatch",
448            ),
449            (
450                DocumentError::PathNotFound {
451                    path: "root.key".to_string(),
452                },
453                "document_path_not_found",
454            ),
455            (
456                DocumentError::IndexOutOfBounds {
457                    path: "items".to_string(),
458                    index: 2,
459                    len: 1,
460                },
461                "document_path_not_found",
462            ),
463            (
464                DocumentError::ParseError {
465                    format: "JSON".to_string(),
466                    detail: "invalid input".to_string(),
467                },
468                "document_parse_failed",
469            ),
470            (
471                DocumentError::IoError {
472                    detail: "unreadable".to_string(),
473                },
474                "document_io_failed",
475            ),
476            (
477                DocumentError::UnsupportedOperation {
478                    format: "INI".to_string(),
479                    operation: "set".to_string(),
480                    detail: "unsupported".to_string(),
481                },
482                "document_unsupported_operation",
483            ),
484        ];
485
486        for (error, expected) in cases {
487            assert_eq!(error.code(), expected);
488        }
489    }
490
491    #[test]
492    fn location_extracts_position_without_content() {
493        // The real layout a parser produces: its own position first, then the
494        // offending source echoed below. The echo here carries `at line 999`
495        // out of the document, and a search from the end reads exactly that —
496        // reporting a wrong line and leaking a document-derived number through
497        // `redacted_message`, which promises never to surface file content.
498        let err = DocumentError::ParseError {
499            format: "TOML".to_string(),
500            detail: "TOML parse error at line 5, column 12\n  |\n\
501                     5 | note = \"see at line 999 for TOPSECRET\" bad\n  |     ^"
502                .to_string(),
503        };
504        assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
505        assert!(!err.redacted_message().contains("999"));
506        assert!(!err.redacted_message().contains("TOPSECRET"));
507
508        let no_column = DocumentError::ParseError {
509            format: "JSON".to_string(),
510            detail: "boom at line 3".to_string(),
511        };
512        assert_eq!(no_column.location().as_deref(), Some("line 3"));
513
514        // No position, and non-parse variants, carry no location.
515        assert!(
516            DocumentError::ParseError {
517                format: "INI".to_string(),
518                detail: "sensitive value".to_string(),
519            }
520            .location()
521            .is_none()
522        );
523        assert!(
524            DocumentError::PathNotFound {
525                path: "a.b".to_string(),
526            }
527            .location()
528            .is_none()
529        );
530    }
531
532    #[test]
533    fn redacted_message_drops_parser_detail() {
534        let err = DocumentError::ParseError {
535            format: "YAML".to_string(),
536            detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
537        };
538        let redacted = err.redacted_message();
539        assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
540        assert!(!redacted.contains("TOPSECRET"));
541
542        // Structural variants pass through unchanged.
543        let path_err = DocumentError::PathNotFound {
544            path: "database.url".to_string(),
545        };
546        assert_eq!(path_err.redacted_message(), path_err.to_string());
547    }
548
549    #[test]
550    fn redacted_message_drops_the_offending_value() {
551        // `from_serde` keeps serde's rendering, which quotes the value that
552        // failed the type check — document content, and a secret as often as not.
553        let err = DocumentError::from_serde(
554            "credentials.token",
555            "invalid type: string \"sk-live-TOPSECRET\", expected u16",
556        );
557        assert!(err.to_string().contains("sk-live-TOPSECRET"));
558        let redacted = err.redacted_message();
559        assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
560        assert!(redacted.contains("credentials.token"), "{redacted}");
561    }
562
563    #[test]
564    fn ambiguous_match_reports_indices_without_document_content() {
565        let err = DocumentError::AmbiguousMatch {
566            prefix: "h1.0.h2".to_string(),
567            segment: "look".to_string(),
568            indices: vec![0, 2],
569        };
570        assert_eq!(
571            err.redacted_message(),
572            "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
573        );
574        assert!(!err.redacted_message().contains("Quick look"));
575    }
576
577    #[test]
578    fn not_traversable_names_the_type_not_the_value() {
579        // This one is safe by construction rather than by redaction: `got` is a
580        // kind name, so even `Display` cannot echo the leaf.
581        let err = DocumentError::NotTraversable {
582            path: "token_secret.inner".to_string(),
583            got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
584                .kind_name()
585                .to_string(),
586        };
587        assert_eq!(
588            err.to_string(),
589            "path `token_secret.inner` is string, cannot traverse further"
590        );
591        assert_eq!(err.redacted_message(), err.to_string());
592    }
593}