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