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