1use camino::Utf8PathBuf;
4use serde::{Deserialize, Serialize};
5
6#[non_exhaustive]
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11 Error,
15 Warning,
18 Hint,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Span {
25 pub start: usize,
27 pub end: usize,
29}
30
31impl From<std::ops::Range<usize>> for Span {
32 fn from(range: std::ops::Range<usize>) -> Self {
33 Self {
34 start: range.start,
35 end: range.end,
36 }
37 }
38}
39
40#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct Location {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub file: Option<Utf8PathBuf>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub span: Option<Span>,
57}
58
59#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Diagnostic {
67 pub severity: Severity,
69 pub message: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub location: Option<Location>,
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub hints: Vec<String>,
83}
84
85impl Diagnostic {
86 pub fn warning(message: impl Into<String>) -> Self {
88 Self {
89 severity: Severity::Warning,
90 message: message.into(),
91 location: None,
92 hints: Vec::new(),
93 }
94 }
95
96 pub fn error(message: impl Into<String>) -> Self {
98 Self {
99 severity: Severity::Error,
100 message: message.into(),
101 location: None,
102 hints: Vec::new(),
103 }
104 }
105
106 pub fn hint(message: impl Into<String>) -> Self {
111 Self {
112 severity: Severity::Hint,
113 message: message.into(),
114 location: None,
115 hints: Vec::new(),
116 }
117 }
118
119 pub fn at_file(mut self, file: impl Into<Utf8PathBuf>) -> Self {
121 let location = self.location.get_or_insert(Location {
122 file: None,
123 span: None,
124 });
125 location.file = Some(file.into());
126 self
127 }
128}
129
130pub(crate) fn parse_failure(diagnostics: &[Diagnostic], kind: &str) -> String {
144 let causes: Vec<String> = diagnostics
145 .iter()
146 .filter(|d| d.severity == Severity::Error)
147 .map(|d| one_line(&d.message))
148 .collect();
149
150 if causes.is_empty() {
151 format!("the {kind} configuration could not be parsed")
152 } else {
153 causes.join("; ")
154 }
155}
156
157fn one_line(message: &str) -> String {
159 message
160 .lines()
161 .map(str::trim)
162 .filter(|line| !line.is_empty())
163 .collect::<Vec<_>>()
164 .join(" ")
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn one_line_keeps_every_part_of_a_multi_line_message() {
173 assert_eq!(one_line("first\n\n second \nthird"), "first second third");
174 assert_eq!(one_line("already one line"), "already one line");
175 }
176
177 #[test]
180 fn parse_failure_keeps_every_error_and_drops_the_rest() {
181 let diagnostics = [
182 Diagnostic::error("TOML parse error at line 1, column 10\n |\n1 | not toml"),
183 Diagnostic::warning("unknown attribute 'colour'"),
184 Diagnostic::error("second cause"),
185 ];
186 assert_eq!(
187 parse_failure(&diagnostics, "pantry"),
188 "TOML parse error at line 1, column 10 | 1 | not toml; second cause"
189 );
190 }
191
192 #[test]
195 fn parse_failure_falls_back_to_naming_the_configuration() {
196 assert_eq!(
197 parse_failure(&[], "pantry"),
198 "the pantry configuration could not be parsed"
199 );
200 assert_eq!(
201 parse_failure(&[Diagnostic::warning("only a warning")], "aisle"),
202 "the aisle configuration could not be parsed"
203 );
204 }
205
206 #[test]
207 fn warning_constructor_sets_severity() {
208 let d = Diagnostic::warning("bad aisle line");
209 assert_eq!(d.severity, Severity::Warning);
210 assert_eq!(d.message, "bad aisle line");
211 assert!(d.location.is_none());
212 }
213
214 #[test]
215 fn hint_constructor_sets_severity() {
216 let d = Diagnostic::hint("consider adding a servings metadata key");
217 assert_eq!(d.severity, Severity::Hint);
218 assert!(d.location.is_none());
219 }
220
221 #[test]
222 fn at_file_attaches_location() {
223 let d = Diagnostic::error("boom").at_file("config/aisle.conf");
224 let location = d.location.expect("location set");
225 assert_eq!(
226 location.file.as_deref().map(|p| p.as_str()),
227 Some("config/aisle.conf")
228 );
229 }
230
231 #[test]
232 fn at_file_preserves_an_existing_span() {
233 let d = Diagnostic {
234 severity: Severity::Error,
235 message: "boom".to_string(),
236 location: Some(Location {
237 file: None,
238 span: Some(Span { start: 12, end: 20 }),
239 }),
240 hints: Vec::new(),
241 }
242 .at_file("soup.cook");
243
244 let location = d.location.expect("location set");
245 assert_eq!(
246 location.file.as_deref().map(|p| p.as_str()),
247 Some("soup.cook")
248 );
249 assert_eq!(location.span, Some(Span { start: 12, end: 20 }));
250 }
251
252 #[test]
253 fn span_converts_from_a_range() {
254 assert_eq!(Span::from(4..9), Span { start: 4, end: 9 });
255 }
256
257 #[test]
258 fn serializes_without_null_location() {
259 let d = Diagnostic::warning("no location");
260 let json = serde_json::to_string(&d).unwrap();
261 assert_eq!(json, r#"{"severity":"warning","message":"no location"}"#);
262 }
263
264 #[test]
265 fn serializes_a_full_location_with_a_named_span() {
266 let d = Diagnostic {
267 severity: Severity::Error,
268 message: "bad quantity".to_string(),
269 location: Some(Location {
270 file: Some(Utf8PathBuf::from("soup.cook")),
271 span: Some(Span { start: 12, end: 20 }),
272 }),
273 hints: Vec::new(),
274 };
275 let json = serde_json::to_string(&d).unwrap();
276 assert_eq!(
277 json,
278 r#"{"severity":"error","message":"bad quantity","location":{"file":"soup.cook","span":{"start":12,"end":20}}}"#
279 );
280 }
281
282 #[test]
283 fn hints_roundtrip_and_are_omitted_when_empty() {
284 let mut d = Diagnostic::warning("duplicate key");
285 d.hints = vec![
286 "Replace the entries with this:\n---\ntitle: B\n---\n".to_string(),
287 "second hint".to_string(),
288 ];
289
290 let json = serde_json::to_string(&d).unwrap();
291 let back: Diagnostic = serde_json::from_str(&json).unwrap();
292 assert_eq!(back, d, "hints must survive a serde roundtrip in order");
293 assert_eq!(back.hints.len(), 2);
294 assert_eq!(back.hints[1], "second hint");
295
296 let empty = Diagnostic::warning("nothing to suggest");
299 let json = serde_json::to_string(&empty).unwrap();
300 assert_eq!(
301 json,
302 r#"{"severity":"warning","message":"nothing to suggest"}"#
303 );
304
305 let back: Diagnostic = serde_json::from_str(&json).unwrap();
308 assert_eq!(back, empty);
309 assert!(back.hints.is_empty());
310 }
311
312 #[test]
313 fn omitted_fields_deserialize_back_to_none() {
314 let d: Diagnostic =
315 serde_json::from_str(r#"{"severity":"warning","message":"no location"}"#).unwrap();
316 assert_eq!(d, Diagnostic::warning("no location"));
317
318 let d: Diagnostic = serde_json::from_str(
319 r#"{"severity":"error","message":"m","location":{"file":"a.cook"}}"#,
320 )
321 .unwrap();
322 let location = d.location.expect("location set");
323 assert_eq!(location.file.as_deref().map(|p| p.as_str()), Some("a.cook"));
324 assert_eq!(location.span, None);
325
326 let d: Diagnostic =
327 serde_json::from_str(r#"{"severity":"hint","message":"m","location":{}}"#).unwrap();
328 assert_eq!(
329 d.location,
330 Some(Location {
331 file: None,
332 span: None
333 })
334 );
335 }
336}