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    ParseError {
46        format: String,
47        detail: String,
48    },
49    IoError {
50        detail: String,
51    },
52    UnsupportedOperation {
53        format: String,
54        operation: String,
55        detail: String,
56    },
57}
58
59impl fmt::Display for DocumentError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            DocumentError::EmptyPath => {
63                write!(f, "empty path provided")
64            }
65            DocumentError::EmptyValues => {
66                write!(f, "at least one value required")
67            }
68            DocumentError::UnknownSegment { path, segment } => {
69                write!(f, "path `{}` segment `{}` not found", path, segment)
70            }
71            DocumentError::UnregisteredArray { path } => {
72                write!(f, "array at `{}` not registered in KeyedList", path)
73            }
74            DocumentError::SlugNotFound { prefix, slug } => {
75                write!(f, "no element with slug `{}` found in `{}`", slug, prefix)
76            }
77            DocumentError::SlugAlreadyExists { prefix, slug } => {
78                write!(f, "slug `{}` already exists in `{}`", slug, prefix)
79            }
80            DocumentError::NotTraversable { path, got } => {
81                write!(f, "path `{}` is {}, cannot traverse further", path, got)
82            }
83            DocumentError::TypeMismatch {
84                path,
85                expected,
86                got,
87                hint,
88            } => {
89                write!(f, "field `{}` expects {}, got `{}`", path, expected, got)?;
90                if let Some(h) = hint {
91                    write!(f, "\n  hint: {}", h)?;
92                }
93                Ok(())
94            }
95            DocumentError::PathNotFound { path } => {
96                write!(f, "path `{}` not found in document", path)
97            }
98            DocumentError::IndexOutOfBounds { path, index, len } => {
99                write!(
100                    f,
101                    "index {} out of bounds at `{}` (len {})",
102                    index, path, len
103                )
104            }
105            DocumentError::ParseError { format, detail } => {
106                write!(f, "failed to parse {}: {}", format, detail)
107            }
108            DocumentError::IoError { detail } => {
109                write!(f, "io error: {}", detail)
110            }
111            DocumentError::UnsupportedOperation {
112                format,
113                operation,
114                detail,
115            } => write!(f, "{} does not support {}: {}", format, operation, detail),
116        }
117    }
118}
119
120impl std::error::Error for DocumentError {}
121
122impl DocumentError {
123    /// Stable, program-decidable error code for this failure category.
124    ///
125    /// Multiple variants can share a code when callers should handle them in
126    /// the same way. In particular, all read-address failures report
127    /// `document_path_not_found`.
128    #[must_use]
129    pub const fn code(&self) -> &'static str {
130        match self {
131            Self::ParseError { .. } => "document_parse_failed",
132            Self::PathNotFound { .. }
133            | Self::UnknownSegment { .. }
134            | Self::IndexOutOfBounds { .. }
135            | Self::UnregisteredArray { .. } => "document_path_not_found",
136            Self::NotTraversable { .. } | Self::TypeMismatch { .. } => "document_type_mismatch",
137            Self::SlugNotFound { .. } => "document_slug_not_found",
138            Self::SlugAlreadyExists { .. } => "document_slug_exists",
139            Self::IoError { .. } => "document_io_failed",
140            Self::UnsupportedOperation { .. } => "document_unsupported_operation",
141            Self::EmptyPath | Self::EmptyValues => "document_invalid_argument",
142        }
143    }
144
145    /// Best-effort, content-free source location for a parse failure.
146    ///
147    /// Returns e.g. `"line 5 column 12"` (or `"line 5"`) for a
148    /// [`DocumentError::ParseError`], and `None` for every other variant or
149    /// when the underlying parser reported no position. The returned string is
150    /// derived from the parser's position only and never contains document
151    /// content, so it is safe to surface even when the parsed file may hold
152    /// secrets.
153    #[must_use]
154    pub fn location(&self) -> Option<String> {
155        let Self::ParseError { detail, .. } = self else {
156            return None;
157        };
158        let rest = &detail[detail.find("line ")? + 5..];
159        let line: String = rest.chars().take_while(char::is_ascii_digit).collect();
160        if line.is_empty() {
161            return None;
162        }
163        let column = rest
164            .find("column ")
165            .map(|start| &rest[start + 7..])
166            .map(|tail| {
167                tail.chars()
168                    .take_while(char::is_ascii_digit)
169                    .collect::<String>()
170            })
171            .filter(|value| !value.is_empty());
172        Some(match column {
173            Some(column) => format!("line {line} column {column}"),
174            None => format!("line {line}"),
175        })
176    }
177
178    /// A display message with any potentially content-bearing detail removed —
179    /// safe to surface when the document may hold secrets.
180    ///
181    /// A [`DocumentError::ParseError`] renders as `failed to parse {format}`
182    /// (with the [`location`](Self::location) appended when known) and drops
183    /// the raw parser detail, which can echo a snippet of the source. Every
184    /// other variant renders the same as its [`Display`], since those carry
185    /// only structural context (paths, type and format names), not content.
186    #[must_use]
187    pub fn redacted_message(&self) -> String {
188        match self {
189            Self::ParseError { format, .. } => match self.location() {
190                Some(location) => format!("failed to parse {format} at {location}"),
191                None => format!("failed to parse {format}"),
192            },
193            other => other.to_string(),
194        }
195    }
196
197    /// Wrap a serde deserialization failure as a `TypeMismatch` so callers that
198    /// do a read-modify-write cycle (set_path → serde round-trip) surface a
199    /// consistent error style rather than a raw serde message.
200    pub fn from_serde(path: impl Into<String>, err: impl std::fmt::Display) -> Self {
201        let msg = err.to_string();
202        // serde messages look like "invalid type: string \"x\", expected u16 at …"
203        // Strip the trailing " at line N column M" to keep the hint concise.
204        let hint = msg
205            .split(" at line ")
206            .next()
207            .unwrap_or(&msg)
208            .trim()
209            .to_string();
210        DocumentError::TypeMismatch {
211            path: path.into(),
212            expected: String::new(),
213            got: hint,
214            hint: None,
215        }
216    }
217}
218
219impl From<io::Error> for DocumentError {
220    fn from(err: io::Error) -> Self {
221        DocumentError::IoError {
222            detail: err.to_string(),
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::DocumentError;
230
231    #[test]
232    fn document_error_codes_are_stable() {
233        let cases = [
234            (DocumentError::EmptyPath, "document_invalid_argument"),
235            (DocumentError::EmptyValues, "document_invalid_argument"),
236            (
237                DocumentError::UnknownSegment {
238                    path: "root.key".to_string(),
239                    segment: "key".to_string(),
240                },
241                "document_path_not_found",
242            ),
243            (
244                DocumentError::UnregisteredArray {
245                    path: "items".to_string(),
246                },
247                "document_path_not_found",
248            ),
249            (
250                DocumentError::SlugNotFound {
251                    prefix: "items".to_string(),
252                    slug: "missing".to_string(),
253                },
254                "document_slug_not_found",
255            ),
256            (
257                DocumentError::SlugAlreadyExists {
258                    prefix: "items".to_string(),
259                    slug: "existing".to_string(),
260                },
261                "document_slug_exists",
262            ),
263            (
264                DocumentError::NotTraversable {
265                    path: "root".to_string(),
266                    got: "string".to_string(),
267                },
268                "document_type_mismatch",
269            ),
270            (
271                DocumentError::TypeMismatch {
272                    path: "root.key".to_string(),
273                    expected: "integer".to_string(),
274                    got: "string".to_string(),
275                    hint: None,
276                },
277                "document_type_mismatch",
278            ),
279            (
280                DocumentError::PathNotFound {
281                    path: "root.key".to_string(),
282                },
283                "document_path_not_found",
284            ),
285            (
286                DocumentError::IndexOutOfBounds {
287                    path: "items".to_string(),
288                    index: 2,
289                    len: 1,
290                },
291                "document_path_not_found",
292            ),
293            (
294                DocumentError::ParseError {
295                    format: "JSON".to_string(),
296                    detail: "invalid input".to_string(),
297                },
298                "document_parse_failed",
299            ),
300            (
301                DocumentError::IoError {
302                    detail: "unreadable".to_string(),
303                },
304                "document_io_failed",
305            ),
306            (
307                DocumentError::UnsupportedOperation {
308                    format: "INI".to_string(),
309                    operation: "set".to_string(),
310                    detail: "unsupported".to_string(),
311                },
312                "document_unsupported_operation",
313            ),
314        ];
315
316        for (error, expected) in cases {
317            assert_eq!(error.code(), expected);
318        }
319    }
320
321    #[test]
322    fn location_extracts_position_without_content() {
323        let err = DocumentError::ParseError {
324            format: "YAML".to_string(),
325            detail: "secret: [ TOPSECRET at line 5 column 12".to_string(),
326        };
327        assert_eq!(err.location().as_deref(), Some("line 5 column 12"));
328
329        let no_column = DocumentError::ParseError {
330            format: "JSON".to_string(),
331            detail: "boom at line 3".to_string(),
332        };
333        assert_eq!(no_column.location().as_deref(), Some("line 3"));
334
335        // No position, and non-parse variants, carry no location.
336        assert!(
337            DocumentError::ParseError {
338                format: "INI".to_string(),
339                detail: "sensitive value".to_string(),
340            }
341            .location()
342            .is_none()
343        );
344        assert!(
345            DocumentError::PathNotFound {
346                path: "a.b".to_string(),
347            }
348            .location()
349            .is_none()
350        );
351    }
352
353    #[test]
354    fn redacted_message_drops_parser_detail() {
355        let err = DocumentError::ParseError {
356            format: "YAML".to_string(),
357            detail: "unexpected TOPSECRET at line 5 column 12".to_string(),
358        };
359        let redacted = err.redacted_message();
360        assert_eq!(redacted, "failed to parse YAML at line 5 column 12");
361        assert!(!redacted.contains("TOPSECRET"));
362
363        // Structural variants pass through unchanged.
364        let path_err = DocumentError::PathNotFound {
365            path: "database.url".to_string(),
366        };
367        assert_eq!(path_err.redacted_message(), path_err.to_string());
368    }
369}