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}