Skip to main content

cookcli_core/pantry/
mod.rs

1//! `cook pantry`: what is in stock, and changing it.
2//!
3//! [`load`] reads the configuration [`Context::pantry`] points at — a file, or
4//! text an editor is holding — and the queries answer questions about it:
5//! everything in it ([`list`]), what is running out ([`depleted`]), what is
6//! about to go off ([`expiring`]), and which recipes it can already cook
7//! ([`recipes`]).
8//!
9//! [`plan`] is the odd one out: it answers "what should I stock?" by looking at
10//! the recipe collection alone, and never reads the pantry at all.
11//!
12//! [`add`], [`remove`] and [`update`] change the pantry and write it back.
13//! They are the only functions in this crate that write to a file the user
14//! owns, so read [`write_atomically`] and **[what a write
15//! touches](#what-a-write-touches)** before calling them.
16//!
17//! # What a write touches
18//!
19//! **Only the entry asked for.** A change is applied to the file as a TOML
20//! document, so everything else is left byte for byte as it was: comments,
21//! blank lines, indentation, key order, the choice between `x = "1%kg"` and
22//! `x = { quantity = "1%kg" }`, attributes `cooklang` does not model, and
23//! values that are not strings.
24//!
25//! It did not always work this way. Every change used to re-parse the whole
26//! file into `cooklang`'s model, apply itself, and serialise that model back —
27//! so anything the model did not carry was gone the first time anything was
28//! added, removed or updated, silently, on a file people hand-write. A
29//! top-level item written with attributes fared worst: the parser reads
30//!
31//! ```toml
32//! salt = { quantity = "1%kg", expire = "2027-01-01" }
33//! ```
34//!
35//! as a *section* named `salt` holding items `quantity` and `expire`, and the
36//! rewrite emitted it as one — destroying the item and inventing two, on a
37//! command that had nothing to do with it
38//! (<https://github.com/cooklang/cookcli/issues/429>).
39//!
40//! What a write still normalises, because it is what the writer must choose:
41//!
42//! - A **new** item is written `name = "quantity"` when that is all it has, and
43//!   `name = { … }` when it carries more. An item added to `general` is always
44//!   written in the short form, because a top-level inline table would be read
45//!   back as a section header — the very shape above.
46//! - An item **updated** with an attribute it has no room for grows from the
47//!   short form into a table, keeping the quantity it had.
48//! - A section emptied by [`remove`] is removed, matching what `cooklang` does
49//!   with an empty section when it reads the file back.
50//!
51//! [`update`] refuses, rather than guesses, when an item's value is neither a
52//! quantity nor a set of attributes — a hand-written `salt = 3`. Attributes
53//! `cooklang` does not model are kept untouched; its own parse still reports
54//! them as unknown fields, which is about what `cook pantry list` can show
55//! rather than about anything being lost.
56
57use crate::{
58    diagnostic::parse_failure,
59    find::{build_tree, listed_ingredients, parse_or_skip, walk},
60    fs_atomic::write_atomically,
61    parser::collect_diagnostics,
62    ConfigSource, Context, CoreError, Diagnostic, Outcome,
63};
64use camino::{Utf8Path, Utf8PathBuf};
65use chrono::{Local, NaiveDate};
66use cooklang_find::RecipeEntry;
67use regex::Regex;
68use std::{
69    cmp::Reverse,
70    collections::{BTreeMap, BTreeSet},
71    sync::LazyLock,
72};
73
74/// How [`ExpiringItem::expire_date`] is written, whatever the file used.
75const ISO_DATE: &str = "%Y-%m-%d";
76
77// ---------------------------------------------------------------------------
78// What a pantry holds
79// ---------------------------------------------------------------------------
80
81/// One item in the pantry.
82///
83/// Every field is carried as it was written, without interpretation: a
84/// quantity is `"500%g"` rather than a number and a unit, and a date is
85/// whatever spelling the file used. The methods below are where interpretation
86/// happens.
87///
88/// `#[non_exhaustive]` because this is an output type consumers read rather
89/// than construct.
90#[non_exhaustive]
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct PantryItem {
93    /// The ingredient's name, as written.
94    pub name: String,
95    /// The section it was written under. `cooklang` collects items written
96    /// above the first section header into a section called `general`, so an
97    /// item always has one.
98    pub section: String,
99    /// How much is in stock — `"500%g"`, `"2"` — or `None` for an item written
100    /// without a quantity.
101    pub quantity: Option<String>,
102    /// When it was bought, if the file says.
103    pub bought: Option<String>,
104    /// When it expires, if the file says. See [`expiring`] for the spellings
105    /// that can be read as a date.
106    pub expire: Option<String>,
107    /// The quantity at or below which this item counts as low, if the file
108    /// sets one. See [`is_low`](PantryItem::is_low).
109    pub low: Option<String>,
110}
111
112impl PantryItem {
113    /// True when the stock has fallen to or below this item's *own* `low`
114    /// threshold.
115    ///
116    /// False unless [`quantity`](PantryItem::quantity) and
117    /// [`low`](PantryItem::low) are both set, both parse as a number with an
118    /// optional unit, and those units are equal: a threshold written in
119    /// different units from the stock is not compared, and neither is a
120    /// quantity that is not a number. Such an item is not "not low" so much as
121    /// unanswerable, and [`depleted`] falls back to its built-in thresholds
122    /// for it.
123    ///
124    /// Computed rather than stored, so it cannot disagree with the fields it
125    /// reads. The comparison is `cooklang`'s own, reached by handing it an
126    /// equivalent item, so that there is one definition of it rather than two
127    /// that can drift.
128    pub fn is_low(&self) -> bool {
129        cooklang::pantry::PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
130            name: self.name.clone(),
131            bought: None,
132            expire: None,
133            quantity: self.quantity.clone(),
134            low: self.low.clone(),
135        })
136        .is_low()
137    }
138
139    fn from_cooklang(section: &str, item: &cooklang::pantry::PantryItem) -> Self {
140        Self {
141            name: item.name().to_string(),
142            section: section.to_string(),
143            quantity: item.quantity().map(ToOwned::to_owned),
144            bought: item.bought().map(ToOwned::to_owned),
145            expire: item.expire().map(ToOwned::to_owned),
146            low: item.low().map(ToOwned::to_owned),
147        }
148    }
149}
150
151/// One section of the pantry, with the items written under it.
152///
153/// `#[non_exhaustive]` because this is an output type consumers read rather
154/// than construct.
155#[non_exhaustive]
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PantrySection {
158    /// The section's name, as written. Equal to the
159    /// [`section`](PantryItem::section) of every item in it, which is carried
160    /// on the items too so that a single item taken out of here still says
161    /// where it came from.
162    pub name: String,
163    /// The items written under it, in file order. May be empty, for a section
164    /// header with nothing under it.
165    pub items: Vec<PantryItem>,
166}
167
168/// A whole pantry configuration.
169///
170/// `#[non_exhaustive]` because this is an output type consumers read rather
171/// than construct.
172#[non_exhaustive]
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PantryContents {
175    /// Every section, in the order the file wrote them.
176    pub sections: Vec<PantrySection>,
177}
178
179impl PantryContents {
180    /// Every item in every section, in file order.
181    pub fn items(&self) -> impl Iterator<Item = &PantryItem> {
182        self.sections.iter().flat_map(|section| &section.items)
183    }
184
185    fn from_conf(conf: &cooklang::pantry::PantryConf) -> Self {
186        // `PantryConf::sections` is an `IndexMap`, so this is the file's own
187        // order rather than an arbitrary one.
188        Self {
189            sections: conf
190                .sections
191                .iter()
192                .map(|(name, items)| PantrySection {
193                    name: name.clone(),
194                    items: items
195                        .iter()
196                        .map(|item| PantryItem::from_cooklang(name, item))
197                        .collect(),
198                })
199                .collect(),
200        }
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Loading
206// ---------------------------------------------------------------------------
207
208/// Read and parse the pantry configuration [`Context::pantry`] names.
209///
210/// Reads through [`ConfigSource`](crate::ConfigSource), so an editor can hand
211/// over pantry text it has not saved instead of a path.
212///
213/// A pantry that parses with warnings — an unknown attribute on an item, say —
214/// is a successful load carrying those warnings as [`Outcome::diagnostics`],
215/// located in the pantry file when it came from one.
216///
217/// # Errors
218///
219/// - [`CoreError::MissingConfig`] if the context carries no pantry at all.
220///   Every query below reports this the same way, because a pantry query with
221///   no pantry has no answer — unlike `shopping_list::generate`, which simply
222///   subtracts nothing.
223/// - [`CoreError::Io`] if a path-backed configuration cannot be read.
224/// - [`CoreError::Config`] if it cannot be parsed at all, naming the file it
225///   came from.
226pub fn load(ctx: &Context) -> Result<Outcome<PantryContents>, CoreError> {
227    let source = ctx.pantry();
228    let Some(text) = source.read()? else {
229        return Err(CoreError::MissingConfig {
230            kind: "pantry".to_string(),
231        });
232    };
233    let path = source.path();
234    tracing::trace!("loading pantry from {:?}", path);
235
236    let parsed = cooklang::pantry::parse_lenient(&text);
237    let diagnostics = collect_diagnostics(parsed.report(), path);
238
239    match parsed.output() {
240        Some(conf) => Ok(Outcome::with_diagnostics(
241            PantryContents::from_conf(conf),
242            diagnostics,
243        )),
244        None => Err(CoreError::Config {
245            path: path.map(ToOwned::to_owned),
246            message: parse_failure(&diagnostics, "pantry"),
247        }),
248    }
249}
250
251// ---------------------------------------------------------------------------
252// list
253// ---------------------------------------------------------------------------
254
255/// Which of the pantry to list.
256///
257/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
258/// keeps a literal working if it grows a field.
259#[derive(Debug, Clone, Default)]
260pub struct ListRequest {
261    /// Keep only this section, compared ignoring ASCII case. `None` lists
262    /// everything.
263    pub section: Option<String>,
264}
265
266/// List the pantry, optionally narrowed to one section.
267///
268/// A filter that matches no section gives an empty list rather than an error:
269/// core reports what is there, and whether "you asked for a section that does
270/// not exist" deserves an error is the caller's policy. The CLI treats it as
271/// one.
272///
273/// # Errors
274///
275/// As [`load`].
276pub fn list(ctx: &Context, req: ListRequest) -> Result<Outcome<PantryContents>, CoreError> {
277    let mut outcome = load(ctx)?;
278    if let Some(section) = &req.section {
279        outcome
280            .value
281            .sections
282            .retain(|s| s.name.eq_ignore_ascii_case(section));
283    }
284    Ok(outcome)
285}
286
287// ---------------------------------------------------------------------------
288// depleted
289// ---------------------------------------------------------------------------
290
291/// Which items count as running out.
292///
293/// Not `#[non_exhaustive]`: consumers construct this.
294#[derive(Debug, Clone, Default)]
295pub struct DepletedRequest {
296    /// Also return items whose stock cannot be judged at all — no quantity, or
297    /// a quantity that is not a number, or a `low` threshold in units that
298    /// cannot be compared with it.
299    pub all: bool,
300}
301
302/// The items that are low or out of stock, in file order.
303///
304/// An item is returned when [`PantryItem::is_low`] says so. When it does not,
305/// the answer depends on what there is to go on:
306///
307/// - No quantity at all: only with [`DepletedRequest::all`], since there is
308///   nothing to compare.
309/// - A quantity, and a `low` threshold in the same units: `is_low` has already
310///   compared them and said no, so only with [`DepletedRequest::all`].
311/// - A quantity, and either no threshold or one in units that do not match:
312///   the built-in thresholds decide — at or below 100 for `g` and `ml`, below
313///   0.5 for `kg` and `l`, at or below 1 for anything else, including a bare
314///   count.
315///
316/// # Errors
317///
318/// As [`load`].
319pub fn depleted(
320    ctx: &Context,
321    req: DepletedRequest,
322) -> Result<Outcome<Vec<PantryItem>>, CoreError> {
323    let outcome = load(ctx)?;
324    let items = outcome
325        .value
326        .items()
327        .filter(|item| is_depleted(item, req.all))
328        .cloned()
329        .collect();
330    Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
331}
332
333/// The rule documented on [`depleted`].
334fn is_depleted(item: &PantryItem, all: bool) -> bool {
335    if item.is_low() {
336        return true;
337    }
338    match &item.quantity {
339        None => all,
340        Some(quantity) => match &item.low {
341            // A threshold in matching units has already been compared by
342            // `is_low` above, and it said no.
343            Some(low) if units_match(quantity, low) => all,
344            _ => is_low_quantity(quantity),
345        },
346    }
347}
348
349// ---------------------------------------------------------------------------
350// expiring
351// ---------------------------------------------------------------------------
352
353/// How far ahead to look for expiring items.
354///
355/// Not `#[non_exhaustive]`: consumers construct this.
356#[derive(Debug, Clone)]
357pub struct ExpiringRequest {
358    /// How many days ahead to look. `0` returns only what has expired or
359    /// expires today.
360    pub days: u32,
361    /// Also return items with no readable expiry date, which carry no
362    /// [`ExpiringItem::days_until_expiry`].
363    pub include_unknown: bool,
364}
365
366impl Default for ExpiringRequest {
367    /// A week ahead, which is `cook pantry expiring`'s default, and only items
368    /// that say when they expire.
369    fn default() -> Self {
370        Self {
371            days: 7,
372            include_unknown: false,
373        }
374    }
375}
376
377/// A pantry item that is expiring, with the arithmetic already done.
378///
379/// `#[non_exhaustive]` because this is an output type consumers read rather
380/// than construct.
381#[non_exhaustive]
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct ExpiringItem {
384    /// The item itself, exactly as [`load`] read it.
385    pub item: PantryItem,
386    /// Its expiry date normalised to ISO 8601 (`2025-06-01`), whichever
387    /// spelling the file used. `None` only for an item included by
388    /// [`ExpiringRequest::include_unknown`].
389    pub expire_date: Option<String>,
390    /// Days from today until it expires: `0` today, negative once it has
391    /// expired. `None` alongside an absent `expire_date`.
392    pub days_until_expiry: Option<i64>,
393}
394
395/// The items expiring within [`ExpiringRequest::days`] of today, soonest
396/// first.
397///
398/// Already-expired items are included, and sort first because their
399/// [`days_until_expiry`](ExpiringItem::days_until_expiry) is negative. Items
400/// with no readable date sort last, and are only present at all with
401/// [`ExpiringRequest::include_unknown`].
402///
403/// # Dates
404///
405/// A date is read as `%Y-%m-%d`, `%d.%m.%Y`, `%d/%m/%Y`, `%m/%d/%Y`,
406/// `%Y.%m.%d` or `%d-%m-%Y`, in that order — so `01/02/2025` is the 1st of
407/// February, not the 2nd of January. Anything else counts as no date at all.
408///
409/// "Today" is the local date of the machine this runs on.
410///
411/// # Errors
412///
413/// As [`load`].
414pub fn expiring(
415    ctx: &Context,
416    req: ExpiringRequest,
417) -> Result<Outcome<Vec<ExpiringItem>>, CoreError> {
418    let outcome = load(ctx)?;
419    let items = expiring_on(&outcome.value, &req, Local::now().date_naive());
420    Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
421}
422
423/// [`expiring`] against a given date, so that the arithmetic can be tested
424/// without the answer depending on the day the tests are run.
425fn expiring_on(
426    contents: &PantryContents,
427    req: &ExpiringRequest,
428    today: NaiveDate,
429) -> Vec<ExpiringItem> {
430    // A `days` big enough to run off the end of the calendar means every date
431    // is within it. Saturating rather than panicking matters in a crate a NAPI
432    // addon calls: `cook pantry expiring -d 4294967295` used to panic here.
433    let threshold = today
434        .checked_add_signed(chrono::Duration::days(i64::from(req.days)))
435        .unwrap_or(NaiveDate::MAX);
436
437    let mut items: Vec<ExpiringItem> = contents
438        .items()
439        .filter_map(|item| match item.expire.as_deref().and_then(parse_date) {
440            Some(date) if date <= threshold => Some(ExpiringItem {
441                item: item.clone(),
442                expire_date: Some(date.format(ISO_DATE).to_string()),
443                days_until_expiry: Some((date - today).num_days()),
444            }),
445            // Expires, but not yet.
446            Some(_) => None,
447            None if req.include_unknown => Some(ExpiringItem {
448                item: item.clone(),
449                expire_date: None,
450                days_until_expiry: None,
451            }),
452            None => None,
453        })
454        .collect();
455
456    // Stable, so items expiring on the same day stay in file order.
457    items.sort_by_key(|item| item.days_until_expiry.unwrap_or(i64::MAX));
458    items
459}
460
461// ---------------------------------------------------------------------------
462// recipes
463// ---------------------------------------------------------------------------
464
465/// How complete a match has to be to be worth reporting.
466///
467/// Not `#[non_exhaustive]`: consumers construct this.
468#[derive(Debug, Clone)]
469pub struct RecipesRequest {
470    /// The lowest percentage of a recipe's ingredients that may be in stock
471    /// for it to count as a partial match, as a whole number out of 100.
472    pub threshold: u8,
473}
474
475impl Default for RecipesRequest {
476    /// 75%, which is `cook pantry recipes`'s default.
477    fn default() -> Self {
478        Self { threshold: 75 }
479    }
480}
481
482/// A recipe most of whose ingredients are in stock.
483///
484/// `#[non_exhaustive]` because this is an output type consumers read rather
485/// than construct.
486#[non_exhaustive]
487#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
488pub struct PartialMatch {
489    /// The recipe's title, or its file stem when it has none.
490    pub name: String,
491    /// What percentage of its ingredients are in stock, rounded down.
492    pub percentage: usize,
493    /// The ingredients that are not, lowercased as they were compared, in
494    /// alphabetical order.
495    pub missing: Vec<String>,
496}
497
498/// What the pantry can cook.
499///
500/// `#[non_exhaustive]` because this is an output type consumers read rather
501/// than construct.
502#[non_exhaustive]
503#[derive(Debug, Clone, Default, PartialEq, Eq)]
504pub struct RecipeMatches {
505    /// Recipes every one of whose ingredients is in stock, by title, in
506    /// alphabetical order.
507    pub full: Vec<String>,
508    /// Recipes that are only partly covered, at or above
509    /// [`RecipesRequest::threshold`], in alphabetical order.
510    pub partial: Vec<PartialMatch>,
511}
512
513/// Work out which recipes under [`Context::base_path`] the pantry can cook.
514///
515/// An ingredient counts as in stock when its name matches a pantry item's,
516/// compared lowercased and otherwise exactly — no unit or quantity is
517/// considered, so a recipe needing a kilo of flour matches a pantry holding a
518/// gram of it. References to other recipes are ignored, and a recipe left with
519/// no ingredients at all matches nothing. See [`listed_ingredients`] for what
520/// else is left out.
521///
522/// Recipes are found by walking the collection, `.menu` files included. A
523/// recipe that cannot be read or parsed is left out, with a warning in
524/// [`Outcome::diagnostics`] naming it — it is not counted as a match or a
525/// miss. Warnings from recipes that *did* parse are not reported here, because
526/// they cannot change the answer; `doctor::validate` is the command for those.
527///
528/// # Errors
529///
530/// - As [`load`], since this needs the pantry.
531/// - [`CoreError::Search`] if the collection cannot be walked, and
532///   [`CoreError::Io`] if a file in it cannot be listed — as
533///   `doctor::validate`.
534pub fn recipes(ctx: &Context, req: RecipesRequest) -> Result<Outcome<RecipeMatches>, CoreError> {
535    let loaded = load(ctx)?;
536    let mut diagnostics = loaded.diagnostics;
537    let stocked: BTreeSet<String> = loaded
538        .value
539        .items()
540        .map(|item| item.name.to_lowercase())
541        .collect();
542
543    let tree = build_tree(ctx.base_path())?;
544    let mut matches = RecipeMatches::default();
545
546    for entry in walk(&tree) {
547        let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
548            continue;
549        };
550        // Lowercased into the set, so a recipe naming `Salt` and `salt` wants
551        // one ingredient rather than two.
552        let wanted: BTreeSet<String> = listed_ingredients(&recipe)
553            .iter()
554            .map(|name| name.to_lowercase())
555            .collect();
556        if wanted.is_empty() {
557            continue;
558        }
559
560        let available = wanted.iter().filter(|name| stocked.contains(*name)).count();
561        let percentage = available * 100 / wanted.len();
562        let name = recipe_name(entry);
563
564        if available == wanted.len() {
565            matches.full.push(name);
566        } else if percentage >= usize::from(req.threshold) {
567            matches.partial.push(PartialMatch {
568                name,
569                percentage,
570                // From a `BTreeSet`, so already in order.
571                missing: wanted
572                    .iter()
573                    .filter(|name| !stocked.contains(*name))
574                    .cloned()
575                    .collect(),
576            });
577        }
578    }
579
580    // The walk yields directories in a `HashMap`'s order, which changes
581    // between runs. Sorting is what makes the answer the same twice running.
582    matches.full.sort();
583    matches.partial.sort();
584
585    Ok(Outcome::with_diagnostics(matches, diagnostics))
586}
587
588// ---------------------------------------------------------------------------
589// plan
590// ---------------------------------------------------------------------------
591
592/// How far to take a pantry plan.
593///
594/// Not `#[non_exhaustive]`: consumers construct this. The default plans until
595/// every recipe is covered.
596#[derive(Debug, Clone, Default)]
597pub struct PlanRequest {
598    /// Stop after this many ingredients. `None` continues until every recipe
599    /// is cookable, or until no ingredient is left to add.
600    pub max_ingredients: Option<usize>,
601    /// Count a recipe as cookable while it is still missing this many
602    /// ingredients. `0` means everything it needs must be stocked.
603    pub allow_missing: usize,
604}
605
606/// One ingredient to buy, and what buying it achieves.
607///
608/// `#[non_exhaustive]` because this is an output type consumers read rather
609/// than construct.
610#[non_exhaustive]
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub struct IngredientStep {
613    /// The ingredient, as recipes write it.
614    pub name: String,
615    /// How many more recipes become cookable once it is in stock.
616    pub new_recipes_unlocked: usize,
617    /// How many recipes are cookable in total by this point — this step and
618    /// every step before it.
619    pub total_cookable: usize,
620}
621
622/// An order to stock a pantry in.
623///
624/// `#[non_exhaustive]` because this is an output type consumers read rather
625/// than construct.
626#[non_exhaustive]
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct PantryPlan {
629    /// The ingredients to buy, most useful first.
630    pub steps: Vec<IngredientStep>,
631    /// How many recipes the plan was worked out over: every recipe in the
632    /// collection that lists at least one ingredient.
633    pub total_recipes: usize,
634}
635
636impl PantryPlan {
637    /// How many recipes are cookable once the whole plan is stocked.
638    ///
639    /// Read off the last step rather than stored alongside it, so it cannot
640    /// disagree with the steps it summarises. Zero for an empty plan.
641    pub fn cookable_recipes(&self) -> usize {
642        self.steps.last().map_or(0, |step| step.total_cookable)
643    }
644
645    /// [`cookable_recipes`](PantryPlan::cookable_recipes) as a percentage of
646    /// [`total_recipes`](PantryPlan::total_recipes), rounded down. Zero when
647    /// there are no recipes at all.
648    pub fn coverage_percentage(&self) -> usize {
649        self.cookable_recipes() * 100 / self.total_recipes.max(1)
650    }
651}
652
653/// Work out which ingredients to stock to cook as much of the collection as
654/// possible.
655///
656/// **The pantry is not consulted.** This answers "what should I buy?" from the
657/// recipes under [`Context::base_path`] alone, so it needs no pantry
658/// configuration and will happily recommend something already in stock.
659///
660/// The plan is greedy: at each step it takes the ingredient wanted by the most
661/// recipes that are not yet cookable, and ties are broken alphabetically. That
662/// is an approximation — a greedy set cover is not guaranteed to be the
663/// shortest plan — but it is deterministic, which the tie-break is there for.
664///
665/// Only `.cook` files are considered; `.menu` files are skipped, and so are
666/// recipes that list no ingredients — and, as in [`recipes`], any that cannot
667/// be read or parsed, each with a warning in [`Outcome::diagnostics`].
668/// Ingredients are those of [`listed_ingredients`], compared exactly as
669/// recipes write them, so `Flour` and `flour` are two ingredients — unlike
670/// [`recipes`], which lowercases.
671///
672/// # Errors
673///
674/// [`CoreError::Search`] if the collection cannot be walked, and
675/// [`CoreError::Io`] if a file in it cannot be listed. Never
676/// [`CoreError::MissingConfig`].
677pub fn plan(ctx: &Context, req: PlanRequest) -> Result<Outcome<PantryPlan>, CoreError> {
678    let tree = build_tree(ctx.base_path())?;
679    let mut diagnostics = Vec::new();
680
681    // What each recipe still needs. A recipe drops out once it is cookable.
682    let mut missing: Vec<BTreeSet<String>> = walk(&tree)
683        .into_iter()
684        .filter(|entry| !entry.is_menu())
685        .filter_map(|entry| parse_or_skip(entry, &mut diagnostics))
686        .map(|recipe| listed_ingredients(&recipe))
687        .filter(|ingredients| !ingredients.is_empty())
688        .collect();
689
690    let total_recipes = missing.len();
691    let max_ingredients = req.max_ingredients.unwrap_or(usize::MAX);
692    let mut steps: Vec<IngredientStep> = Vec::new();
693    let mut cookable = 0;
694
695    while cookable < total_recipes && steps.len() < max_ingredients {
696        let Some(best) = most_wanted(&missing) else {
697            // Nothing left to choose: every remaining recipe wants nothing,
698            // which `allow_missing` cannot satisfy.
699            break;
700        };
701
702        let mut newly_cookable = 0;
703        missing.retain_mut(|wanted| {
704            wanted.remove(&best);
705            if wanted.len() <= req.allow_missing {
706                newly_cookable += 1;
707                false
708            } else {
709                true
710            }
711        });
712        cookable += newly_cookable;
713
714        steps.push(IngredientStep {
715            name: best,
716            new_recipes_unlocked: newly_cookable,
717            total_cookable: cookable,
718        });
719    }
720
721    Ok(Outcome::with_diagnostics(
722        PantryPlan {
723            steps,
724            total_recipes,
725        },
726        diagnostics,
727    ))
728}
729
730/// The ingredient wanted by the most recipes, ties broken alphabetically.
731fn most_wanted(missing: &[BTreeSet<String>]) -> Option<String> {
732    let mut scores: BTreeMap<&str, usize> = BTreeMap::new();
733    for wanted in missing {
734        for ingredient in wanted {
735            *scores.entry(ingredient.as_str()).or_insert(0) += 1;
736        }
737    }
738    scores
739        // Highest count wins; `Reverse` on the name turns "largest" into
740        // "alphabetically first" for the tie, which is what makes two runs
741        // over the same collection agree.
742        .into_iter()
743        .max_by_key(|&(name, count)| (count, Reverse(name)))
744        .map(|(name, _)| name.to_string())
745}
746
747// ---------------------------------------------------------------------------
748// add, remove, update
749// ---------------------------------------------------------------------------
750
751/// An item to add to the pantry.
752///
753/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
754/// keeps a literal working if it grows a field.
755#[derive(Debug, Clone, Default)]
756pub struct AddRequest {
757    /// The section to add it under, matched and written exactly as given —
758    /// unlike [`ListRequest::section`], case counts, so adding to `Dairy` when
759    /// the file says `dairy` makes a second section.
760    pub section: String,
761    /// The ingredient's name.
762    pub name: String,
763    /// How much is in stock, as pantry files write it: `"500%g"`, `"2"`.
764    pub quantity: Option<String>,
765    /// When it was bought.
766    pub bought: Option<String>,
767    /// When it expires. See [`expiring`] for the spellings that can be read
768    /// back as a date.
769    pub expire: Option<String>,
770    /// The quantity at or below which it counts as low.
771    pub low: Option<String>,
772}
773
774/// Which item to take out of the pantry.
775///
776/// Not `#[non_exhaustive]`: consumers construct this.
777#[derive(Debug, Clone, Default)]
778pub struct RemoveRequest {
779    /// The section holding it, matched exactly.
780    pub section: String,
781    /// The item's name, matched exactly.
782    pub name: String,
783}
784
785/// What to change about an item already in the pantry.
786///
787/// Not `#[non_exhaustive]`: consumers construct this. At least one attribute
788/// must be set; see [`update`].
789#[derive(Debug, Clone, Default)]
790pub struct UpdateRequest {
791    /// The section holding it, matched exactly.
792    pub section: String,
793    /// The item's name, matched exactly. Not changed by an update — remove and
794    /// add to rename.
795    pub name: String,
796    /// The new quantity, or `None` to leave it as it is.
797    pub quantity: Option<String>,
798    /// The new bought date, or `None` to leave it as it is.
799    pub bought: Option<String>,
800    /// The new expiry date, or `None` to leave it as it is.
801    pub expire: Option<String>,
802    /// The new low-stock threshold, or `None` to leave it as it is.
803    pub low: Option<String>,
804}
805
806/// Add an item to the pantry and write it back.
807///
808/// The section is created if the file has no such section, and the file itself
809/// is created if there is none — under `<base_path>/config/pantry.conf`, which
810/// is where [`Context::discover`] looks first. That is the one case where this
811/// crate invents a path rather than being told one.
812///
813/// Returns the pantry as it now stands on disk, so a caller need not read it
814/// back, together with any warnings from parsing what was there before.
815///
816/// Only the entry asked for is touched; see [what a write
817/// touches](self#what-a-write-touches).
818///
819/// # Errors
820///
821/// - [`CoreError::ReadOnlyConfig`] if the context carries the pantry inline.
822///   There is nowhere to write it, and inventing a path would put an editor's
823///   unsaved buffer on someone's disk.
824/// - [`CoreError::PantryEdit`] if the section already holds an item of that
825///   name, compared exactly. Nothing is written; [`update`] is how an item is
826///   changed.
827/// - [`CoreError::Config`] if the existing file cannot be parsed at all, and
828///   [`CoreError::Io`] if it cannot be read or the new one cannot be written.
829pub fn add(ctx: &Context, req: AddRequest) -> Result<Outcome<PantryContents>, CoreError> {
830    let attributes = edit::Attributes {
831        quantity: req.quantity,
832        bought: req.bought,
833        expire: req.expire,
834        low: req.low,
835    };
836    edit::check_general_attributes(&req.section, &req.name, &attributes)?;
837
838    let path = path_to_create(ctx)?;
839    let (mut doc, mut diagnostics) = read_document_or_empty(&path)?;
840
841    if edit::item_exists(&doc, &req.section, &req.name) {
842        return Err(CoreError::PantryEdit {
843            message: format!(
844                "item '{}' already exists in section '{}'",
845                req.name, req.section
846            ),
847        });
848    }
849
850    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
851    edit::insert(&mut doc, &req.section, &req.name, &attributes);
852
853    save(&path, &doc, diagnostics)
854}
855
856/// Take an item out of the pantry and write it back.
857///
858/// A section left with no items is removed too, because `cooklang` drops empty
859/// sections when it reads a file and keeping one would not survive the next
860/// read anyway.
861///
862/// Returns the pantry as it now stands on disk, and any warnings from parsing
863/// what was there before.
864///
865/// Only the entry asked for is touched; see [what a write
866/// touches](self#what-a-write-touches).
867///
868/// # Errors
869///
870/// - [`CoreError::MissingConfig`] if the context carries no pantry: there is
871///   nothing to take an item out of. Unlike [`add`], this does not create one.
872/// - [`CoreError::ReadOnlyConfig`] if it carries the pantry inline.
873/// - [`CoreError::PantryEdit`] if there is no such section, or no such item in
874///   it. Nothing is written.
875/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
876///   written.
877pub fn remove(ctx: &Context, req: RemoveRequest) -> Result<Outcome<PantryContents>, CoreError> {
878    let path = path_to_edit(ctx)?;
879    let (mut doc, mut diagnostics) = read_document(&path)?;
880
881    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
882    if !edit::section_exists(&doc, &req.section) {
883        return Err(section_not_found(&req.section));
884    }
885    if !edit::item_exists(&doc, &req.section, &req.name) {
886        return Err(item_not_found(&req.name, &req.section));
887    }
888
889    edit::remove(&mut doc, &req.section, &req.name);
890
891    save(&path, &doc, diagnostics)
892}
893
894/// Change an item already in the pantry and write it back.
895///
896/// Only the attributes set on the request are changed; the rest of the item is
897/// left as it was. There is no way to clear an attribute — `None` means "leave
898/// it", not "remove it" — so an item is cleared by removing and adding it.
899///
900/// Returns the pantry as it now stands on disk, and any warnings from parsing
901/// what was there before.
902///
903/// Only the entry asked for is touched; see [what a write
904/// touches](self#what-a-write-touches).
905///
906/// # Errors
907///
908/// - [`CoreError::PantryEdit`] if the request sets no attribute at all, since
909///   that could only rewrite the file to what it already said; or if there is
910///   no such section, or no such item in it. Nothing is written.
911/// - [`CoreError::MissingConfig`] if the context carries no pantry, and
912///   [`CoreError::ReadOnlyConfig`] if it carries one inline.
913/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
914///   written.
915pub fn update(ctx: &Context, req: UpdateRequest) -> Result<Outcome<PantryContents>, CoreError> {
916    let attributes = edit::Attributes {
917        quantity: req.quantity,
918        bought: req.bought,
919        expire: req.expire,
920        low: req.low,
921    };
922
923    // Checked before anything is read: an update of nothing is a mistake
924    // whether or not there is a pantry to make it in.
925    if attributes.is_empty() {
926        return Err(CoreError::PantryEdit {
927            message: format!(
928                "no attributes given to update on item '{}' in section '{}'",
929                req.name, req.section
930            ),
931        });
932    }
933
934    edit::check_general_attributes(&req.section, &req.name, &attributes)?;
935
936    let path = path_to_edit(ctx)?;
937    let (mut doc, mut diagnostics) = read_document(&path)?;
938
939    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
940    if !edit::section_exists(&doc, &req.section) {
941        return Err(section_not_found(&req.section));
942    }
943    if !edit::item_exists(&doc, &req.section, &req.name) {
944        return Err(item_not_found(&req.name, &req.section));
945    }
946
947    edit::apply(&mut doc, &req.section, &req.name, &attributes)?;
948
949    save(&path, &doc, diagnostics)
950}
951
952fn section_not_found(section: &str) -> CoreError {
953    CoreError::PantryEdit {
954        message: format!("section '{section}' not found"),
955    }
956}
957
958fn item_not_found(name: &str, section: &str) -> CoreError {
959    CoreError::PantryEdit {
960        message: format!("item '{name}' not found in section '{section}'"),
961    }
962}
963
964/// The file [`add`] writes, which need not exist yet.
965fn path_to_create(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
966    match ctx.pantry() {
967        ConfigSource::Path(path) => Ok(path.clone()),
968        // Named with `discover`'s own constants, so that the file this creates
969        // stays the one the next `discover` finds.
970        ConfigSource::None => Ok(ctx
971            .base_path()
972            .join(crate::context::LOCAL_CONFIG_DIR)
973            .join(crate::context::AUTO_PANTRY)),
974        ConfigSource::Inline(_) => Err(read_only()),
975    }
976}
977
978/// The file [`remove`] and [`update`] write, which must exist: there is
979/// nothing to take an item out of, or to change, without one.
980fn path_to_edit(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
981    match ctx.pantry() {
982        ConfigSource::Path(path) => Ok(path.clone()),
983        ConfigSource::None => Err(CoreError::MissingConfig {
984            kind: "pantry".to_string(),
985        }),
986        ConfigSource::Inline(_) => Err(read_only()),
987    }
988}
989
990fn read_only() -> CoreError {
991    CoreError::ReadOnlyConfig {
992        kind: "pantry".to_string(),
993    }
994}
995
996fn parse_conf(
997    path: &Utf8Path,
998    text: &str,
999) -> Result<(cooklang::pantry::PantryConf, Vec<Diagnostic>), CoreError> {
1000    let parsed = cooklang::pantry::parse_lenient(text);
1001    let diagnostics = collect_diagnostics(parsed.report(), Some(path));
1002    match parsed.output() {
1003        Some(conf) => Ok((conf.clone(), diagnostics)),
1004        None => Err(CoreError::Config {
1005            path: Some(path.to_owned()),
1006            message: parse_failure(&diagnostics, "pantry"),
1007        }),
1008    }
1009}
1010
1011/// Convert a section written as an array of names into the equivalent table,
1012/// and say so.
1013///
1014/// The array form has nowhere to put a key, so an edit to such a section has to
1015/// rewrite it. Doing that here, rather than leaving [`edit::insert`] to replace
1016/// the array wholesale, is what keeps the names already in it.
1017fn normalise_array_section(
1018    doc: &mut toml_edit::DocumentMut,
1019    section: &str,
1020    path: &Utf8Path,
1021) -> Vec<Diagnostic> {
1022    let converted = edit::normalise_array_section(doc, section);
1023    if converted.is_empty() {
1024        return Vec::new();
1025    }
1026    vec![Diagnostic::warning(format!(
1027        "section '{section}' was written as a list of names, which cannot hold quantities; \
1028         rewritten as a [{section}] section keeping {}",
1029        converted.join(", ")
1030    ))
1031    .at_file(path.to_owned())]
1032}
1033
1034/// Read a pantry file as an editable document, and the warnings `cooklang`
1035/// raises about it.
1036///
1037/// Parsed twice, deliberately and cheaply: once as TOML, which is what an edit
1038/// is applied to, and once through `cooklang`, whose lenient parse is what
1039/// produces the diagnostics a caller expects and what decides whether the file
1040/// is a *pantry* rather than merely valid TOML.
1041fn read_document(path: &Utf8Path) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1042    let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
1043        path: path.to_owned(),
1044        source,
1045    })?;
1046    parse_document(path, &text)
1047}
1048
1049/// As [`read_document`], but an absent file is an empty document rather than an
1050/// error — which is what lets [`add`] create one.
1051///
1052/// Missing is judged by the read failing rather than by asking whether the
1053/// file exists first, so that nothing can delete it in between.
1054fn read_document_or_empty(
1055    path: &Utf8Path,
1056) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1057    match std::fs::read_to_string(path) {
1058        Ok(text) => parse_document(path, &text),
1059        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1060            Ok((toml_edit::DocumentMut::new(), Vec::new()))
1061        }
1062        Err(source) => Err(CoreError::Io {
1063            path: path.to_owned(),
1064            source,
1065        }),
1066    }
1067}
1068
1069fn parse_document(
1070    path: &Utf8Path,
1071    text: &str,
1072) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1073    // `cooklang` first, so that a file it rejects is reported the way every
1074    // other pantry command reports it, rather than as a TOML error.
1075    let (_, diagnostics) = parse_conf(path, text)?;
1076    Ok((edit::parse(text, path)?, diagnostics))
1077}
1078
1079/// Write the edited document over `path` and read back what it now says.
1080///
1081/// Reading back rather than deriving the result from the edit is what keeps the
1082/// returned [`PantryContents`] honest: it is the file as the next command will
1083/// see it, normalisation and all.
1084fn save(
1085    path: &Utf8Path,
1086    doc: &toml_edit::DocumentMut,
1087    diagnostics: Vec<Diagnostic>,
1088) -> Result<Outcome<PantryContents>, CoreError> {
1089    let text = doc.to_string();
1090    write_atomically(path, &text)?;
1091
1092    // The re-read is for the value, not for its diagnostics: those are the same
1093    // ones already collected from reading the file, and reporting them twice
1094    // per edit is noise.
1095    let (conf, _) = parse_conf(path, &text)?;
1096    Ok(Outcome::with_diagnostics(
1097        PantryContents::from_conf(&conf),
1098        diagnostics,
1099    ))
1100}
1101
1102/// What to call a recipe in the results: its title, or its file stem.
1103///
1104/// The fallback is `cooklang-find`'s job and it always manages one for a
1105/// file-backed entry, so "unknown" is unreachable through a walk. Kept because
1106/// dropping a nameless recipe from the results would be worse than naming it
1107/// badly.
1108fn recipe_name(entry: &RecipeEntry) -> String {
1109    entry
1110        .name()
1111        .clone()
1112        .unwrap_or_else(|| "unknown".to_string())
1113}
1114
1115// ---------------------------------------------------------------------------
1116// Reading quantities and dates
1117// ---------------------------------------------------------------------------
1118
1119/// A quantity as pantry files write it: a number, an optional `%`, then an
1120/// optional unit. The unit group matches the empty string, so a bare count
1121/// parses with no unit rather than failing.
1122static QUANTITY: LazyLock<Regex> = LazyLock::new(|| {
1123    Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$").expect("the quantity pattern is valid")
1124});
1125
1126/// The unit of a quantity, lowercased, or `None` if it is not a quantity at
1127/// all. A bare count has an empty unit.
1128fn unit_of(quantity: &str) -> Option<String> {
1129    QUANTITY
1130        .captures(quantity)
1131        .map(|captures| captures[2].to_lowercase())
1132}
1133
1134/// Whether two quantities are written in the same unit, and so can be
1135/// compared. False if either is not a quantity.
1136fn units_match(quantity: &str, low_threshold: &str) -> bool {
1137    match (unit_of(quantity), unit_of(low_threshold)) {
1138        (Some(quantity), Some(threshold)) => quantity == threshold,
1139        _ => false,
1140    }
1141}
1142
1143/// The built-in "running out" thresholds, for items that set none of their
1144/// own. False for anything that is not a quantity.
1145fn is_low_quantity(quantity: &str) -> bool {
1146    let Some(captures) = QUANTITY.captures(quantity) else {
1147        return false;
1148    };
1149    let Ok(amount) = captures[1].parse::<f64>() else {
1150        return false;
1151    };
1152
1153    match captures[2].to_lowercase().as_str() {
1154        "g" | "ml" => amount <= 100.0,
1155        "kg" | "l" => amount < 0.5,
1156        // A bare count, `item`, `items`, and every unit not listed above.
1157        _ => amount <= 1.0,
1158    }
1159}
1160
1161/// The date spellings a pantry file may use, tried in this order.
1162const DATE_FORMATS: [&str; 6] = [
1163    "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
1164];
1165
1166/// Read a date in any of [`DATE_FORMATS`], or `None`.
1167fn parse_date(date: &str) -> Option<NaiveDate> {
1168    DATE_FORMATS
1169        .iter()
1170        .find_map(|format| NaiveDate::parse_from_str(date, format).ok())
1171}
1172
1173mod edit;
1174
1175#[cfg(test)]
1176mod tests;