Skip to main content

cookcli_core/
doctor.rs

1//! The checks behind `cook doctor`.
2//!
3//! [`validate`] walks every `.cook` and `.menu` file under a root, parses it,
4//! and reports what it found. Broken recipes are the *payload*, not a failure:
5//! a collection full of syntax errors still validates successfully. See
6//! [`Outcome`] for the rule. [`broken_references`] follows up on what it found,
7//! resolving the recipe references a report collected.
8//!
9//! [`aisle_coverage`] and [`pantry_coverage`] answer the other two questions
10//! `cook doctor` asks: which of a collection's ingredients are categorised in
11//! `aisle.conf`, and which of them are already in the pantry.
12
13use crate::{
14    diagnostic::{parse_failure, Severity},
15    find::{build_tree, listed_ingredients, parse_or_skip, walk},
16    parser::{collect_diagnostics, render_report, PARSER},
17    ConfigSource, Context, CoreError, Diagnostic, Outcome, Style,
18};
19use camino::{Utf8Path, Utf8PathBuf};
20use cooklang_find::RecipeEntry;
21use std::collections::{BTreeMap, BTreeSet};
22
23/// A validation run.
24///
25/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
26/// keeps a literal working if it grows a field.
27#[derive(Debug, Clone, Default)]
28pub struct ValidateRequest {
29    /// Directory whose recipes to validate. Defaults to the context base path.
30    ///
31    /// Every [`RecipeValidation::path`] is expressed against this, so it
32    /// doubles as the root results are reported relative to.
33    pub base_dir: Option<Utf8PathBuf>,
34    /// Whether [`RecipeValidation::rendered`] carries ANSI escape codes.
35    ///
36    /// Defaults to [`Style::Plain`], because a library must not put escape
37    /// codes in a string a web view or a log file might receive. The CLI passes
38    /// [`Style::Ansi`] for its terminal output. Nothing else about the result
39    /// depends on this.
40    pub style: Style,
41}
42
43/// What validating one recipe found.
44///
45/// `#[non_exhaustive]` because this is an output type consumers read rather
46/// than construct.
47#[non_exhaustive]
48#[derive(Debug, Clone)]
49pub struct RecipeValidation {
50    /// Where the recipe sits under the validation root.
51    ///
52    /// Always relative to that root, and the same path [`diagnostics`] and
53    /// [`rendered`] name. `cooklang-find` takes every path relative to the root
54    /// while building the tree, and fails the whole walk if it cannot, so there
55    /// is no way for an absolute path to reach this field.
56    ///
57    /// [`diagnostics`]: RecipeValidation::diagnostics
58    /// [`rendered`]: RecipeValidation::rendered
59    pub path: Utf8PathBuf,
60    /// Every problem the parser raised, errors and warnings alike, in the order
61    /// the parser produced them. Empty for a recipe with nothing wrong with it.
62    ///
63    /// A recipe that could not be read at all carries a single error
64    /// diagnostic saying so, rather than dropping out of the report.
65    pub diagnostics: Vec<Diagnostic>,
66    /// The parser's own multi-line report, with the offending source lines
67    /// quoted, ready to print verbatim. Empty exactly when [`diagnostics`] is —
68    /// except for a recipe that could not be read, which has a diagnostic but
69    /// no source to quote, so nothing to render.
70    ///
71    /// Carries ANSI escape codes when [`ValidateRequest::style`] is
72    /// [`Style::Ansi`], and none when it is [`Style::Plain`]. This is the one
73    /// difference from [`CoreError::Parse::rendered`], which is always plain.
74    ///
75    /// [`diagnostics`]: RecipeValidation::diagnostics
76    /// [`CoreError::Parse::rendered`]: crate::CoreError::Parse
77    pub rendered: String,
78    /// The recipes this one references, spelled as they are written in it —
79    /// `./sauce`, say. In source order, with a recipe referenced twice listed
80    /// twice.
81    ///
82    /// Nothing here has been resolved: a name in this list need not exist, and
83    /// checking that is the caller's job. Empty for a recipe with errors, since
84    /// `cooklang` produces no recipe to read them off — so a broken recipe's
85    /// references go unchecked rather than being reported as missing.
86    pub references: Vec<String>,
87}
88
89impl RecipeValidation {
90    /// How many of this recipe's diagnostics have the given severity.
91    fn count(&self, severity: Severity) -> usize {
92        self.diagnostics
93            .iter()
94            .filter(|d| d.severity == severity)
95            .count()
96    }
97}
98
99/// Everything [`validate`] found under one root.
100///
101/// The five totals the CLI prints are **methods rather than fields**. They hold
102/// nothing that [`recipes`](ValidationReport::recipes) does not, and computing
103/// them on demand is what makes it impossible for a total to disagree with the
104/// recipes it counts.
105///
106/// `#[non_exhaustive]` because this is an output type consumers read rather
107/// than construct.
108#[non_exhaustive]
109#[derive(Debug, Clone)]
110pub struct ValidationReport {
111    /// The root that was walked: [`ValidateRequest::base_dir`], or the
112    /// context's base path when that was unset.
113    ///
114    /// Carried so that a report is self-contained — every
115    /// [`RecipeValidation::path`] is relative to this, and
116    /// [`broken_references`] resolves against it without having to be told the
117    /// root a second time and possibly told it wrong.
118    pub base_dir: Utf8PathBuf,
119    /// Every recipe found under the root, clean ones included, in path order.
120    ///
121    /// The order is this crate's, not the walk's: `cooklang-find` holds a
122    /// directory's entries in a `HashMap`, so the walk itself yields them in an
123    /// order that changes between runs. Sorting makes a printed report
124    /// diffable.
125    pub recipes: Vec<RecipeValidation>,
126}
127
128impl ValidationReport {
129    /// How many recipes were scanned, valid or not.
130    pub fn total_recipes(&self) -> usize {
131        self.recipes.len()
132    }
133
134    /// How many recipes have at least one error.
135    pub fn recipes_with_errors(&self) -> usize {
136        self.recipes
137            .iter()
138            .filter(|r| r.count(Severity::Error) > 0)
139            .count()
140    }
141
142    /// How many recipes have at least one warning.
143    pub fn recipes_with_warnings(&self) -> usize {
144        self.recipes
145            .iter()
146            .filter(|r| r.count(Severity::Warning) > 0)
147            .count()
148    }
149
150    /// How many errors there are in total, across every recipe.
151    pub fn total_errors(&self) -> usize {
152        self.recipes.iter().map(|r| r.count(Severity::Error)).sum()
153    }
154
155    /// How many warnings there are in total, across every recipe.
156    pub fn total_warnings(&self) -> usize {
157        self.recipes
158            .iter()
159            .map(|r| r.count(Severity::Warning))
160            .sum()
161    }
162
163    /// The references each recipe makes, keyed by recipe path, for callers that
164    /// want to check them all at once. Recipes that reference nothing are left
165    /// out entirely.
166    ///
167    /// A view over [`RecipeValidation::references`], borrowed rather than
168    /// cloned, and ordered so that a caller reporting broken references reports
169    /// them the same way twice running.
170    ///
171    /// **Nothing here has been resolved**, and a reference that resolves to
172    /// nothing raises no diagnostic, so it does not reach
173    /// [`Outcome::diagnostics`] or [`has_errors`](Outcome::has_errors) either.
174    /// Pass the report to [`broken_references`] to find out which of these lead
175    /// anywhere.
176    pub fn references(&self) -> BTreeMap<&Utf8Path, &[String]> {
177        self.recipes
178            .iter()
179            .filter(|r| !r.references.is_empty())
180            .map(|r| (r.path.as_path(), r.references.as_slice()))
181            .collect()
182    }
183}
184
185/// Validate every recipe under `req`'s root.
186///
187/// The root is [`ValidateRequest::base_dir`], or [`Context::base_path`] when
188/// that is unset. Nothing else on the context is consulted.
189///
190/// # Errors are data
191///
192/// This returns `Ok` for a collection of entirely broken recipes: finding those
193/// errors *is* the job, and they come back in the report. It returns `Err` only
194/// when the walk could not happen at all. The returned [`Outcome`] also carries
195/// every diagnostic as one flat list, so that
196/// [`has_errors`](Outcome::has_errors) means what it says — the same
197/// diagnostics as in the report, each naming its own file.
198///
199/// # `has_errors` is not the whole verdict
200///
201/// **A broken recipe reference is not a diagnostic**, so a collection whose
202/// only fault is a reference leading nowhere has an empty
203/// [`Outcome::diagnostics`] and `has_errors() == false`. Resolving references
204/// costs a filesystem lookup each and needs a decision this function does not
205/// make, so it is [`broken_references`]'s separate job.
206///
207/// A caller gating on validation — a CI exit code, an editor's problem list —
208/// therefore wants both:
209///
210/// ```no_run
211/// # use cookcli_core::{doctor, Context};
212/// # fn main() -> Result<(), cookcli_core::CoreError> {
213/// # let ctx = Context::new("recipes".into());
214/// let outcome = doctor::validate(&ctx, doctor::ValidateRequest::default())?;
215/// let ok = !outcome.has_errors() && doctor::broken_references(&outcome.value).is_empty();
216/// # let _ = ok;
217/// # Ok(())
218/// # }
219/// ```
220///
221/// `cook doctor validate --strict` fails on either, which is why it does its
222/// own arithmetic over the two.
223///
224/// # Errors
225///
226/// - [`CoreError::Search`] if the root does not exist, is not a directory, or
227///   cannot be turned into a search pattern.
228/// - [`CoreError::Io`] if a file under the root turned up in the walk and could
229///   not be listed. A file that is listed and then cannot be *read* is not an
230///   error: it is one recipe in the report carrying one error diagnostic.
231pub fn validate(
232    ctx: &Context,
233    req: ValidateRequest,
234) -> Result<Outcome<ValidationReport>, CoreError> {
235    let base_dir = req
236        .base_dir
237        .unwrap_or_else(|| ctx.base_path().to_path_buf());
238
239    tracing::trace!("validating recipes under {base_dir}");
240
241    let tree = build_tree(&base_dir)?;
242
243    let mut recipes: Vec<RecipeValidation> = walk(&tree)
244        .into_iter()
245        .map(|entry| validate_entry(entry, &base_dir, req.style))
246        .collect();
247    // `walk` already orders the entries by their full path, which under one
248    // root is the same order; sorted again on the path as reported, because
249    // that is what this crate promises and what makes it true whatever `walk`
250    // decides to do.
251    recipes.sort_by(|a, b| a.path.cmp(&b.path));
252
253    let diagnostics = recipes
254        .iter()
255        .flat_map(|r| r.diagnostics.iter().cloned())
256        .collect();
257
258    Ok(Outcome::with_diagnostics(
259        ValidationReport { base_dir, recipes },
260        diagnostics,
261    ))
262}
263
264/// Read, parse and describe one recipe. Never fails: a recipe that cannot be
265/// read is described as such, so that one bad file does not end the walk.
266fn validate_entry(entry: &RecipeEntry, base_dir: &Utf8Path, style: Style) -> RecipeValidation {
267    // `build_tree` only ever produces named, file-backed entries, so neither
268    // fallback is reachable through it. They are kept because skipping an entry
269    // instead would make `total_recipes` disagree with the tree that was
270    // walked, which is worse than reporting a file that cannot be read.
271    let name = entry
272        .name()
273        .clone()
274        .unwrap_or_else(|| "unknown".to_string());
275    let full_path = entry.path().cloned().unwrap_or_else(|| base_dir.join(name));
276    let path = relative_to(base_dir, &full_path);
277
278    let content = match std::fs::read_to_string(&full_path) {
279        Ok(content) => content,
280        Err(e) => {
281            return RecipeValidation {
282                // Phrased as the CLI has always printed it. The path is not
283                // repeated into the message: it is on the location, and on the
284                // header every caller prints above it.
285                diagnostics: vec![
286                    Diagnostic::error(format!("Failed to read file: {e}")).at_file(path.clone())
287                ],
288                path,
289                rendered: String::new(),
290                references: Vec::new(),
291            };
292        }
293    };
294
295    let parsed = PARSER.parse(&content);
296    let diagnostics = collect_diagnostics(parsed.report(), Some(&path));
297
298    // `write` on an empty report produces an empty string anyway; the guard is
299    // to skip indexing the source lines of every healthy recipe in a
300    // collection, which is the common case.
301    let rendered = if diagnostics.is_empty() {
302        String::new()
303    } else {
304        render_report(parsed.report(), path.as_str(), &content, style.is_ansi())
305    };
306
307    // No output means no references: `cooklang` produces none for a recipe
308    // with errors, so a broken recipe contributes nothing here.
309    let references = parsed
310        .output()
311        .map(|recipe| {
312            recipe
313                .ingredients
314                .iter()
315                .filter_map(|ingredient| ingredient.reference.as_ref())
316                .map(|reference| {
317                    if reference.components.is_empty() {
318                        reference.name.clone()
319                    } else {
320                        reference.path("/")
321                    }
322                })
323                .collect()
324        })
325        .unwrap_or_default();
326
327    RecipeValidation {
328        path,
329        diagnostics,
330        rendered,
331        references,
332    }
333}
334
335/// Express `path` relative to `base_dir`, leaving it whole when it does not
336/// start with it.
337///
338/// Unlike the search module's namesake, the fallback here cannot fire:
339/// `build_tree` takes the same prefix off every path it yields, and fails the
340/// whole walk with `TreeError::StripPrefixError` rather than yielding one that
341/// will not strip. A root spelled `./recipes` is what provokes that — the walk
342/// resolves the pattern to `recipes/soup.cook`, losing the `./` — and it comes
343/// back as [`CoreError::Search`] from [`validate`], never as a path here.
344/// Returning the path whole still beats unwrapping, in a crate a NAPI addon
345/// calls.
346fn relative_to(base_dir: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
347    path.strip_prefix(base_dir).unwrap_or(path).to_owned()
348}
349
350// ---------------------------------------------------------------------------
351// Recipe references
352// ---------------------------------------------------------------------------
353
354/// Resolve every reference a report collected, keeping the ones that lead
355/// nowhere.
356///
357/// Keyed by the referring recipe, as [`ValidationReport::references`] is, and
358/// carrying the references in the order that recipe writes them — so a recipe
359/// making the same broken reference twice reports it twice. Recipes all of
360/// whose references resolve are left out entirely, so an empty map means every
361/// reference in the collection is good.
362///
363/// Every reference is resolved by [`find::resolve_reference`], the same way
364/// the shopping list follows one: from [`ValidationReport::base_dir`], the root
365/// that was validated, except for a `..`, which steps up from the directory of
366/// the recipe making it. A reference is judged by whether *the collection*
367/// holds a recipe of that name, so one pointing outside the validated root is
368/// broken however readable the file it lands on happens to be.
369///
370/// "Broken" is the whole of "could not be resolved to a readable recipe": a
371/// reference naming a file that exists but cannot be opened counts, exactly as
372/// one naming nothing at all does. Reporting them apart would need a reason on
373/// every entry, which nothing has yet asked for.
374///
375/// Nothing here fails, so there is no `Result`: this is the check, and what it
376/// finds is the answer. It does touch the filesystem, once per reference —
377/// which is why it is a function rather than a method on the report.
378pub fn broken_references(report: &ValidationReport) -> BTreeMap<&Utf8Path, Vec<String>> {
379    report
380        .references()
381        .into_iter()
382        .filter_map(|(recipe, references)| {
383            // `recipe` is the referring file, relative to the validated root;
384            // its directory is what a `..` in one of its references steps up
385            // from.
386            let from = recipe.parent().unwrap_or(Utf8Path::new("")).to_owned();
387            let broken: Vec<String> = references
388                .iter()
389                .filter(|reference| {
390                    crate::find::resolve_reference(&from, reference).is_none_or(|path| {
391                        crate::find::get_recipe(&report.base_dir, path.as_str()).is_err()
392                    })
393                })
394                .cloned()
395                .collect();
396            (!broken.is_empty()).then_some((recipe, broken))
397        })
398        .collect()
399}
400
401// ---------------------------------------------------------------------------
402// Ingredient coverage
403// ---------------------------------------------------------------------------
404
405/// Which collection to check a configuration against.
406///
407/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
408/// keeps a literal working if it grows a field.
409#[derive(Debug, Clone, Default)]
410pub struct CoverageRequest {
411    /// Directory whose recipes to scan. Defaults to the context base path.
412    pub base_dir: Option<Utf8PathBuf>,
413}
414
415/// One ingredient a collection uses, and whether a configuration knows it.
416///
417/// `#[non_exhaustive]` because this is an output type consumers read rather
418/// than construct.
419#[non_exhaustive]
420#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct CheckedIngredient {
422    /// The ingredient's name, spelled as the recipes write it. Where two
423    /// recipes spell it differently — `Salt` and `salt` — both spellings are
424    /// listed, even though the configuration is matched ignoring case.
425    pub name: String,
426    /// Whether the configuration names this ingredient.
427    pub known: bool,
428    /// The recipes that write this ingredient, as paths relative to the
429    /// scanned directory, in path order and without repeats.
430    ///
431    /// Spellings are separate ingredients here, so the recipes writing `Salt`
432    /// and those writing `salt` are listed against their own spelling. That is
433    /// the point of tracking them: it is how a collection's inconsistencies —
434    /// `ground cumin`, `cumin powder`, `cummin` — become findable.
435    ///
436    /// Never empty: an ingredient is only here because some recipe used it.
437    pub recipes: Vec<Utf8PathBuf>,
438}
439
440/// How much of a collection's ingredients a configuration accounts for.
441///
442/// The two views callers want — what is covered and what is not — are
443/// [`known`](IngredientCoverage::known) and
444/// [`unknown`](IngredientCoverage::unknown), derived from
445/// [`ingredients`](IngredientCoverage::ingredients) rather than stored beside
446/// it, so that they cannot disagree with it or with each other.
447///
448/// `#[non_exhaustive]` because this is an output type consumers read rather
449/// than construct.
450#[non_exhaustive]
451#[derive(Debug, Clone, Default, PartialEq, Eq)]
452pub struct IngredientCoverage {
453    /// How many recipes were scanned, including any that could not be read or
454    /// parsed — those contribute no ingredients, and say so in
455    /// [`Outcome::diagnostics`].
456    pub total_recipes: usize,
457    /// Every distinct ingredient the collection uses, each marked with whether
458    /// the configuration knows it.
459    ///
460    /// Ordered by Unicode code point rather than alphabetically, so every
461    /// capitalised name sorts before every lowercase one: `Beetroot`,
462    /// `Zucchini`, `apple`. That is what CookCLI has always printed, and
463    /// changing it would move output nobody asked to have moved — a consumer
464    /// wanting a human ordering should sort these itself.
465    ///
466    /// References to other recipes are not ingredients and are left out.
467    pub ingredients: Vec<CheckedIngredient>,
468}
469
470impl IngredientCoverage {
471    /// How many distinct ingredients the collection uses.
472    pub fn total_ingredients(&self) -> usize {
473        self.ingredients.len()
474    }
475
476    /// The ingredients the configuration knows, in the order of
477    /// [`ingredients`](IngredientCoverage::ingredients).
478    pub fn known(&self) -> impl Iterator<Item = &str> {
479        self.known_entries()
480            .map(|ingredient| ingredient.name.as_str())
481    }
482
483    /// The ingredients the configuration does not know, in the order of
484    /// [`ingredients`](IngredientCoverage::ingredients).
485    ///
486    /// With no configuration at all this is every ingredient, since nothing is
487    /// known. Ask [`ConfigSource::is_unset`] if you need to tell that from a
488    /// configuration that simply covers nothing.
489    pub fn unknown(&self) -> impl Iterator<Item = &str> {
490        self.unknown_entries()
491            .map(|ingredient| ingredient.name.as_str())
492    }
493
494    /// As [`known`](IngredientCoverage::known), but keeping each ingredient
495    /// whole — its [`recipes`](CheckedIngredient::recipes) along with its name.
496    pub fn known_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
497        self.filtered(true)
498    }
499
500    /// As [`unknown`](IngredientCoverage::unknown), but keeping each ingredient
501    /// whole — its [`recipes`](CheckedIngredient::recipes) along with its name.
502    ///
503    /// This is the view that answers "which recipes do I have to go and fix?":
504    /// an uncategorised ingredient is usually a misspelling of a categorised
505    /// one, and the recipes listed against it are where that misspelling is.
506    pub fn unknown_entries(&self) -> impl Iterator<Item = &CheckedIngredient> {
507        self.filtered(false)
508    }
509
510    fn filtered(&self, known: bool) -> impl Iterator<Item = &CheckedIngredient> {
511        self.ingredients
512            .iter()
513            .filter(move |ingredient| ingredient.known == known)
514    }
515}
516
517/// Check the collection's ingredients against the aisle configuration
518/// [`Context::aisle`] names — the question `cook doctor aisle` asks.
519///
520/// An ingredient is known when the configuration names it, or names it as a
521/// synonym of something else, compared lowercased and otherwise exactly. With
522/// no aisle configuration nothing is known; see
523/// [`unknown`](IngredientCoverage::unknown).
524///
525/// The fold is `str::to_lowercase`, so it is case-insensitive over the whole of
526/// Unicode rather than ASCII alone: `Öl` matches an entry spelled `öl`. `cook
527/// doctor aisle` compared with `eq_ignore_ascii_case` before this moved here,
528/// and so reported such a name as uncategorised.
529///
530/// A configuration that parses with warnings — a duplicate entry, say — is a
531/// successful check carrying those warnings as [`Outcome::diagnostics`],
532/// located in the file when it came from one.
533///
534/// # Errors
535///
536/// - [`CoreError::Io`] if the configuration is named but cannot be read.
537/// - [`CoreError::Config`] if it cannot be parsed at all. `cooklang`'s aisle
538///   parser is documented as never failing this way, so this is unreachable
539///   today; it is listed because the signature admits it and
540///   [`pantry_coverage`], which shares the code, does reach it.
541/// - [`CoreError::Search`] if the collection cannot be walked, and
542///   [`CoreError::Io`] if a file in it cannot be listed — as [`validate`]. A
543///   recipe that cannot be *parsed* is not an error: it is left out, with a
544///   warning in [`Outcome::diagnostics`].
545pub fn aisle_coverage(
546    ctx: &Context,
547    req: CoverageRequest,
548) -> Result<Outcome<IngredientCoverage>, CoreError> {
549    let source = ctx.aisle();
550    let mut diagnostics = Vec::new();
551    let mut known = BTreeSet::new();
552
553    if let Some(text) = source.read()? {
554        let parsed = cooklang::aisle::parse_lenient(&text);
555        diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
556        let conf = parsed
557            .output()
558            .ok_or_else(|| config_error(source, "aisle", &diagnostics))?;
559        // The map is keyed by each name and synonym, already lowercased.
560        known.extend(conf.ingredients_info().into_keys());
561    }
562
563    coverage(ctx, req, &known, diagnostics)
564}
565
566/// Check the collection's ingredients against the pantry configuration
567/// [`Context::pantry`] names — the question `cook doctor pantry` asks.
568///
569/// An ingredient is known when an item of that name is in stock, in any
570/// section, compared lowercased and otherwise exactly; no quantity or date is
571/// considered, so an item that has run out still counts as known. With no
572/// pantry configuration nothing is known; see
573/// [`unknown`](IngredientCoverage::unknown).
574///
575/// Note the direction: this reports on the *collection's* ingredients, so a
576/// pantry item no recipe uses is not mentioned at all.
577///
578/// # Errors
579///
580/// Exactly as [`aisle_coverage`], except that [`CoreError::Config`] is
581/// genuinely reachable here — a `pantry.conf` that is not TOML — and comes back
582/// worded identically to [`pantry::load`](crate::pantry::load)'s, so that two
583/// commands reading one broken file say the same thing about it.
584pub fn pantry_coverage(
585    ctx: &Context,
586    req: CoverageRequest,
587) -> Result<Outcome<IngredientCoverage>, CoreError> {
588    let source = ctx.pantry();
589    let mut diagnostics = Vec::new();
590    let mut known = BTreeSet::new();
591
592    if let Some(text) = source.read()? {
593        let parsed = cooklang::pantry::parse_lenient(&text);
594        diagnostics.extend(collect_diagnostics(parsed.report(), source.path()));
595        let conf = parsed
596            .output()
597            .ok_or_else(|| config_error(source, "pantry", &diagnostics))?;
598        known.extend(conf.all_items().map(|item| item.name().to_lowercase()));
599    }
600
601    coverage(ctx, req, &known, diagnostics)
602}
603
604/// The failure that leaves a lenient parse with no configuration at all.
605///
606/// The cause is taken from the diagnostics the parse did produce, through the
607/// same [`parse_failure`] the pantry module words its own failures with — a
608/// caller told only "it could not be parsed" has nothing to go and fix.
609fn config_error(source: &ConfigSource, kind: &str, diagnostics: &[Diagnostic]) -> CoreError {
610    CoreError::Config {
611        path: source.path().map(ToOwned::to_owned),
612        message: parse_failure(diagnostics, kind),
613    }
614}
615
616/// Scan the collection and mark each ingredient against `known`, which holds
617/// the configuration's names already lowercased.
618fn coverage(
619    ctx: &Context,
620    req: CoverageRequest,
621    known: &BTreeSet<String>,
622    mut diagnostics: Vec<Diagnostic>,
623) -> Result<Outcome<IngredientCoverage>, CoreError> {
624    let base_dir = req
625        .base_dir
626        .unwrap_or_else(|| ctx.base_path().to_path_buf());
627
628    let tree = build_tree(&base_dir)?;
629    let entries = walk(&tree);
630    let total_recipes = entries.len();
631
632    // A map, so an ingredient two recipes share is one ingredient carrying
633    // both of them. Ordered throughout, so that neither the ingredients nor
634    // any one ingredient's recipes depend on the order `cooklang-find`
635    // happened to yield the directories in.
636    let mut names: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
637    for entry in entries {
638        let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
639            continue;
640        };
641        // Relative to the directory that was scanned, as `validate` reports a
642        // recipe's path, so that both halves of `cook doctor` name a file the
643        // same way. The fallbacks are `validate_entry`'s and unreachable for
644        // the same reason: `build_tree` only makes named, file-backed entries.
645        let path = entry
646            .path()
647            .map(|path| relative_to(&base_dir, path))
648            .or_else(|| entry.name().clone().map(Utf8PathBuf::from))
649            .unwrap_or_else(|| Utf8PathBuf::from("unknown"));
650        for name in listed_ingredients(&recipe) {
651            names.entry(name).or_default().insert(path.clone());
652        }
653    }
654
655    let ingredients = names
656        .into_iter()
657        .map(|(name, recipes)| CheckedIngredient {
658            known: known.contains(&name.to_lowercase()),
659            name,
660            recipes: recipes.into_iter().collect(),
661        })
662        .collect();
663
664    Ok(Outcome::with_diagnostics(
665        IngredientCoverage {
666            total_recipes,
667            ingredients,
668        },
669        diagnostics,
670    ))
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::find::tree_error;
677    use cooklang_find::tree::TreeError;
678
679    const CLEAN: &str = "---\ntitle: Basic Sauce\n---\n\nHeat @oil{2%tbsp} in a #pan.\n";
680    /// Deprecated `>>` metadata parses, with one warning.
681    const DEPRECATED: &str = ">> title: Old Style\n\nBoil @water{1%l}.\n";
682    /// Two ingredients with quantities but no name: two hard parse errors.
683    const BROKEN: &str = "---\ntitle: Broken\n---\n\nAdd @{1%tsp} and @{2%tsp}.\n";
684
685    /// A collection with one of everything: a clean recipe in a subdirectory,
686    /// a clean recipe making references, one that warns and one that errors.
687    fn fixture() -> tempfile::TempDir {
688        let dir = tempfile::TempDir::new().unwrap();
689        let base = base(&dir);
690        std::fs::create_dir(base.join("Breakfast")).unwrap();
691        write(
692            &base.join("Breakfast").join("pancakes.cook"),
693            "---\ntitle: Pancakes\n---\n\nMix @flour{2%cups}.\n",
694        );
695        write(&base.join("sauce.cook"), CLEAN);
696        write(
697            &base.join("with_ref.cook"),
698            "---\ntitle: With Reference\n---\n\nMake @./sauce{} and @./nonexistent{}.\n",
699        );
700        write(&base.join("deprecated.cook"), DEPRECATED);
701        write(&base.join("broken.cook"), BROKEN);
702        dir
703    }
704
705    fn write(path: &Utf8Path, text: &str) {
706        std::fs::write(path, text).unwrap();
707    }
708
709    fn base(dir: &tempfile::TempDir) -> Utf8PathBuf {
710        Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).unwrap()
711    }
712
713    /// Validate a root, through the context base path.
714    fn run(base_dir: &Utf8Path) -> ValidationReport {
715        run_styled(base_dir, Style::Plain)
716    }
717
718    fn run_styled(base_dir: &Utf8Path, style: Style) -> ValidationReport {
719        validate(
720            &Context::new(base_dir.to_owned()),
721            ValidateRequest {
722                base_dir: None,
723                style,
724            },
725        )
726        .expect("validation succeeds")
727        .into_value()
728    }
729
730    /// The reported paths, left as paths rather than stringified.
731    ///
732    /// That is what makes the assertions below portable: camino compares a
733    /// path component by component, so the `Breakfast\pancakes.cook` the walk
734    /// produces on Windows equals the `Breakfast/pancakes.cook` written here,
735    /// while comparing the two as strings would not. Nothing else is
736    /// loosened — a recipe reported under a different directory, or with a
737    /// different file name, still fails.
738    fn paths(report: &ValidationReport) -> Vec<Utf8PathBuf> {
739        report.recipes.iter().map(|r| r.path.clone()).collect()
740    }
741
742    fn recipe<'a>(report: &'a ValidationReport, path: &str) -> &'a RecipeValidation {
743        report
744            .recipes
745            .iter()
746            .find(|r| r.path == path)
747            .unwrap_or_else(|| panic!("{path} missing from {:?}", paths(report)))
748    }
749
750    /// Subdirectories are walked, and every recipe is reported whether or not
751    /// there is anything wrong with it.
752    #[test]
753    fn every_recipe_under_the_root_is_reported_including_nested_ones() {
754        let dir = fixture();
755        let report = run(&base(&dir));
756        assert_eq!(
757            paths(&report),
758            [
759                "Breakfast/pancakes.cook",
760                "broken.cook",
761                "deprecated.cook",
762                "sauce.cook",
763                "with_ref.cook",
764            ]
765        );
766        assert_eq!(report.total_recipes(), 5);
767    }
768
769    /// The five totals the CLI prints, pinned together so that one of them
770    /// going wrong cannot hide behind another.
771    #[test]
772    fn totals_count_diagnostics_and_the_recipes_carrying_them() {
773        let dir = fixture();
774        let report = run(&base(&dir));
775
776        assert_eq!(report.total_recipes(), 5);
777        assert_eq!(report.total_errors(), 2, "both errors in broken.cook");
778        assert_eq!(report.recipes_with_errors(), 1);
779        assert_eq!(report.total_warnings(), 1, "deprecated.cook warns once");
780        assert_eq!(report.recipes_with_warnings(), 1);
781    }
782
783    #[test]
784    fn a_failing_recipe_carries_both_diagnostics_and_a_rendered_report() {
785        let dir = fixture();
786        let report = run(&base(&dir));
787        let broken = recipe(&report, "broken.cook");
788
789        assert_eq!(broken.diagnostics.len(), 2);
790        for d in &broken.diagnostics {
791            assert_eq!(d.severity, Severity::Error, "expected errors: {d:?}");
792        }
793        // The structured half locates the problem in the file it came from.
794        let location = broken.diagnostics[0]
795            .location
796            .as_ref()
797            .expect("location set");
798        assert_eq!(location.file.as_deref(), Some(Utf8Path::new("broken.cook")));
799        assert!(location.span.is_some(), "an error must carry its span");
800
801        // ...and the rendered half quotes the source, which is the whole
802        // reason it is carried alongside.
803        assert!(
804            broken.rendered.contains("broken.cook"),
805            "report should name the file: {}",
806            broken.rendered
807        );
808        assert!(
809            broken.rendered.contains("Add @{1%tsp} and @{2%tsp}."),
810            "report should quote the source line: {}",
811            broken.rendered
812        );
813    }
814
815    #[test]
816    fn a_warning_is_reported_as_a_warning_not_an_error() {
817        let dir = fixture();
818        let report = run(&base(&dir));
819        let deprecated = recipe(&report, "deprecated.cook");
820
821        assert_eq!(deprecated.diagnostics.len(), 1);
822        assert_eq!(deprecated.diagnostics[0].severity, Severity::Warning);
823        assert!(
824            !deprecated.rendered.is_empty(),
825            "a warning is worth showing"
826        );
827    }
828
829    #[test]
830    fn a_clean_recipe_carries_neither_diagnostics_nor_a_rendered_report() {
831        let dir = fixture();
832        let report = run(&base(&dir));
833        let clean = recipe(&report, "sauce.cook");
834
835        assert!(clean.diagnostics.is_empty(), "{:?}", clean.diagnostics);
836        assert!(clean.rendered.is_empty(), "{:?}", clean.rendered);
837    }
838
839    #[test]
840    fn references_are_collected_as_they_are_written() {
841        let dir = fixture();
842        let report = run(&base(&dir));
843
844        assert_eq!(
845            recipe(&report, "with_ref.cook").references,
846            ["./sauce", "./nonexistent"],
847            "in source order, unresolved"
848        );
849        assert!(recipe(&report, "sauce.cook").references.is_empty());
850    }
851
852    /// A recipe referenced twice is listed twice: the list is what the recipe
853    /// says, not a set. Deduplicating here would quietly hide a double
854    /// reference from anything counting them.
855    #[test]
856    fn a_reference_made_twice_is_listed_twice() {
857        let dir = tempfile::TempDir::new().unwrap();
858        let base = base(&dir);
859        write(&base.join("sauce.cook"), CLEAN);
860        write(
861            &base.join("dup.cook"),
862            "---\ntitle: Dup\n---\n\nMake @./sauce{}, then more @./sauce{}.\n",
863        );
864
865        let report = run(&base);
866        assert_eq!(
867            recipe(&report, "dup.cook").references,
868            ["./sauce", "./sauce"]
869        );
870    }
871
872    /// `cooklang` produces no recipe at all for one with errors, so there is
873    /// nothing to read references off. Pinned because it is the reason a broken
874    /// recipe's references go unchecked, which reads like a bug until you know
875    /// it is deliberate.
876    #[test]
877    fn a_recipe_with_errors_contributes_no_references() {
878        let dir = tempfile::TempDir::new().unwrap();
879        let base = base(&dir);
880        write(
881            &base.join("broken_ref.cook"),
882            "---\ntitle: Broken\n---\n\nMake @./sauce{} and add @{1%tsp}.\n",
883        );
884
885        let report = run(&base);
886        let broken = recipe(&report, "broken_ref.cook");
887        assert_eq!(broken.count(Severity::Error), 1, "{:?}", broken.diagnostics);
888        assert!(
889            broken.references.is_empty(),
890            "expected no references, got {:?}",
891            broken.references
892        );
893        assert!(report.references().is_empty());
894    }
895
896    /// The map view leaves out recipes that reference nothing, so a caller
897    /// checking references does not have to.
898    #[test]
899    fn the_reference_map_holds_only_recipes_that_reference_something() {
900        let dir = fixture();
901        let report = run(&base(&dir));
902        let references = report.references();
903
904        assert_eq!(
905            references.keys().copied().collect::<Vec<_>>(),
906            [Utf8Path::new("with_ref.cook")]
907        );
908        assert_eq!(references[Utf8Path::new("with_ref.cook")].len(), 2);
909    }
910
911    /// A file listed by the walk that cannot then be read is one error, and the
912    /// walk carries on. The file is valid UTF-8 through its front matter — so
913    /// the walk lists it — and invalid after, so reading the whole of it fails.
914    /// That needs no permission games, and so behaves the same on every
915    /// platform.
916    #[test]
917    fn a_file_that_cannot_be_read_is_one_error_and_does_not_end_the_walk() {
918        let dir = fixture();
919        let base = base(&dir);
920        std::fs::write(
921            base.join("bad_bytes.cook"),
922            b"---\ntitle: Bad Bytes\n---\n\nBoil @water{1%l} \xFF.\n",
923        )
924        .unwrap();
925
926        let report = run(&base);
927        let unreadable = recipe(&report, "bad_bytes.cook");
928
929        assert_eq!(unreadable.diagnostics.len(), 1);
930        assert_eq!(unreadable.diagnostics[0].severity, Severity::Error);
931        assert!(
932            unreadable.diagnostics[0]
933                .message
934                .starts_with("Failed to read file"),
935            "{:?}",
936            unreadable.diagnostics[0]
937        );
938        assert!(
939            unreadable.rendered.is_empty(),
940            "there is no source to quote: {:?}",
941            unreadable.rendered
942        );
943        assert_eq!(unreadable.references, Vec::<String>::new());
944
945        // It counts, once, as one error in one recipe...
946        assert_eq!(report.total_recipes(), 6);
947        assert_eq!(report.total_errors(), 3);
948        assert_eq!(report.recipes_with_errors(), 2);
949        // ...and everything else was still validated.
950        assert_eq!(recipe(&report, "broken.cook").diagnostics.len(), 2);
951        assert!(recipe(&report, "sauce.cook").diagnostics.is_empty());
952    }
953
954    /// Broken recipes are the payload. Only the walk failing is an `Err`.
955    #[test]
956    fn a_collection_full_of_errors_still_validates_successfully() {
957        let dir = tempfile::TempDir::new().unwrap();
958        write(&base(&dir).join("broken.cook"), BROKEN);
959
960        let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default())
961            .expect("errors in recipes are data, not a failed command");
962
963        assert_eq!(outcome.value.total_errors(), 2);
964        assert!(
965            outcome.has_errors(),
966            "the outcome must carry the errors it found: {:?}",
967            outcome.diagnostics
968        );
969        assert_eq!(
970            outcome.diagnostics.len(),
971            2,
972            "every diagnostic in the report, flat"
973        );
974    }
975
976    /// A clean collection says so in both places.
977    #[test]
978    fn a_clean_collection_has_no_diagnostics_at_all() {
979        let dir = tempfile::TempDir::new().unwrap();
980        write(&base(&dir).join("sauce.cook"), CLEAN);
981
982        let outcome = validate(&Context::new(base(&dir)), ValidateRequest::default()).unwrap();
983        assert!(!outcome.has_errors());
984        assert!(outcome.diagnostics.is_empty());
985        assert_eq!(outcome.value.total_errors(), 0);
986        assert_eq!(outcome.value.total_warnings(), 0);
987        assert_eq!(outcome.value.total_recipes(), 1);
988    }
989
990    #[test]
991    fn style_decides_whether_the_rendered_report_is_coloured() {
992        let dir = fixture();
993        let base = base(&dir);
994
995        let plain = run_styled(&base, Style::Plain);
996        let plain = &recipe(&plain, "broken.cook").rendered;
997        assert!(
998            !plain.contains('\u{1b}'),
999            "Style::Plain must emit no escape codes: {plain:?}"
1000        );
1001
1002        let coloured = run_styled(&base, Style::Ansi);
1003        let coloured = &recipe(&coloured, "broken.cook").rendered;
1004        assert!(
1005            coloured.contains('\u{1b}'),
1006            "Style::Ansi must emit escape codes: {coloured:?}"
1007        );
1008        assert_eq!(
1009            *plain,
1010            anstream::adapter::strip_str(coloured).to_string(),
1011            "the two must differ only in the escape codes"
1012        );
1013    }
1014
1015    /// The default is the safe one, because a library must not hand escape
1016    /// codes to a caller that never asked for them.
1017    #[test]
1018    fn the_default_style_is_plain() {
1019        assert_eq!(ValidateRequest::default().style, Style::Plain);
1020    }
1021
1022    #[test]
1023    fn base_dir_overrides_the_context_base_path() {
1024        let validated = fixture();
1025        let ignored = tempfile::TempDir::new().unwrap();
1026        write(&base(&ignored).join("decoy.cook"), BROKEN);
1027
1028        let report = validate(
1029            &Context::new(base(&ignored)),
1030            ValidateRequest {
1031                base_dir: Some(base(&validated)),
1032                style: Style::Plain,
1033            },
1034        )
1035        .expect("validation succeeds")
1036        .into_value();
1037
1038        assert_eq!(report.total_recipes(), 5);
1039        assert!(
1040            !paths(&report).contains(&Utf8PathBuf::from("decoy.cook")),
1041            "{:?}",
1042            paths(&report)
1043        );
1044    }
1045
1046    #[test]
1047    fn without_a_base_dir_the_context_base_path_is_validated() {
1048        let dir = fixture();
1049        assert_eq!(run(&base(&dir)).total_recipes(), 5);
1050    }
1051
1052    /// `cooklang-find` holds a directory's entries in a `HashMap`, so the walk
1053    /// order changes from run to run. Sorting is what makes a printed report
1054    /// diffable, and it is asserted over several runs because a single run of
1055    /// an unsorted walk can come out sorted by luck.
1056    #[test]
1057    fn recipes_come_back_in_path_order_every_time() {
1058        let dir = fixture();
1059        let base = base(&dir);
1060        let expected = paths(&run(&base));
1061        let mut sorted = expected.clone();
1062        sorted.sort();
1063        assert_eq!(expected, sorted);
1064
1065        for _ in 0..8 {
1066            assert_eq!(paths(&run(&base)), expected, "order must not vary");
1067        }
1068    }
1069
1070    #[test]
1071    fn an_empty_directory_validates_to_an_empty_report() {
1072        let dir = tempfile::TempDir::new().unwrap();
1073        let report = run(&base(&dir));
1074        assert_eq!(report.total_recipes(), 0);
1075        assert!(report.references().is_empty());
1076    }
1077
1078    /// Unlike a search, a root that is not there is a real failure: there is
1079    /// nothing to validate and the caller almost certainly mistyped it.
1080    #[test]
1081    fn a_root_that_does_not_exist_is_reported() {
1082        let dir = tempfile::TempDir::new().unwrap();
1083        let missing = base(&dir).join("nope");
1084
1085        match validate(&Context::new(missing.clone()), ValidateRequest::default()) {
1086            Err(CoreError::Search { base_dir, message }) => {
1087                assert_eq!(base_dir, missing);
1088                assert_eq!(message, "no such directory");
1089            }
1090            other => panic!(
1091                "expected CoreError::Search, got {:?}",
1092                other.map(|o| o.value)
1093            ),
1094        }
1095    }
1096
1097    #[test]
1098    fn a_root_that_is_a_file_is_reported() {
1099        let dir = tempfile::TempDir::new().unwrap();
1100        let file = base(&dir).join("sauce.cook");
1101        write(&file, CLEAN);
1102
1103        match validate(&Context::new(file.clone()), ValidateRequest::default()) {
1104            Err(CoreError::Search { base_dir, message }) => {
1105                assert_eq!(base_dir, file);
1106                assert_eq!(message, "not a directory");
1107            }
1108            other => panic!(
1109                "expected CoreError::Search, got {:?}",
1110                other.map(|o| o.value)
1111            ),
1112        }
1113    }
1114
1115    /// A root whose name contains glob syntax is a real directory a user can
1116    /// really have, and it cannot be turned into a pattern.
1117    #[test]
1118    fn a_root_that_is_not_a_valid_glob_pattern_is_reported() {
1119        let dir = tempfile::TempDir::new().unwrap();
1120        let root = base(&dir).join("re[ci");
1121        std::fs::create_dir(&root).unwrap();
1122        write(&root.join("sauce.cook"), CLEAN);
1123
1124        match validate(&Context::new(root.clone()), ValidateRequest::default()) {
1125            Err(CoreError::Search { base_dir, message }) => {
1126                assert_eq!(base_dir, root);
1127                assert!(
1128                    message.contains("attern"),
1129                    "the cause must survive: {message}"
1130                );
1131            }
1132            other => panic!(
1133                "expected CoreError::Search, got {:?}",
1134                other.map(|o| o.value)
1135            ),
1136        }
1137    }
1138
1139    /// The remaining mappings need a failure that cannot be arranged from a
1140    /// test, so they are pinned through `tree_error` directly.
1141    #[test]
1142    fn a_listing_failure_names_the_file_rather_than_the_root() {
1143        let root = Utf8Path::new("/recipes");
1144
1145        let unusable = tree_error(
1146            TreeError::RecipeEntryError(cooklang_find::RecipeEntryError::MetadataError(
1147                "bad front matter".to_string(),
1148            )),
1149            root,
1150        );
1151        match unusable {
1152            CoreError::Io { path, source } => {
1153                assert_eq!(path, root);
1154                assert!(
1155                    source.to_string().contains("bad front matter"),
1156                    "the cause must survive: {source}"
1157                );
1158            }
1159            other => panic!("expected CoreError::Io, got {other:?}"),
1160        }
1161
1162        let unstrippable = tree_error(
1163            TreeError::StripPrefixError("recipes/soup.cook".to_string()),
1164            root,
1165        );
1166        match unstrippable {
1167            CoreError::Search { base_dir, message } => {
1168                assert_eq!(base_dir, root);
1169                assert!(message.contains("recipes/soup.cook"), "{message}");
1170            }
1171            other => panic!("expected CoreError::Search, got {other:?}"),
1172        }
1173    }
1174
1175    #[test]
1176    fn a_path_under_the_root_is_stripped_and_one_outside_is_left_alone() {
1177        assert_eq!(
1178            relative_to(
1179                Utf8Path::new("/recipes"),
1180                Utf8Path::new("/recipes/Breakfast/pancakes.cook")
1181            ),
1182            "Breakfast/pancakes.cook"
1183        );
1184        assert_eq!(
1185            relative_to(
1186                Utf8Path::new("./recipes"),
1187                Utf8Path::new("recipes/soup.cook")
1188            ),
1189            "recipes/soup.cook"
1190        );
1191    }
1192
1193    // -----------------------------------------------------------------------
1194    // Recipe references
1195    // -----------------------------------------------------------------------
1196
1197    /// The broken references of a collection, as pairs, for readable
1198    /// assertions.
1199    fn broken(report: &ValidationReport) -> Vec<(String, Vec<String>)> {
1200        broken_references(report)
1201            .into_iter()
1202            .map(|(recipe, missing)| (recipe.to_string(), missing))
1203            .collect()
1204    }
1205
1206    #[test]
1207    fn the_report_records_the_root_it_was_validated_against() {
1208        let dir = fixture();
1209        assert_eq!(run(&base(&dir)).base_dir, base(&dir));
1210
1211        let elsewhere = tempfile::TempDir::new().unwrap();
1212        let report = validate(
1213            &Context::new(base(&elsewhere)),
1214            ValidateRequest {
1215                base_dir: Some(base(&dir)),
1216                style: Style::Plain,
1217            },
1218        )
1219        .expect("validation succeeds")
1220        .into_value();
1221        assert_eq!(
1222            report.base_dir,
1223            base(&dir),
1224            "the root that was walked, not the context's"
1225        );
1226    }
1227
1228    /// `with_ref.cook` makes two references: one to a recipe that is there and
1229    /// one to a recipe that is not. Only the second is reported.
1230    #[test]
1231    fn only_references_that_resolve_to_nothing_are_reported() {
1232        let dir = fixture();
1233        assert_eq!(
1234            broken(&run(&base(&dir))),
1235            [(
1236                "with_ref.cook".to_string(),
1237                vec!["./nonexistent".to_string()]
1238            )]
1239        );
1240    }
1241
1242    #[test]
1243    fn a_collection_whose_references_all_resolve_reports_none() {
1244        let dir = tempfile::TempDir::new().unwrap();
1245        let base = base(&dir);
1246        write(&base.join("sauce.cook"), CLEAN);
1247        write(
1248            &base.join("dish.cook"),
1249            "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1250        );
1251
1252        assert!(
1253            broken_references(&run(&base)).is_empty(),
1254            "a resolvable reference must not be reported"
1255        );
1256    }
1257
1258    /// A recipe that makes the same broken reference twice has something wrong
1259    /// with it twice, and the CLI counts one error per mention.
1260    #[test]
1261    fn a_reference_repeated_is_reported_once_per_mention() {
1262        let dir = tempfile::TempDir::new().unwrap();
1263        let base = base(&dir);
1264        write(
1265            &base.join("dish.cook"),
1266            "---\ntitle: Dish\n---\n\nMake @./absent{}, then more @./absent{}.\n",
1267        );
1268
1269        assert_eq!(
1270            broken(&run(&base)),
1271            [(
1272                "dish.cook".to_string(),
1273                vec!["./absent".to_string(), "./absent".to_string()]
1274            )]
1275        );
1276    }
1277
1278    /// References are looked up in the collection as a whole, so a recipe in a
1279    /// subdirectory can reference one at the root. This is what makes the
1280    /// spelling `./sauce` work from anywhere, and it is the behaviour `cook
1281    /// doctor validate` has always had.
1282    #[test]
1283    fn references_resolve_against_the_validated_root_from_anywhere_in_it() {
1284        let dir = tempfile::TempDir::new().unwrap();
1285        let base = base(&dir);
1286        write(&base.join("sauce.cook"), CLEAN);
1287        std::fs::create_dir(base.join("Dinner")).unwrap();
1288        write(
1289            &base.join("Dinner").join("dish.cook"),
1290            "---\ntitle: Dish\n---\n\nMake @./sauce{}.\n",
1291        );
1292
1293        assert!(
1294            broken_references(&run(&base)).is_empty(),
1295            "a nested recipe must be able to reference the root's"
1296        );
1297    }
1298
1299    /// A collection with nothing to check comes back empty rather than
1300    /// reporting anything.
1301    #[test]
1302    fn a_collection_with_no_references_has_none_broken() {
1303        let dir = tempfile::TempDir::new().unwrap();
1304        write(&base(&dir).join("sauce.cook"), CLEAN);
1305        assert!(broken_references(&run(&base(&dir))).is_empty());
1306    }
1307
1308    // -----------------------------------------------------------------------
1309    // Ingredient coverage
1310    // -----------------------------------------------------------------------
1311
1312    /// A collection of one recipe, so that a check has something to scan.
1313    fn one_recipe(text: &str) -> tempfile::TempDir {
1314        let dir = tempfile::TempDir::new().unwrap();
1315        write(&base(&dir).join("dish.cook"), text);
1316        dir
1317    }
1318
1319    fn aisle_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1320        Context::new(base(dir)).with_aisle(ConfigSource::Inline(conf.to_string()))
1321    }
1322
1323    fn pantry_ctx(dir: &tempfile::TempDir, conf: &str) -> Context {
1324        Context::new(base(dir)).with_pantry(ConfigSource::Inline(conf.to_string()))
1325    }
1326
1327    fn checked(ctx: &Context, aisle: bool) -> Outcome<IngredientCoverage> {
1328        let request = CoverageRequest::default();
1329        if aisle {
1330            aisle_coverage(ctx, request)
1331        } else {
1332            pantry_coverage(ctx, request)
1333        }
1334        .expect("the check succeeds")
1335    }
1336
1337    fn known(coverage: &IngredientCoverage) -> Vec<&str> {
1338        coverage.known().collect()
1339    }
1340
1341    fn unknown(coverage: &IngredientCoverage) -> Vec<&str> {
1342        coverage.unknown().collect()
1343    }
1344
1345    fn all(coverage: &IngredientCoverage) -> Vec<&str> {
1346        coverage
1347            .ingredients
1348            .iter()
1349            .map(|ingredient| ingredient.name.as_str())
1350            .collect()
1351    }
1352
1353    /// The recipes listed against one ingredient, by name.
1354    ///
1355    /// As paths rather than strings, so that the expectations can be written
1356    /// with `/` and still hold on Windows: `Utf8Path` compares component by
1357    /// component, where `&str` would compare `Breakfast/porridge.cook` against
1358    /// the `Breakfast\porridge.cook` the platform actually produces.
1359    fn recipes_for<'a>(coverage: &'a IngredientCoverage, name: &str) -> Vec<&'a Utf8Path> {
1360        coverage
1361            .ingredients
1362            .iter()
1363            .find(|ingredient| ingredient.name == name)
1364            .unwrap_or_else(|| panic!("{name} is in the coverage"))
1365            .recipes
1366            .iter()
1367            .map(Utf8PathBuf::as_path)
1368            .collect()
1369    }
1370
1371    #[test]
1372    fn an_aisle_splits_the_collection_into_categorised_and_not() {
1373        let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1374        let coverage = checked(
1375            &aisle_ctx(&dir, "[produce]\nleek\n\n[pantry]\nsalt\n"),
1376            true,
1377        )
1378        .value;
1379
1380        assert_eq!(known(&coverage), ["leek", "salt"]);
1381        assert_eq!(unknown(&coverage), ["water"]);
1382        assert_eq!(coverage.total_ingredients(), 3);
1383        assert_eq!(coverage.total_recipes, 1);
1384    }
1385
1386    /// An aisle entry naming several spellings of one thing knows all of them.
1387    #[test]
1388    fn an_aisle_synonym_counts_as_knowing_the_ingredient() {
1389        let dir = one_recipe("Add @aubergine{1}.\n");
1390        let coverage = checked(&aisle_ctx(&dir, "[produce]\neggplant|aubergine\n"), true).value;
1391
1392        assert_eq!(known(&coverage), ["aubergine"]);
1393        assert!(unknown(&coverage).is_empty());
1394    }
1395
1396    #[test]
1397    fn a_pantry_knows_its_items_from_every_section() {
1398        let dir = one_recipe("Add @salt{1%tsp}, @milk{1%l} and @water{1%l}.\n");
1399        let conf = "[pantry]\nsalt = \"1%kg\"\n\n[dairy]\nmilk = \"1%l\"\n";
1400        let coverage = checked(&pantry_ctx(&dir, conf), false).value;
1401
1402        assert_eq!(known(&coverage), ["milk", "salt"]);
1403        assert_eq!(unknown(&coverage), ["water"]);
1404    }
1405
1406    /// Nothing about the stock is considered: an item that has run out is
1407    /// still an item the pantry knows about.
1408    #[test]
1409    fn a_pantry_item_that_has_run_out_still_counts_as_known() {
1410        let dir = one_recipe("Add @honey{1%tbsp}.\n");
1411        let conf = "[pantry]\nhoney = { quantity = \"0\", low = \"100%g\" }\n";
1412        assert_eq!(
1413            known(&checked(&pantry_ctx(&dir, conf), false).value),
1414            ["honey"]
1415        );
1416    }
1417
1418    /// Both checks compare names ignoring case, and both report the
1419    /// ingredient as the *recipe* spells it.
1420    #[test]
1421    fn names_are_matched_ignoring_case_and_reported_as_the_recipe_writes_them() {
1422        let dir = one_recipe("Add @Salt{1%tsp} and @PEPPER{}.\n");
1423
1424        let aisle = checked(&aisle_ctx(&dir, "[pantry]\nsalt\npepper\n"), true).value;
1425        assert_eq!(known(&aisle), ["PEPPER", "Salt"]);
1426        assert!(unknown(&aisle).is_empty());
1427
1428        let conf = "[pantry]\nsalt = \"1%kg\"\npepper = \"50%g\"\n";
1429        let pantry = checked(&pantry_ctx(&dir, conf), false).value;
1430        assert_eq!(known(&pantry), ["PEPPER", "Salt"]);
1431        assert!(unknown(&pantry).is_empty());
1432    }
1433
1434    /// The fold is Unicode's, not ASCII's. Worth pinning because it is a
1435    /// deliberate change: the CLI compared with `eq_ignore_ascii_case` before
1436    /// this moved into core, which left `Öl` reported as uncategorised however
1437    /// the configuration spelled it.
1438    #[test]
1439    fn a_non_ascii_name_is_matched_ignoring_case_too() {
1440        let dir = one_recipe("Add @Öl{1%tbsp} and @Ärter{100%g}.\n");
1441
1442        let aisle = checked(&aisle_ctx(&dir, "[pantry]\növerste|öl\närter\n"), true).value;
1443        assert_eq!(known(&aisle), ["Ärter", "Öl"]);
1444        assert!(unknown(&aisle).is_empty(), "{:?}", unknown(&aisle));
1445
1446        // Non-ASCII keys have to be quoted to be valid TOML.
1447        let conf = "[pantry]\n\"öl\" = \"1%l\"\n\"ärter\" = \"1%kg\"\n";
1448        assert_eq!(
1449            known(&checked(&pantry_ctx(&dir, conf), false).value),
1450            ["Ärter", "Öl"]
1451        );
1452    }
1453
1454    /// Two spellings of one ingredient are two entries, because the report
1455    /// says what the recipes say. Both are judged the same way.
1456    #[test]
1457    fn two_spellings_of_one_ingredient_are_both_listed() {
1458        let dir = tempfile::TempDir::new().unwrap();
1459        write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1460        write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1461
1462        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1463        assert_eq!(known(&coverage), ["Salt", "salt"]);
1464        assert_eq!(coverage.total_ingredients(), 2);
1465    }
1466
1467    /// With nothing to check against, everything is unknown — and the
1468    /// collection is still scanned, which is what lets `cook doctor aisle`
1469    /// report the count before explaining that there is no configuration.
1470    #[test]
1471    fn without_a_configuration_nothing_is_known() {
1472        let dir = one_recipe("Add @salt{1%tsp}.\n");
1473        let ctx = Context::new(base(&dir));
1474
1475        for aisle in [true, false] {
1476            let coverage = checked(&ctx, aisle).value;
1477            assert_eq!(coverage.total_recipes, 1);
1478            assert!(known(&coverage).is_empty(), "aisle: {aisle}");
1479            assert_eq!(unknown(&coverage), ["salt"], "aisle: {aisle}");
1480        }
1481    }
1482
1483    /// A reference is a recipe to make, not a thing to have in, so it is not
1484    /// an ingredient — however the configuration happens to name it.
1485    #[test]
1486    fn references_to_other_recipes_are_not_ingredients() {
1487        let dir = one_recipe("Make @./sauce{} and add @water{1%l}.\n");
1488        write(&base(&dir).join("sauce.cook"), CLEAN);
1489
1490        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsauce\nwater\noil\n"), true).value;
1491        assert_eq!(
1492            all(&coverage),
1493            ["oil", "water"],
1494            "the referenced recipe's own ingredients count; the reference does not"
1495        );
1496    }
1497
1498    #[test]
1499    fn every_recipe_under_the_root_is_scanned_including_nested_ones() {
1500        let dir = one_recipe("Boil @water{1%l}.\n");
1501        std::fs::create_dir(base(&dir).join("Breakfast")).unwrap();
1502        write(
1503            &base(&dir).join("Breakfast").join("porridge.cook"),
1504            "Simmer @oats{50%g}.\n",
1505        );
1506
1507        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nwater\n"), true).value;
1508        assert_eq!(coverage.total_recipes, 2);
1509        assert_eq!(
1510            unknown(&coverage),
1511            ["oats"],
1512            "a subdirectory must be walked"
1513        );
1514        assert_eq!(
1515            recipes_for(&coverage, "oats"),
1516            [Utf8Path::new("Breakfast/porridge.cook")],
1517            "a recipe is named relative to the directory that was scanned"
1518        );
1519    }
1520
1521    /// The point of the recipe list: an odd spelling is one recipe against the
1522    /// collection's many, and the list says which one to go and open.
1523    #[test]
1524    fn an_ingredient_carries_every_recipe_that_writes_it() {
1525        let dir = tempfile::TempDir::new().unwrap();
1526        write(
1527            &base(&dir).join("curry.cook"),
1528            "Add @ground cumin{1%tsp}.\n",
1529        );
1530        write(&base(&dir).join("dal.cook"), "Add @ground cumin{2%tsp}.\n");
1531        write(&base(&dir).join("stew.cook"), "Add @cumin powder{1%tsp}.\n");
1532
1533        let coverage = checked(&aisle_ctx(&dir, "[spices]\nground cumin\n"), true).value;
1534
1535        assert_eq!(
1536            recipes_for(&coverage, "ground cumin"),
1537            [Utf8Path::new("curry.cook"), Utf8Path::new("dal.cook")],
1538            "shared by two recipes, listed once each, in path order"
1539        );
1540        assert_eq!(
1541            recipes_for(&coverage, "cumin powder"),
1542            [Utf8Path::new("stew.cook")]
1543        );
1544    }
1545
1546    /// A recipe naming the same ingredient twice is still one recipe: the
1547    /// count beside the name is recipes, not mentions.
1548    #[test]
1549    fn a_recipe_using_an_ingredient_twice_is_listed_once() {
1550        let dir = one_recipe("Add @salt{1%tsp}, then more @salt{1%tsp}.\n");
1551        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1552        assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("dish.cook")]);
1553    }
1554
1555    /// Spellings are separate ingredients, so each keeps its own recipes —
1556    /// otherwise the report could not tell which file writes `Salt`.
1557    #[test]
1558    fn each_spelling_keeps_its_own_recipes() {
1559        let dir = tempfile::TempDir::new().unwrap();
1560        write(&base(&dir).join("a.cook"), "Add @Salt{1%tsp}.\n");
1561        write(&base(&dir).join("b.cook"), "Add @salt{1%tsp}.\n");
1562
1563        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1564        assert_eq!(recipes_for(&coverage, "Salt"), [Utf8Path::new("a.cook")]);
1565        assert_eq!(recipes_for(&coverage, "salt"), [Utf8Path::new("b.cook")]);
1566    }
1567
1568    /// The pantry check tracks recipes too: it says which of your recipes an
1569    /// item in stock is keeping off the shopping list.
1570    #[test]
1571    fn the_pantry_check_lists_recipes_as_well() {
1572        let dir = tempfile::TempDir::new().unwrap();
1573        write(&base(&dir).join("a.cook"), "Add @rice{100%g}.\n");
1574        write(&base(&dir).join("b.cook"), "Add @rice{200%g}.\n");
1575
1576        let coverage = checked(&pantry_ctx(&dir, "[pantry]\nrice = \"5%kg\"\n"), false).value;
1577        assert_eq!(known(&coverage), ["rice"]);
1578        assert_eq!(
1579            recipes_for(&coverage, "rice"),
1580            [Utf8Path::new("a.cook"), Utf8Path::new("b.cook")]
1581        );
1582    }
1583
1584    /// A recipe that cannot be parsed still counts as scanned — the CLI has
1585    /// always said so — but contributes no ingredients, and says why.
1586    #[test]
1587    fn a_recipe_that_cannot_be_parsed_is_counted_but_contributes_nothing() {
1588        let dir = one_recipe("Add @salt{1%tsp}.\n");
1589        write(&base(&dir).join("broken.cook"), BROKEN);
1590
1591        let outcome = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true);
1592        assert_eq!(outcome.value.total_recipes, 2);
1593        assert_eq!(outcome.value.total_ingredients(), 1);
1594        assert_eq!(known(&outcome.value), ["salt"]);
1595
1596        let skipped = outcome
1597            .diagnostics
1598            .iter()
1599            .find(|d| d.message.contains("broken.cook"))
1600            .unwrap_or_else(|| {
1601                panic!(
1602                    "the skipped recipe must be named: {:?}",
1603                    outcome.diagnostics
1604                )
1605            });
1606        // A warning rather than an error: the check still produced its answer,
1607        // and `Outcome::has_errors` must not say otherwise.
1608        assert_eq!(skipped.severity, Severity::Warning);
1609        assert!(!outcome.has_errors());
1610    }
1611
1612    /// `cooklang-find` holds a directory's entries in a `HashMap`, so the walk
1613    /// order changes from run to run. Asserted over several runs because one
1614    /// run of an unsorted walk can come out sorted by luck.
1615    ///
1616    /// The order is by code point, **not** alphabetical: `Zucchini` sorts
1617    /// before `apple` because `Z` is `U+005A` and `a` is `U+0061`. The mixed
1618    /// case in the fixture is what holds the documented behaviour to account —
1619    /// an all-lowercase fixture cannot tell the two orderings apart.
1620    #[test]
1621    fn ingredients_come_back_in_code_point_order_every_time() {
1622        let dir = tempfile::TempDir::new().unwrap();
1623        for (file, ingredient) in [
1624            ("a", "yeast"),
1625            ("b", "flour"),
1626            ("c", "sugar"),
1627            ("d", "Zucchini"),
1628            ("e", "apple"),
1629            ("f", "Beetroot"),
1630        ] {
1631            write(
1632                &base(&dir).join(format!("{file}.cook")),
1633                &format!("Add @{ingredient}{{1}}.\n"),
1634            );
1635        }
1636        let ctx = aisle_ctx(&dir, "[pantry]\nflour\n");
1637
1638        for _ in 0..8 {
1639            let coverage = checked(&ctx, true).value;
1640            assert_eq!(
1641                all(&coverage),
1642                ["Beetroot", "Zucchini", "apple", "flour", "sugar", "yeast"],
1643                "capitalised names sort first, as CookCLI has always printed them"
1644            );
1645            assert_eq!(
1646                unknown(&coverage),
1647                ["Beetroot", "Zucchini", "apple", "sugar", "yeast"]
1648            );
1649        }
1650    }
1651
1652    /// The two views are derived from one list, so between them they account
1653    /// for every ingredient exactly once.
1654    #[test]
1655    fn the_two_views_partition_the_ingredients() {
1656        let dir = one_recipe("Add @salt{1%tsp}, @water{1%l} and @leek{1}.\n");
1657        let coverage = checked(&aisle_ctx(&dir, "[produce]\nleek\n"), true).value;
1658
1659        let mut both: Vec<&str> = known(&coverage)
1660            .into_iter()
1661            .chain(unknown(&coverage))
1662            .collect();
1663        both.sort_unstable();
1664        assert_eq!(both, all(&coverage));
1665        assert_eq!(
1666            known(&coverage).len() + unknown(&coverage).len(),
1667            coverage.total_ingredients()
1668        );
1669    }
1670
1671    #[test]
1672    fn a_coverage_base_dir_overrides_the_context_base_path() {
1673        let scanned = one_recipe("Add @salt{1%tsp}.\n");
1674        let ignored = one_recipe("Add @decoy{1}.\n");
1675
1676        let coverage = aisle_coverage(
1677            &aisle_ctx(&ignored, "[pantry]\nsalt\n"),
1678            CoverageRequest {
1679                base_dir: Some(base(&scanned)),
1680            },
1681        )
1682        .expect("the check succeeds")
1683        .into_value();
1684
1685        assert_eq!(known(&coverage), ["salt"]);
1686        assert!(!all(&coverage).contains(&"decoy"), "{:?}", all(&coverage));
1687    }
1688
1689    /// A warning in the configuration is carried back rather than logged, so
1690    /// that a caller other than the CLI can show it.
1691    #[test]
1692    fn a_warning_in_the_aisle_configuration_comes_back_as_a_diagnostic() {
1693        let dir = one_recipe("Add @leek{1}.\n");
1694        let outcome = checked(&aisle_ctx(&dir, "[produce]\nleek\n\n[dairy]\nleek\n"), true);
1695
1696        assert!(
1697            outcome
1698                .diagnostics
1699                .iter()
1700                .any(|d| d.message.contains("Duplicate ingredient")),
1701            "{:?}",
1702            outcome.diagnostics
1703        );
1704        // ...and the check still answers.
1705        assert_eq!(known(&outcome.value), ["leek"]);
1706    }
1707
1708    /// The pantry's mirror of the test above. It exists because deleting
1709    /// `pantry_coverage`'s `collect_diagnostics` call left every other test in
1710    /// this module passing: the aisle twin does not cover it.
1711    #[test]
1712    fn a_warning_in_the_pantry_configuration_comes_back_as_a_diagnostic() {
1713        let dir = one_recipe("Add @ice{1}.\n");
1714        let conf = "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n";
1715        let outcome = checked(&pantry_ctx(&dir, conf), false);
1716
1717        assert!(
1718            !outcome.diagnostics.is_empty(),
1719            "the unknown attribute must be reported"
1720        );
1721        for diagnostic in &outcome.diagnostics {
1722            assert_eq!(diagnostic.severity, Severity::Warning, "{diagnostic:?}");
1723        }
1724        assert!(!outcome.has_errors());
1725        // ...and the check still answers.
1726        assert_eq!(known(&outcome.value), ["ice"]);
1727    }
1728
1729    /// A warning carries the file it came from, so a caller showing it can say
1730    /// which configuration to go and edit.
1731    #[test]
1732    fn a_configuration_warning_is_located_in_the_file_it_came_from() {
1733        let dir = one_recipe("Add @ice{1}.\n");
1734        let path = base(&dir).join("pantry.conf");
1735        write(
1736            &path,
1737            "[freezer]\nice = { quantity = \"1%kg\", colour = \"white\" }\n",
1738        );
1739
1740        let ctx = Context::new(base(&dir)).with_pantry(ConfigSource::Path(path.clone()));
1741        let outcome =
1742            pantry_coverage(&ctx, CoverageRequest::default()).expect("the check succeeds");
1743
1744        let located = outcome
1745            .diagnostics
1746            .iter()
1747            .find(|d| d.location.is_some())
1748            .unwrap_or_else(|| panic!("expected a located warning: {:?}", outcome.diagnostics));
1749        assert_eq!(
1750            located.location.as_ref().and_then(|l| l.file.as_deref()),
1751            Some(path.as_path())
1752        );
1753    }
1754
1755    /// A configuration the context names but cannot read is a failure, not an
1756    /// absent configuration: reporting it as "nothing is categorised" would
1757    /// send the user editing a file that is fine.
1758    #[test]
1759    fn a_configuration_that_cannot_be_read_is_reported() {
1760        let dir = one_recipe("Add @salt{1%tsp}.\n");
1761        let missing = base(&dir).join("config").join("aisle.conf");
1762
1763        match aisle_coverage(
1764            &Context::new(base(&dir)).with_aisle(ConfigSource::Path(missing.clone())),
1765            CoverageRequest::default(),
1766        ) {
1767            Err(CoreError::Io { path, source }) => {
1768                assert_eq!(path, missing);
1769                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
1770            }
1771            other => panic!("expected CoreError::Io, got {:?}", other.map(|o| o.value)),
1772        }
1773    }
1774
1775    /// The same verdict *and the same wording* `pantry::load` reaches on the
1776    /// same file. Asserted against `load`'s own answer rather than a literal,
1777    /// because the point is that the two agree: a user told two different
1778    /// things about one file by two commands has to work out which is true.
1779    #[test]
1780    fn a_pantry_that_cannot_be_parsed_at_all_is_reported_as_pantry_load_reports_it() {
1781        let dir = one_recipe("Add @salt{1%tsp}.\n");
1782        let ctx = pantry_ctx(&dir, "this is not toml [");
1783
1784        let from_load = match crate::pantry::load(&ctx) {
1785            Err(CoreError::Config { message, .. }) => message,
1786            other => panic!("expected CoreError::Config from load, got {other:?}"),
1787        };
1788
1789        match pantry_coverage(&ctx, CoverageRequest::default()) {
1790            Err(CoreError::Config { path, message }) => {
1791                assert_eq!(path, None, "an inline configuration has no path");
1792                // The parser's own cause, not a constant: without this the
1793                // message degrades to "could not be parsed" and nobody notices.
1794                assert!(
1795                    message.contains("TOML parse error"),
1796                    "the cause must survive: {message}"
1797                );
1798                assert_eq!(message, from_load, "the two commands must agree");
1799            }
1800            other => panic!(
1801                "expected CoreError::Config, got {:?}",
1802                other.map(|o| o.value)
1803            ),
1804        }
1805    }
1806
1807    /// A root that is not there fails the same way validation does.
1808    #[test]
1809    fn a_root_that_does_not_exist_is_reported_by_a_coverage_check() {
1810        let dir = tempfile::TempDir::new().unwrap();
1811        let missing = base(&dir).join("nope");
1812
1813        match pantry_coverage(
1814            &Context::new(missing.clone()).with_pantry(ConfigSource::Inline(String::new())),
1815            CoverageRequest::default(),
1816        ) {
1817            Err(CoreError::Search { base_dir, message }) => {
1818                assert_eq!(base_dir, missing);
1819                assert_eq!(message, "no such directory");
1820            }
1821            other => panic!(
1822                "expected CoreError::Search, got {:?}",
1823                other.map(|o| o.value)
1824            ),
1825        }
1826    }
1827
1828    #[test]
1829    fn an_empty_collection_covers_nothing() {
1830        let dir = tempfile::TempDir::new().unwrap();
1831        let coverage = checked(&aisle_ctx(&dir, "[pantry]\nsalt\n"), true).value;
1832        assert_eq!(coverage.total_recipes, 0);
1833        assert_eq!(coverage.total_ingredients(), 0);
1834        assert!(known(&coverage).is_empty());
1835        assert!(unknown(&coverage).is_empty());
1836    }
1837}