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 the format of `{}` from its 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.split_once(" at line ") {
314            Some((_, rest)) => rest,
315            None => head.strip_prefix("line ")?,
316        };
317        let line = leading_ascii_digits(rest)?;
318        let column = rest
319            .split_once("column ")
320            .and_then(|(_, tail)| leading_ascii_digits(tail));
321        Some(match column {
322            Some(column) => format!("line {line} column {column}"),
323            None => format!("line {line}"),
324        })
325    }
326
327    /// A display message with any potentially content-bearing detail removed —
328    /// safe to surface when the document may hold secrets.
329    ///
330    /// Two variants can quote material that originates in the document and are
331    /// rewritten here:
332    ///
333    /// - [`DocumentError::ParseError`] renders as `failed to parse {format}`
334    ///   (with the [`location`](Self::location) appended when known), dropping
335    ///   the parser detail, which echoes a snippet of the source.
336    ///
337    /// [`DocumentError::PathSyntax`], [`DocumentError::SourceRefused`] and
338    /// [`DocumentError::InvalidArgument`] keep their detail in full. It is the
339    /// reason they exist as separate variants: their text is written here, about
340    /// the caller's address, argument, or file *encoding* — never lifted from
341    /// document content — and it is the only part that says what to do next.
342    /// Dropping it as a precaution against a leak that cannot happen turned an
343    /// actionable refusal into `failed to parse Markdown`.
344    /// - [`DocumentError::TypeMismatch`] drops `got` and `hint`. When built by
345    ///   [`Self::from_serde`] those carry serde's rendering of the offending
346    ///   value, which is document content.
347    ///
348    /// Every other variant renders the same as its [`std::fmt::Display`], carrying only
349    /// structural context: paths, requested slugs, indices, and type or format
350    /// names. In particular, [`Self::AmbiguousMatch`] carries candidate indices
351    /// rather than matched field values.
352    /// [`DocumentError::NotTraversable`] belongs to that group because `got` is
353    /// a [`Value::kind_name`](crate::document::Value::kind_name), not a value.
354    #[must_use]
355    pub fn redacted_message(&self) -> String {
356        match self {
357            Self::ParseError { format, .. } => match self.location() {
358                Some(location) => format!("failed to parse {format} at {location}"),
359                None => format!("failed to parse {format}"),
360            },
361            Self::TypeMismatch { path, expected, .. } => {
362                if expected.is_empty() {
363                    format!("field `{path}` has the wrong type")
364                } else {
365                    format!("field `{path}` expects {expected}")
366                }
367            }
368            other => other.to_string(),
369        }
370    }
371
372    /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
373    /// do a read-modify-write cycle (set_path → serde round-trip) surface a
374    /// consistent error style rather than a raw serde message.
375    pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
376        let msg = err.to_string();
377        // serde messages look like "invalid type: string \"x\", expected u16 at …"
378        // Strip the trailing " at line N column M" to keep the hint concise.
379        let hint = msg
380            .split(" at line ")
381            .next()
382            .unwrap_or(&msg)
383            .trim()
384            .to_string();
385        DocumentError::TypeMismatch {
386            path: path.into(),
387            expected: String::new(),
388            got: hint,
389            hint: None,
390        }
391    }
392}
393
394impl From<io::Error> for DocumentError {
395    fn from(err: io::Error) -> Self {
396        DocumentError::IoError {
397            detail: err.to_string(),
398        }
399    }
400}
401
402fn leading_ascii_digits(text: &str) -> Option<&str> {
403    let end = text.bytes().take_while(u8::is_ascii_digit).count();
404    (end > 0).then_some(&text[..end])
405}
406
407#[cfg(test)]
408mod tests {
409    use super::DocumentError;
410
411    #[test]
412    fn document_error_codes_are_stable() {
413        let cases = [
414            (DocumentError::EmptyPath, "document_invalid_argument"),
415            (DocumentError::EmptyValues, "document_invalid_argument"),
416            (
417                DocumentError::UnknownSegment {
418                    path: "root.key".to_string(),
419                    segment: "key".to_string(),
420                },
421                "document_path_not_found",
422            ),
423            (
424                DocumentError::UnregisteredArray {
425                    path: "items".to_string(),
426                },
427                "document_path_not_found",
428            ),
429            (
430                DocumentError::SlugNotFound {
431                    prefix: "items".to_string(),
432                    slug: "missing".to_string(),
433                },
434                "document_slug_not_found",
435            ),
436            (
437                DocumentError::SlugAlreadyExists {
438                    prefix: "items".to_string(),
439                    slug: "existing".to_string(),
440                },
441                "document_slug_exists",
442            ),
443            (
444                DocumentError::AmbiguousMatch {
445                    prefix: "items".to_string(),
446                    segment: "look".to_string(),
447                    indices: vec![0, 2],
448                },
449                "document_ambiguous_match",
450            ),
451            (
452                DocumentError::NotTraversable {
453                    path: "root".to_string(),
454                    got: "string".to_string(),
455                },
456                "document_type_mismatch",
457            ),
458            (
459                DocumentError::TypeMismatch {
460                    path: "root.key".to_string(),
461                    expected: "integer".to_string(),
462                    got: "string".to_string(),
463                    hint: None,
464                },
465                "document_type_mismatch",
466            ),
467            (
468                DocumentError::PathNotFound {
469                    path: "root.key".to_string(),
470                },
471                "document_path_not_found",
472            ),
473            (
474                DocumentError::IndexOutOfBounds {
475                    path: "items".to_string(),
476                    index: 2,
477                    len: 1,
478                },
479                "document_path_not_found",
480            ),
481            (
482                DocumentError::ParseError {
483                    format: "JSON".to_string(),
484                    detail: "invalid input".to_string(),
485                },
486                "document_parse_failed",
487            ),
488            (
489                DocumentError::IoError {
490                    detail: "unreadable".to_string(),
491                },
492                "document_io_failed",
493            ),
494            (
495                DocumentError::AlreadyExists {
496                    path: "config.toml".to_string(),
497                },
498                "document_target_exists",
499            ),
500            (
501                DocumentError::TooLarge {
502                    path: "config.toml".to_string(),
503                    max_bytes: 1024,
504                },
505                "document_too_large",
506            ),
507            (
508                DocumentError::UnsupportedOperation {
509                    format: "INI".to_string(),
510                    operation: "set".to_string(),
511                    detail: "unsupported".to_string(),
512                },
513                "document_unsupported_operation",
514            ),
515        ];
516
517        for (error, expected) in cases {
518            assert_eq!(error.code(), expected);
519        }
520    }
521
522    #[test]
523    fn location_extracts_position_without_content() {
524        // The real layout a parser produces: its own position first, then the
525        // offending source echoed below. The echo here carries `at line 999`
526        // out of the document, and a search from the end reads exactly that —
527        // reporting a wrong line and leaking a document-derived number through
528        // `redacted_message`, which promises never to surface file content.
529        let err = DocumentError::ParseError {
530            format: "TOML".to_string(),
531            detail: "TOML parse error at line 5, column 12\n  |\n\
532                     5 | note = \"see at line 999 for TOPSECRET\" bad\n  |     ^"
533                .to_string(),
534        };
535        assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
536        assert!(!err.redacted_message().contains("999"));
537        assert!(!err.redacted_message().contains("TOPSECRET"));
538
539        let no_column = DocumentError::ParseError {
540            format: "JSON".to_string(),
541            detail: "boom at line 3".to_string(),
542        };
543        assert_eq!(no_column.location().as_deref(), Some("line 3"));
544
545        // No position, and non-parse variants, carry no location.
546        assert!(
547            DocumentError::ParseError {
548                format: "INI".to_string(),
549                detail: "sensitive value".to_string(),
550            }
551            .location()
552            .is_none()
553        );
554        assert!(
555            DocumentError::PathNotFound {
556                path: "a.b".to_string(),
557            }
558            .location()
559            .is_none()
560        );
561    }
562
563    #[test]
564    fn redacted_message_drops_parser_detail() {
565        let err = DocumentError::ParseError {
566            format: "YAML".to_string(),
567            detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
568        };
569        let redacted = err.redacted_message();
570        assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
571        assert!(!redacted.contains("TOPSECRET"));
572
573        // Structural variants pass through unchanged.
574        let path_err = DocumentError::PathNotFound {
575            path: "database.url".to_string(),
576        };
577        assert_eq!(path_err.redacted_message(), path_err.to_string());
578    }
579
580    #[test]
581    fn redacted_message_drops_the_offending_value() {
582        // `from_serde` keeps serde's rendering, which quotes the value that
583        // failed the type check — document content, and a secret as often as not.
584        let err = DocumentError::from_serde(
585            "credentials.token",
586            "invalid type: string \"sk-live-TOPSECRET\", expected u16",
587        );
588        assert!(err.to_string().contains("sk-live-TOPSECRET"));
589        let redacted = err.redacted_message();
590        assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
591        assert!(redacted.contains("credentials.token"), "{redacted}");
592    }
593
594    #[test]
595    fn ambiguous_match_reports_indices_without_document_content() {
596        let err = DocumentError::AmbiguousMatch {
597            prefix: "h1.0.h2".to_string(),
598            segment: "look".to_string(),
599            indices: vec![0, 2],
600        };
601        assert_eq!(
602            err.redacted_message(),
603            "segment `look` matches 2 elements of `h1.0.h2` at indices 0, 2"
604        );
605        assert!(!err.redacted_message().contains("Quick look"));
606    }
607
608    #[test]
609    fn not_traversable_names_the_type_not_the_value() {
610        // This one is safe by construction rather than by redaction: `got` is a
611        // kind name, so even `Display` cannot echo the leaf.
612        let err = DocumentError::NotTraversable {
613            path: "token_secret.inner".to_string(),
614            got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
615                .kind_name()
616                .to_string(),
617        };
618        assert_eq!(
619            err.to_string(),
620            "path `token_secret.inner` is string, cannot traverse further"
621        );
622        assert_eq!(err.redacted_message(), err.to_string());
623    }
624}