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    UnregisteredArray {
17        path: String,
18    },
19    SlugNotFound {
20        prefix: String,
21        slug: String,
22    },
23    SlugAlreadyExists {
24        prefix: String,
25        slug: String,
26    },
27    NotTraversable {
28        path: String,
29        got: String,
30    },
31    TypeMismatch {
32        path: String,
33        expected: String,
34        got: String,
35        hint: Option<String>,
36    },
37    PathNotFound {
38        path: String,
39    },
40    IndexOutOfBounds {
41        path: String,
42        index: usize,
43        len: usize,
44    },
45    /// A parser rejected the source. `detail` is the parser's own text, which
46    /// quotes the offending line — see [`Self::redacted_message`].
47    ParseError {
48        format: String,
49        detail: String,
50    },
51    /// A staged edit rendered source this format's own parser rejects, caught
52    /// by the read-back in `save_atomic` before any bytes reached disk.
53    ///
54    /// `detail` is already redacted: it comes from
55    /// [`Self::redacted_message`] of the rejection, not from its `Display`.
56    WriteWouldCorrupt {
57        format: String,
58        detail: String,
59    },
60    /// No format could be inferred for `path`, so nothing was parsed at all.
61    ///
62    /// Distinct from [`Self::ParseError`] because it is about the file's name,
63    /// never its contents: it carries no document text, and dropping its detail
64    /// as a precaution would throw away the only actionable thing it says.
65    FormatUnknown {
66        path: String,
67    },
68    IoError {
69        detail: String,
70    },
71    UnsupportedOperation {
72        format: String,
73        operation: String,
74        detail: String,
75    },
76}
77
78impl fmt::Display for DocumentError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            DocumentError::EmptyPath => {
82                write!(f, "empty path provided")
83            }
84            DocumentError::EmptyValues => {
85                write!(f, "at least one value required")
86            }
87            DocumentError::UnknownSegment { path, segment } => {
88                write!(f, "path `{}` segment `{}` not found", path, segment)
89            }
90            DocumentError::UnregisteredArray { path } => {
91                write!(f, "array at `{}` not registered in KeyedList", path)
92            }
93            DocumentError::SlugNotFound { prefix, slug } => {
94                write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
95            }
96            DocumentError::SlugAlreadyExists { prefix, slug } => {
97                write!(f, "slug `{}` already exists in `{}`", slug, prefix)
98            }
99            DocumentError::NotTraversable { path, got } => {
100                write!(f, "path `{}` is {}, cannot traverse further", path, got)
101            }
102            DocumentError::TypeMismatch {
103                path,
104                expected,
105                got,
106                hint,
107            } => {
108                write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
109                if let Some(h) = hint {
110                    write!(f, "\n  hint: {}", h)?;
111                }
112                Ok(())
113            }
114            DocumentError::PathNotFound { path } => {
115                write!(f, "path `{}` not found in document", path)
116            }
117            DocumentError::IndexOutOfBounds { path, index, len } => {
118                write!(
119                    f,
120                    "index {} out of bounds at `{}` (len {})",
121                    index, path, len
122                )
123            }
124            DocumentError::ParseError { format, detail } => {
125                write!(f, "failed to parse {}: {}", format, detail)
126            }
127            DocumentError::WriteWouldCorrupt { format, detail } => {
128                write!(
129                    f,
130                    "refusing to write: the edit produced {} this parser rejects ({}); the file is unchanged",
131                    format, detail
132                )
133            }
134            DocumentError::FormatUnknown { path } => {
135                write!(
136                    f,
137                    "cannot detect format from file extension `{}`; pass an explicit format",
138                    path
139                )
140            }
141            DocumentError::IoError { detail } => {
142                write!(f, "io error: {}", detail)
143            }
144            DocumentError::UnsupportedOperation {
145                format,
146                operation,
147                detail,
148            } => write!(f, "{} does not support {}: {}", format, operation, detail),
149        }
150    }
151}
152
153impl std::error::Error for DocumentError {}
154
155impl DocumentError {
156    /// Stable, program-decidable error code for this failure category.
157    ///
158    /// Multiple variants can share a code when callers should handle them in
159    /// the same way. In particular, all read-address failures report
160    /// `document_path_not_found`.
161    #[must_use]
162    pub const fn code(&self) -> &'static str {
163        match self {
164            Self::ParseError { .. } => "document_parse_failed",
165            Self::FormatUnknown { .. } => "document_format_unknown",
166            Self::WriteWouldCorrupt { .. } => "document_write_would_corrupt",
167            Self::PathNotFound { .. }
168            | Self::UnknownSegment { .. }
169            | Self::IndexOutOfBounds { .. }
170            | Self::UnregisteredArray { .. } => "document_path_not_found",
171            Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
172            Self::SlugNotFound { .. } => "document_slug_not_found",
173            Self::SlugAlreadyExists { .. } => "document_slug_exists",
174            Self::IoError { .. } => "document_io_failed",
175            Self::UnsupportedOperation { .. } => "document_unsupported_operation",
176            Self::EmptyPath | Self::EmptyValues => "document_invalid_argument",
177        }
178    }
179
180    /// Best-effort, content-free source location for a parse failure.
181    ///
182    /// Returns e.g. `"line 5 column 12"` (or `"line 5"`) for a
183    /// [`DocumentError::ParseError`], and `None` for every other variant or
184    /// when the underlying parser reported no position. The returned string is
185    /// derived from the parser's position only and never contains document
186    /// content, so it is safe to surface even when the parsed file may hold
187    /// secrets.
188    #[must_use]
189    pub fn location(&self) -> Option<String> {
190        let Self::ParseError { detail, .. } = self else {
191            return None;
192        };
193        let rest = &detail[detail.find("line ")? + 5..];
194        let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
195        if line.is_empty() {
196            return None;
197        }
198        let column = rest
199            .find("column ")
200            .map(|start| &rest[start + 7..])
201            .map(|tail| {
202                tail.chars()
203                    .take_while(char::is_ascii_digit)
204                    .collect::<String>()
205            })
206            .filter(|value| !value.is_empty());
207        Some(match column {
208            Some(column) => format!("line {line} column {column}"),
209            None => format!("line {line}"),
210        })
211    }
212
213    /// A display message with any potentially content-bearing detail removed —
214    /// safe to surface when the document may hold secrets.
215    ///
216    /// Two variants quote material that originates in the document and are
217    /// rewritten here:
218    ///
219    /// - [`DocumentError::ParseError`] renders as `failed to parse {format}`
220    ///   (with the [`location`](Self::location) appended when known), dropping
221    ///   the parser detail, which echoes a snippet of the source.
222    /// - [`DocumentError::TypeMismatch`] drops `got` and `hint`. When built by
223    ///   [`Self::from_serde`] those carry serde's rendering of the offending
224    ///   value, which is document content.
225    ///
226    /// Every other variant renders the same as its [`Display`], carrying only
227    /// structural context: paths, slugs, indices, and type or format names.
228    /// [`DocumentError::NotTraversable`] belongs to that group because `got` is
229    /// a [`Value::kind_name`](crate::document::Value::kind_name), not a value.
230    #[must_use]
231    pub fn redacted_message(&self) -> String {
232        match self {
233            Self::ParseError { format, .. } => match self.location() {
234                Some(location) => format!("failed to parse {format} at {location}"),
235                None => format!("failed to parse {format}"),
236            },
237            Self::TypeMismatch { path, expected, .. } => {
238                if expected.is_empty() {
239                    format!("field `{path}` has the wrong type")
240                } else {
241                    format!("field `{path}` expects {expected}")
242                }
243            }
244            other => other.to_string(),
245        }
246    }
247
248    /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
249    /// do a read-modify-write cycle (set_path → serde round-trip) surface a
250    /// consistent error style rather than a raw serde message.
251    pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
252        let msg = err.to_string();
253        // serde messages look like "invalid type: string \"x\", expected u16 at …"
254        // Strip the trailing " at line N column M" to keep the hint concise.
255        let hint = msg
256            .split(" at line ")
257            .next()
258            .unwrap_or(&msg)
259            .trim()
260            .to_string();
261        DocumentError::TypeMismatch {
262            path: path.into(),
263            expected: String::new(),
264            got: hint,
265            hint: None,
266        }
267    }
268}
269
270impl From<io::Error> for DocumentError {
271    fn from(err: io::Error) -> Self {
272        DocumentError::IoError {
273            detail: err.to_string(),
274        }
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::DocumentError;
281
282    #[test]
283    fn document_error_codes_are_stable() {
284        let cases = [
285            (DocumentError::EmptyPath, "document_invalid_argument"),
286            (DocumentError::EmptyValues, "document_invalid_argument"),
287            (
288                DocumentError::UnknownSegment {
289                    path: "root.key".to_string(),
290                    segment: "key".to_string(),
291                },
292                "document_path_not_found",
293            ),
294            (
295                DocumentError::UnregisteredArray {
296                    path: "items".to_string(),
297                },
298                "document_path_not_found",
299            ),
300            (
301                DocumentError::SlugNotFound {
302                    prefix: "items".to_string(),
303                    slug: "missing".to_string(),
304                },
305                "document_slug_not_found",
306            ),
307            (
308                DocumentError::SlugAlreadyExists {
309                    prefix: "items".to_string(),
310                    slug: "existing".to_string(),
311                },
312                "document_slug_exists",
313            ),
314            (
315                DocumentError::NotTraversable {
316                    path: "root".to_string(),
317                    got: "string".to_string(),
318                },
319                "document_type_mismatch",
320            ),
321            (
322                DocumentError::TypeMismatch {
323                    path: "root.key".to_string(),
324                    expected: "integer".to_string(),
325                    got: "string".to_string(),
326                    hint: None,
327                },
328                "document_type_mismatch",
329            ),
330            (
331                DocumentError::PathNotFound {
332                    path: "root.key".to_string(),
333                },
334                "document_path_not_found",
335            ),
336            (
337                DocumentError::IndexOutOfBounds {
338                    path: "items".to_string(),
339                    index: 2,
340                    len: 1,
341                },
342                "document_path_not_found",
343            ),
344            (
345                DocumentError::ParseError {
346                    format: "JSON".to_string(),
347                    detail: "invalid input".to_string(),
348                },
349                "document_parse_failed",
350            ),
351            (
352                DocumentError::IoError {
353                    detail: "unreadable".to_string(),
354                },
355                "document_io_failed",
356            ),
357            (
358                DocumentError::UnsupportedOperation {
359                    format: "INI".to_string(),
360                    operation: "set".to_string(),
361                    detail: "unsupported".to_string(),
362                },
363                "document_unsupported_operation",
364            ),
365        ];
366
367        for (error, expected) in cases {
368            assert_eq!(error.code(), expected);
369        }
370    }
371
372    #[test]
373    fn location_extracts_position_without_content() {
374        let err = DocumentError::ParseError {
375            format: "YAML".to_string(),
376            detail: "secret: [ TOPSECRET at line 5 column 12".to_string(),
377        };
378        assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
379
380        let no_column = DocumentError::ParseError {
381            format: "JSON".to_string(),
382            detail: "boom at line 3".to_string(),
383        };
384        assert_eq!(no_column.location().as_deref(), Some("line 3"));
385
386        // No position, and non-parse variants, carry no location.
387        assert!(
388            DocumentError::ParseError {
389                format: "INI".to_string(),
390                detail: "sensitive value".to_string(),
391            }
392            .location()
393            .is_none()
394        );
395        assert!(
396            DocumentError::PathNotFound {
397                path: "a.b".to_string(),
398            }
399            .location()
400            .is_none()
401        );
402    }
403
404    #[test]
405    fn redacted_message_drops_parser_detail() {
406        let err = DocumentError::ParseError {
407            format: "YAML".to_string(),
408            detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
409        };
410        let redacted = err.redacted_message();
411        assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
412        assert!(!redacted.contains("TOPSECRET"));
413
414        // Structural variants pass through unchanged.
415        let path_err = DocumentError::PathNotFound {
416            path: "database.url".to_string(),
417        };
418        assert_eq!(path_err.redacted_message(), path_err.to_string());
419    }
420
421    #[test]
422    fn redacted_message_drops_the_offending_value() {
423        // `from_serde` keeps serde's rendering, which quotes the value that
424        // failed the type check — document content, and a secret as often as not.
425        let err = DocumentError::from_serde(
426            "credentials.token",
427            "invalid type: string \"sk-live-TOPSECRET\", expected u16",
428        );
429        assert!(err.to_string().contains("sk-live-TOPSECRET"));
430        let redacted = err.redacted_message();
431        assert!(!redacted.contains("sk-live-TOPSECRET"), "{redacted}");
432        assert!(redacted.contains("credentials.token"), "{redacted}");
433    }
434
435    #[test]
436    fn not_traversable_names_the_type_not_the_value() {
437        // This one is safe by construction rather than by redaction: `got` is a
438        // kind name, so even `Display` cannot echo the leaf.
439        let err = DocumentError::NotTraversable {
440            path: "token_secret.inner".to_string(),
441            got: crate::document::Value::String("sk-live-TOPSECRET".to_string())
442                .kind_name()
443                .to_string(),
444        };
445        assert_eq!(
446            err.to_string(),
447            "path `token_secret.inner` is string, cannot traverse further"
448        );
449        assert_eq!(err.redacted_message(), err.to_string());
450    }
451}