Skip to main content

cookcli_core/
doctor.rs

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