Skip to main content

tanzim_value/
error.rs

1use crate::Location;
2use std::fmt::{self, Display, Formatter};
3
4/// Error while deserializing configuration input.
5///
6/// [`Display`] is one line by default; use `{error:#}` for source context and caret.
7///
8/// [`Location`] is boxed so the whole [`Error`] stays small enough to return by value without
9/// tripping `clippy::result_large_err` (a [`Location`] now carries the full originating
10/// [`tanzim_source::Source`]).
11#[derive(Debug, Clone, PartialEq)]
12pub enum Error {
13    InvalidUtf8 {
14        location: Box<Location>,
15    },
16    UnsupportedType {
17        location: Box<Location>,
18        found: &'static str,
19    },
20    Parse {
21        location: Option<Box<Location>>,
22        message: String,
23    },
24    /// A value could not be deserialized into the requested type (`serde` Cargo feature).
25    ///
26    /// The offending node's [`Location`] is stamped on by the deserializer (see
27    /// [`Error::or_location`]); because every parsed node's `Location` already carries its
28    /// pre-rendered source [`snippet`](Location::snippet), `{error:#}` renders a caret underline
29    /// without any post-hoc source lookup.
30    #[cfg(feature = "serde")]
31    Deserialize {
32        message: String,
33        location: Option<Box<Location>>,
34    },
35}
36
37impl Error {
38    /// Stamp `location` onto a [`Error::Deserialize`] that has none yet, so errors bubbling up from
39    /// a leaf get the nearest enclosing node's position (and its pre-rendered snippet). Errors that
40    /// already carry a location (or are not deserialize errors) are returned unchanged.
41    #[cfg(feature = "serde")]
42    pub(crate) fn or_location(mut self, location: &Location) -> Self {
43        if let Self::Deserialize {
44            location: slot @ None,
45            ..
46        } = &mut self
47        {
48            *slot = Some(Box::new(location.clone()));
49        }
50        self
51    }
52}
53
54fn located_message(location: &Location, message: &str) -> String {
55    format!("{message} at {location}")
56}
57
58impl Display for Error {
59    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::InvalidUtf8 { location } => {
62                write!(f, "invalid utf-8 in configuration input from {location}")?;
63            }
64            Self::UnsupportedType { location, found } => {
65                write!(
66                    f,
67                    "{}",
68                    located_message(
69                        location,
70                        &format!("unsupported configuration input type `{found}`"),
71                    )
72                )?;
73            }
74            Self::Parse {
75                location: Some(location),
76                message,
77            } => write!(f, "{}", located_message(location, message))?,
78            Self::Parse { message, .. } => write!(f, "{message}")?,
79            #[cfg(feature = "serde")]
80            Self::Deserialize {
81                message,
82                location: Some(location),
83            } => write!(
84                f,
85                "{}",
86                located_message(
87                    location,
88                    &format!("failed to deserialize configuration: {message}"),
89                )
90            )?,
91            #[cfg(feature = "serde")]
92            Self::Deserialize { message, .. } => {
93                write!(f, "failed to deserialize configuration: {message}")?
94            }
95        }
96
97        // Alternate form appends the offending value's pre-rendered source snippet (gutter + caret),
98        // computed once at parse time and stored on the `Location` — nothing is recomputed here.
99        if f.alternate() {
100            let location = match self {
101                Self::InvalidUtf8 { location } | Self::UnsupportedType { location, .. } => {
102                    Some(location.as_ref())
103                }
104                Self::Parse {
105                    location: Some(location),
106                    ..
107                } => Some(location.as_ref()),
108                #[cfg(feature = "serde")]
109                Self::Deserialize {
110                    location: Some(location),
111                    ..
112                } => Some(location.as_ref()),
113                _ => None,
114            };
115            if let Some(location) = location
116                && !location.snippet.is_empty()
117            {
118                write!(f, "\n{}", location.snippet)?;
119            }
120        }
121
122        Ok(())
123    }
124}
125
126impl std::error::Error for Error {}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::Location;
132    use tanzim_source::Source;
133
134    fn source() -> Source {
135        Source::named("file").with_resource("config.toml")
136    }
137
138    #[test]
139    fn default_display_is_single_line() {
140        let error = Error::UnsupportedType {
141            location: Box::new(Location::at("file", "config.toml", Some(2), Some(7), None)),
142            found: "datetime",
143        };
144        let message = error.to_string();
145        assert!(!message.contains('\n'));
146        assert!(!message.contains('^'));
147        assert!(message.contains("file:config.toml:2:7"));
148    }
149
150    #[test]
151    fn alternate_display_underlines_token() {
152        let text = "foo: bar\nbaz: datetime\n";
153        let error = Error::UnsupportedType {
154            location: Box::new(Location::in_text(source(), text, Some(2), Some(6), Some(8))),
155            found: "datetime",
156        };
157        let message = format!("{error:#}");
158        assert!(message.contains("^^^^"));
159        assert!(message.contains("baz: datetime"));
160    }
161
162    #[test]
163    fn alternate_display_aligns_gutter_pipe() {
164        let text = "foo: bar\n\nbaz:\n\n  qux: datetime\n";
165        let error = Error::UnsupportedType {
166            location: Box::new(Location::in_text(source(), text, Some(5), Some(8), None)),
167            found: "datetime",
168        };
169        let message = format!("{error:#}");
170        let source_line = message
171            .lines()
172            .find(|line| line.contains("qux: datetime"))
173            .expect("source line");
174        let underline_line = message
175            .lines()
176            .find(|line| line.contains('^'))
177            .expect("underline line");
178        let source_pipe = source_line.find('|').expect("source pipe");
179        let underline_pipe = underline_line.find('|').expect("underline pipe");
180        assert_eq!(source_pipe, underline_pipe);
181    }
182
183    #[cfg(feature = "serde")]
184    #[test]
185    fn deserialize_error_renders_caret_without_attach_step() {
186        // A Deserialize error stamped with a node Location that already carries a snippet renders a
187        // caret in alternate mode with no post-hoc source attachment.
188        let text = "name: app\nport: nope\n";
189        let location = Location::in_text(source(), text, Some(2), Some(7), Some(4));
190        let error = Error::Deserialize {
191            message: "invalid type: string, expected u16".to_string(),
192            location: None,
193        }
194        .or_location(&location);
195        let plain = error.to_string();
196        assert!(!plain.contains('\n'));
197        assert!(!plain.contains('^'));
198        let alternate = format!("{error:#}");
199        assert!(alternate.contains("port: nope"));
200        assert!(alternate.contains("^^^^"));
201    }
202}