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