Skip to main content

cookcli_core/
diagnostic.rs

1//! Structured diagnostics shared by every command.
2
3use camino::Utf8PathBuf;
4use serde::{Deserialize, Serialize};
5
6/// How much a [`Diagnostic`] matters.
7#[non_exhaustive]
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum Severity {
11    /// The input is broken. Whether this is fatal depends on the command: see
12    /// [`Outcome`](crate::Outcome) for when errors are reported as data rather
13    /// than returned as `Err`.
14    Error,
15    /// The input is usable but suspect, and the result may not be what the
16    /// author intended.
17    Warning,
18    /// A suggestion. Nothing is wrong.
19    Hint,
20}
21
22/// A byte range into the source file: `start..end`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Span {
25    /// Byte offset of the first byte of the range.
26    pub start: usize,
27    /// Byte offset one past the last byte of the range.
28    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/// Where in a source file a diagnostic applies.
41///
42/// Both fields are optional: a diagnostic about a configuration file as a whole
43/// has a file but no span, and one raised before any source is known has
44/// neither.
45///
46/// `#[non_exhaustive]` because this is an output type that consumers read
47/// rather than construct, and more positional detail may be added later.
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct Location {
51    /// The file the diagnostic refers to.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub file: Option<Utf8PathBuf>,
54    /// The range within that file the diagnostic refers to.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub span: Option<Span>,
57}
58
59/// A single problem found while running a command.
60///
61/// `#[non_exhaustive]` because this is an output type that consumers read
62/// rather than construct, so that adding detail later — label text, related
63/// locations — is not a breaking change.
64#[non_exhaustive]
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Diagnostic {
67    /// How much this diagnostic matters.
68    pub severity: Severity,
69    /// Human-readable description of the problem, one line, no trailing period.
70    pub message: String,
71    /// Where the problem is, when that is known.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub location: Option<Location>,
74    /// Suggestions for fixing the problem, most useful first. Often a
75    /// ready-to-apply replacement, so worth surfacing to the user verbatim.
76    /// May be empty.
77    ///
78    /// Unlike the `Option` fields above, `default` is load-bearing here:
79    /// `skip_serializing_if` on a non-`Option` omits the key entirely, and
80    /// without `default` that omission fails to deserialize.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub hints: Vec<String>,
83}
84
85impl Diagnostic {
86    /// A diagnostic with [`Severity::Warning`], no location and no hints.
87    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    /// A diagnostic with [`Severity::Error`], no location and no hints.
97    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    /// A diagnostic with [`Severity::Hint`], no location and no hints.
107    ///
108    /// The severity is a suggestion about the input; `hints` are suggested
109    /// fixes. A `Hint` diagnostic may carry hints of its own.
110    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    /// Attach a source file to this diagnostic, keeping any span already set.
120    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
130/// Word the failure that left a lenient parse with no configuration at all,
131/// from the diagnostics it did produce.
132///
133/// The causes are flattened onto one line because [`CoreError`]'s `Display` is
134/// documented as being one: a TOML syntax error arrives as a multi-line report
135/// with the offending line quoted underneath it. `kind` names the configuration
136/// as it is named to the user — `"pantry"`, `"aisle"` — and is used only for
137/// the fallback, when the parse produced no error diagnostic to quote.
138///
139/// Shared so that two commands reading the same broken file say the same thing
140/// about it: `pantry::load` and `doctor::pantry_coverage` both reach here.
141///
142/// [`CoreError`]: crate::CoreError
143pub(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
157/// Collapse a multi-line message onto one line, keeping every part of it.
158fn 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    /// Every error is kept, flattened, and joined — a caller handed this
178    /// instead of the diagnostics has to be able to act on it.
179    #[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    /// Only when there is nothing to quote does the wording fall back to
193    /// naming the configuration.
194    #[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        // Empty hints are omitted from the JSON entirely. Match the key, not
297        // the bare word, which could also occur inside the message.
298        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        // ...so the missing key must deserialize back to an empty Vec rather
306        // than failing. This is what `#[serde(default)]` buys here.
307        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}