Skip to main content

cookcli_core/
error.rs

1//! The public error type.
2
3use crate::diagnostic::Diagnostic;
4use camino::Utf8PathBuf;
5
6/// Errors returned by `cookcli-core` commands.
7///
8/// Every `Display` rendering is a single lowercase line with no trailing
9/// newline, so it composes into log lines and error chains. Variants that have
10/// a longer, human-formatted report carry it in a field for the caller to print
11/// separately.
12///
13/// `#[non_exhaustive]` so that adding variants stays non-breaking for
14/// downstream consumers.
15#[non_exhaustive]
16#[derive(Debug, thiserror::Error)]
17pub enum CoreError {
18    /// No recipe could be resolved from the given path or name.
19    #[error("recipe not found: {name}")]
20    RecipeNotFound {
21        /// The path or name that was looked up.
22        name: String,
23    },
24
25    /// A recipe failed to parse.
26    #[error("failed to parse recipe '{name}'")]
27    Parse {
28        /// How the recipe is identified in messages. Commands reading from
29        /// disk put the file's path here, so that it agrees with the location
30        /// `rendered` and the diagnostics point at; text parsed from memory
31        /// carries whatever name the caller supplied.
32        name: String,
33        /// The individual parse problems, for programmatic consumers.
34        diagnostics: Vec<Diagnostic>,
35        /// The parser's own multi-line report with source line context, which
36        /// the CLI prints verbatim. Not part of `Display`.
37        ///
38        /// Always free of ANSI escape codes, so it is safe to write to a file
39        /// or send over a wire. Callers wanting colour for a terminal should
40        /// re-render with [`render_report`](crate::parser::render_report) and
41        /// `ansi: true`.
42        rendered: String,
43    },
44
45    /// A scaling factor was not a finite number.
46    ///
47    /// Only NaN and infinity are rejected. Zero and negative factors are
48    /// accepted, because the CLI accepts them and this crate must not change
49    /// that behaviour. NaN is worth catching because a missing JavaScript
50    /// argument arrives as `undefined`, which becomes NaN across NAPI and
51    /// would otherwise silently produce NaN quantities.
52    #[error("scale factor must be finite, but was {scale}")]
53    InvalidScale {
54        /// The rejected factor.
55        scale: f64,
56    },
57
58    /// A command needed a configuration the context does not carry.
59    ///
60    /// Distinct from [`CoreError::Config`], which means one was supplied and
61    /// could not be understood. Not every command needs one:
62    /// `shopping_list::generate` treats an absent pantry as "subtract
63    /// nothing", where the pantry queries cannot, because the pantry is the
64    /// thing they report on.
65    #[error("no {kind} configuration")]
66    MissingConfig {
67        /// Which configuration is missing, as it is named to the user:
68        /// `"pantry"` is the only one anything returns today.
69        kind: String,
70    },
71
72    /// A command that changes a configuration was handed one it cannot write
73    /// back.
74    ///
75    /// Reached only through [`ConfigSource::Inline`](crate::ConfigSource):
76    /// an editor holding pantry text in a buffer has somewhere to put an edit,
77    /// but this crate has no idea where. Applying the change is the caller's
78    /// to do, or it can supply a
79    /// [`ConfigSource::Path`](crate::ConfigSource::Path) instead.
80    ///
81    /// Distinct from [`CoreError::MissingConfig`], which is having nothing to
82    /// change at all.
83    #[error("cannot write the {kind} configuration: it was supplied inline rather than as a file")]
84    ReadOnlyConfig {
85        /// Which configuration, as it is named to the user: `"pantry"` is the
86        /// only one anything returns today.
87        kind: String,
88    },
89
90    /// A configuration could not be understood.
91    #[error("invalid configuration{}: {message}", .path.as_ref().map(|p| format!(" at {p}")).unwrap_or_default())]
92    Config {
93        /// The file the configuration came from, absent when it was supplied
94        /// inline.
95        path: Option<Utf8PathBuf>,
96        /// What was wrong with it.
97        message: String,
98    },
99
100    /// A pantry could not be changed as asked.
101    ///
102    /// The file was read and understood; it is the change that does not apply
103    /// — adding an item that is already there, or naming an item or section
104    /// that is not. Distinct from [`CoreError::Config`], which is a file that
105    /// could not be read as a pantry at all, and from [`CoreError::Io`], which
106    /// is a file that could not be read or written.
107    ///
108    /// Nothing has been written when this is returned: every check runs before
109    /// the pantry is saved.
110    #[error("cannot change the pantry: {message}")]
111    PantryEdit {
112        /// What stopped the change, naming the item and section it was asked
113        /// about.
114        message: String,
115    },
116
117    /// A report could not be produced from a template and a recipe.
118    ///
119    /// Covers a broken *template* and a recipe `cooklang-reports` could not
120    /// parse alike, because that crate parses the recipe itself, with its own
121    /// parser configuration, from inside the render call — the two failures
122    /// arrive down one channel and are not worth guessing apart. A recipe that
123    /// fails core's own parser still yields [`CoreError::Parse`]; it is only
124    /// [`report::render`](crate::report::render) that reports both this way.
125    #[error("template rendering failed: {message}")]
126    Render {
127        /// A one-line summary, for logs and error chains.
128        message: String,
129        /// The template engine's own multi-line report — source location,
130        /// error chain, and its hints — which the CLI prints verbatim. Not part
131        /// of `Display`, exactly as [`CoreError::Parse`]'s `rendered` is not.
132        rendered: String,
133    },
134
135    // No variant for a circular recipe reference. Reference expansion is
136    // bounded rather than recursive, so a cycle neither loops nor fails — it
137    // silently double-counts the ingredients it revisits, which is
138    // <https://github.com/cooklang/cookcli/issues/424>. Whoever fixes that
139    // needs to reintroduce a variant here; it was removed rather than left
140    // unreachable, because a public variant nothing can return invites
141    // consumers to write dead match arms and implies a guarantee this crate
142    // does not make.
143    /// A recipe reference could not be expanded into its ingredients.
144    ///
145    /// The referenced recipe was found and parsed; what failed was working out
146    /// how much of it the referring recipe wants — an unusable quantity on the
147    /// reference, or a target the referenced recipe cannot be scaled to.
148    /// Absence and parse failures are [`CoreError::RecipeNotFound`] and
149    /// [`CoreError::Parse`] as usual.
150    #[error("cannot expand recipe reference '{name}': {message}")]
151    Reference {
152        /// The referenced recipe, as it is spelled in the referring recipe.
153        name: String,
154        /// What went wrong.
155        message: String,
156    },
157
158    /// A directory could not be searched.
159    ///
160    /// Distinct from [`CoreError::Io`] because nothing was read: the search
161    /// root itself could not be turned into something searchable, so the walk
162    /// never started. A root whose name contains glob syntax — `notes[2024]` —
163    /// is the way to reach this, since `cooklang-find` builds its file pattern
164    /// by joining onto the root without escaping it. Reporting that as a failed
165    /// read would send the user looking at permissions.
166    #[error("cannot search '{base_dir}': {message}")]
167    Search {
168        /// The directory that could not be searched.
169        base_dir: Utf8PathBuf,
170        /// What went wrong with it.
171        message: String,
172    },
173
174    /// A saved shopping list could not be read as one.
175    ///
176    /// The `.shopping-list` file was read; it is its contents that could not be
177    /// parsed. Distinct from [`CoreError::Io`], which is the file itself being
178    /// unreadable, and from [`CoreError::Parse`], which is a recipe.
179    ///
180    /// Reachable because the file is a plain text format users and other
181    /// Cooklang apps edit directly, so this crate is not the only thing that
182    /// writes it.
183    #[error("invalid shopping list at {path}: {message}")]
184    InvalidShoppingList {
185        /// The shopping list that could not be parsed.
186        path: Utf8PathBuf,
187        /// What was wrong with it, as the format parser reported it.
188        message: String,
189    },
190
191    /// A file could not be read or written.
192    ///
193    /// There is deliberately no `From<std::io::Error>`: every call site must
194    /// name the path it was working on. The message stays neutral between
195    /// reading and writing, because the variant covers both; the specific
196    /// failure is in `source`.
197    #[error("i/o error on {path}")]
198    Io {
199        /// The file being accessed.
200        path: Utf8PathBuf,
201        /// The underlying operating system error.
202        #[source]
203        source: std::io::Error,
204    },
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn display(error: &CoreError) -> String {
212        error.to_string()
213    }
214
215    /// Stops compiling when a `CoreError` variant is added or removed.
216    ///
217    /// `every_display_is_a_single_line` below has to list its inputs by hand,
218    /// so it cannot notice a new variant on its own. When this match breaks,
219    /// add the variant here *and* to that test's `errors` array — do not just
220    /// add a `_ => {}` arm.
221    fn _all_variants_are_covered(e: &CoreError) {
222        match e {
223            CoreError::RecipeNotFound { .. }
224            | CoreError::Parse { .. }
225            | CoreError::InvalidScale { .. }
226            | CoreError::MissingConfig { .. }
227            | CoreError::ReadOnlyConfig { .. }
228            | CoreError::Config { .. }
229            | CoreError::PantryEdit { .. }
230            | CoreError::Render { .. }
231            | CoreError::Reference { .. }
232            | CoreError::Search { .. }
233            | CoreError::InvalidShoppingList { .. }
234            | CoreError::Io { .. } => {}
235        }
236    }
237
238    #[test]
239    fn every_display_is_a_single_line() {
240        let errors = [
241            CoreError::RecipeNotFound {
242                name: "soup".to_string(),
243            },
244            CoreError::Parse {
245                name: "soup".to_string(),
246                diagnostics: vec![Diagnostic::error("bad quantity")],
247                rendered: "line 1\nline 2\n".to_string(),
248            },
249            CoreError::InvalidScale { scale: f64::NAN },
250            CoreError::MissingConfig {
251                kind: "pantry".to_string(),
252            },
253            CoreError::ReadOnlyConfig {
254                kind: "pantry".to_string(),
255            },
256            CoreError::Config {
257                path: None,
258                message: "unknown section".to_string(),
259            },
260            CoreError::PantryEdit {
261                message: "item 'flour' already exists in section 'pantry'".to_string(),
262            },
263            CoreError::Render {
264                message: "undefined variable".to_string(),
265                rendered: "line 1\nline 2\n".to_string(),
266            },
267            CoreError::Reference {
268                name: "./sauce".to_string(),
269                message: "unit mismatch (expected ml, got g)".to_string(),
270            },
271            CoreError::Search {
272                base_dir: Utf8PathBuf::from("/recipes/notes[2024]"),
273                message: "Pattern syntax error near position 20".to_string(),
274            },
275            CoreError::InvalidShoppingList {
276                path: Utf8PathBuf::from("/recipes/.shopping-list"),
277                message: "Invalid multiplier: expected a number".to_string(),
278            },
279            CoreError::Io {
280                path: Utf8PathBuf::from("config/aisle.conf"),
281                source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
282            },
283        ];
284
285        for error in &errors {
286            let rendered = display(error);
287            assert!(!rendered.contains('\n'), "multi-line Display: {rendered:?}");
288            assert!(
289                !rendered.ends_with(char::is_whitespace),
290                "trailing whitespace: {rendered:?}"
291            );
292
293            // Lowercase, so it reads correctly mid-sentence in an error chain.
294            // A rendering may open with a path or another interpolated value,
295            // so judge the first cased letter rather than the first character.
296            if let Some(c) = rendered.chars().find(|c| c.is_alphabetic()) {
297                assert!(
298                    !c.is_uppercase(),
299                    "Display starts with an uppercase word: {rendered:?}"
300                );
301            }
302        }
303    }
304
305    #[test]
306    fn parse_keeps_the_rendered_report_out_of_display() {
307        let error = CoreError::Parse {
308            name: "soup".to_string(),
309            diagnostics: Vec::new(),
310            rendered: "a very long report".to_string(),
311        };
312        assert_eq!(display(&error), "failed to parse recipe 'soup'");
313    }
314
315    #[test]
316    fn render_keeps_the_rendered_report_out_of_display() {
317        let error = CoreError::Render {
318            message: "syntax error: unexpected end of input".to_string(),
319            rendered: "a very long report\nwith hints\n".to_string(),
320        };
321        assert_eq!(
322            display(&error),
323            "template rendering failed: syntax error: unexpected end of input"
324        );
325    }
326
327    #[test]
328    fn config_display_covers_both_path_and_inline() {
329        let with_path = CoreError::Config {
330            path: Some(Utf8PathBuf::from("config/aisle.conf")),
331            message: "unknown section".to_string(),
332        };
333        assert_eq!(
334            display(&with_path),
335            "invalid configuration at config/aisle.conf: unknown section"
336        );
337
338        let inline = CoreError::Config {
339            path: None,
340            message: "unknown section".to_string(),
341        };
342        assert_eq!(display(&inline), "invalid configuration: unknown section");
343    }
344
345    #[test]
346    fn io_display_names_the_path() {
347        let error = CoreError::Io {
348            path: Utf8PathBuf::from("/etc/pantry.conf"),
349            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
350        };
351        assert_eq!(display(&error), "i/o error on /etc/pantry.conf");
352        assert!(std::error::Error::source(&error).is_some());
353    }
354}