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, parse_or_skip, required_ingredients, 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 so are optional
519/// ingredients (`@?chives`): a recipe whose only absent ingredients are
520/// optional is a full match. A recipe left with no ingredients at all matches
521/// nothing. See [`required_ingredients`] for what else is left out.
522///
523/// Recipes are found by walking the collection, `.menu` files included. A
524/// recipe that cannot be read or parsed is left out, with a warning in
525/// [`Outcome::diagnostics`] naming it — it is not counted as a match or a
526/// miss. Warnings from recipes that *did* parse are not reported here, because
527/// they cannot change the answer; `doctor::validate` is the command for those.
528///
529/// # Errors
530///
531/// - As [`load`], since this needs the pantry.
532/// - [`CoreError::Search`] if the collection cannot be walked, and
533///   [`CoreError::Io`] if a file in it cannot be listed — as
534///   `doctor::validate`.
535pub fn recipes(ctx: &Context, req: RecipesRequest) -> Result<Outcome<RecipeMatches>, CoreError> {
536    let loaded = load(ctx)?;
537    let mut diagnostics = loaded.diagnostics;
538    let stocked: BTreeSet<String> = loaded
539        .value
540        .items()
541        .map(|item| item.name.to_lowercase())
542        .collect();
543
544    let tree = build_tree(ctx.base_path())?;
545    let mut matches = RecipeMatches::default();
546
547    for entry in walk(&tree) {
548        let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
549            continue;
550        };
551        // Lowercased into the set, so a recipe naming `Salt` and `salt` wants
552        // one ingredient rather than two.
553        let wanted: BTreeSet<String> = required_ingredients(&recipe)
554            .iter()
555            .map(|name| name.to_lowercase())
556            .collect();
557        if wanted.is_empty() {
558            continue;
559        }
560
561        let available = wanted.iter().filter(|name| stocked.contains(*name)).count();
562        let percentage = available * 100 / wanted.len();
563        let name = recipe_name(entry);
564
565        if available == wanted.len() {
566            matches.full.push(name);
567        } else if percentage >= usize::from(req.threshold) {
568            matches.partial.push(PartialMatch {
569                name,
570                percentage,
571                // From a `BTreeSet`, so already in order.
572                missing: wanted
573                    .iter()
574                    .filter(|name| !stocked.contains(*name))
575                    .cloned()
576                    .collect(),
577            });
578        }
579    }
580
581    // The walk yields directories in a `HashMap`'s order, which changes
582    // between runs. Sorting is what makes the answer the same twice running.
583    matches.full.sort();
584    matches.partial.sort();
585
586    Ok(Outcome::with_diagnostics(matches, diagnostics))
587}
588
589// ---------------------------------------------------------------------------
590// plan
591// ---------------------------------------------------------------------------
592
593/// How far to take a pantry plan.
594///
595/// Not `#[non_exhaustive]`: consumers construct this. The default plans until
596/// every recipe is covered.
597#[derive(Debug, Clone, Default)]
598pub struct PlanRequest {
599    /// Stop after this many ingredients. `None` continues until every recipe
600    /// is cookable, or until no ingredient is left to add.
601    pub max_ingredients: Option<usize>,
602    /// Count a recipe as cookable while it is still missing this many
603    /// ingredients. `0` means everything it needs must be stocked.
604    pub allow_missing: usize,
605}
606
607/// One ingredient to buy, and what buying it achieves.
608///
609/// `#[non_exhaustive]` because this is an output type consumers read rather
610/// than construct.
611#[non_exhaustive]
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct IngredientStep {
614    /// The ingredient, as recipes write it.
615    pub name: String,
616    /// How many more recipes become cookable once it is in stock.
617    pub new_recipes_unlocked: usize,
618    /// How many recipes are cookable in total by this point — this step and
619    /// every step before it.
620    pub total_cookable: usize,
621}
622
623/// An order to stock a pantry in.
624///
625/// `#[non_exhaustive]` because this is an output type consumers read rather
626/// than construct.
627#[non_exhaustive]
628#[derive(Debug, Clone, PartialEq, Eq)]
629pub struct PantryPlan {
630    /// The ingredients to buy, most useful first.
631    pub steps: Vec<IngredientStep>,
632    /// How many recipes the plan was worked out over: every recipe in the
633    /// collection that lists at least one ingredient.
634    pub total_recipes: usize,
635}
636
637impl PantryPlan {
638    /// How many recipes are cookable once the whole plan is stocked.
639    ///
640    /// Read off the last step rather than stored alongside it, so it cannot
641    /// disagree with the steps it summarises. Zero for an empty plan.
642    pub fn cookable_recipes(&self) -> usize {
643        self.steps.last().map_or(0, |step| step.total_cookable)
644    }
645
646    /// [`cookable_recipes`](PantryPlan::cookable_recipes) as a percentage of
647    /// [`total_recipes`](PantryPlan::total_recipes), rounded down. Zero when
648    /// there are no recipes at all.
649    pub fn coverage_percentage(&self) -> usize {
650        self.cookable_recipes() * 100 / self.total_recipes.max(1)
651    }
652}
653
654/// Work out which ingredients to stock to cook as much of the collection as
655/// possible.
656///
657/// **The pantry is not consulted.** This answers "what should I buy?" from the
658/// recipes under [`Context::base_path`] alone, so it needs no pantry
659/// configuration and will happily recommend something already in stock.
660///
661/// The plan is greedy: at each step it takes the ingredient wanted by the most
662/// recipes that are not yet cookable, and ties are broken alphabetically. That
663/// is an approximation — a greedy set cover is not guaranteed to be the
664/// shortest plan — but it is deterministic, which the tie-break is there for.
665///
666/// Only `.cook` files are considered; `.menu` files are skipped, and so are
667/// recipes that list no ingredients — and, as in [`recipes`], any that cannot
668/// be read or parsed, each with a warning in [`Outcome::diagnostics`].
669/// Ingredients are those of [`required_ingredients`] — optional ones are
670/// never worth stocking to make a recipe cookable — compared exactly as
671/// recipes write them, so `Flour` and `flour` are two ingredients — unlike
672/// [`recipes`], which lowercases.
673///
674/// # Errors
675///
676/// [`CoreError::Search`] if the collection cannot be walked, and
677/// [`CoreError::Io`] if a file in it cannot be listed. Never
678/// [`CoreError::MissingConfig`].
679pub fn plan(ctx: &Context, req: PlanRequest) -> Result<Outcome<PantryPlan>, CoreError> {
680    let tree = build_tree(ctx.base_path())?;
681    let mut diagnostics = Vec::new();
682
683    // What each recipe still needs. A recipe drops out once it is cookable.
684    let mut missing: Vec<BTreeSet<String>> = walk(&tree)
685        .into_iter()
686        .filter(|entry| !entry.is_menu())
687        .filter_map(|entry| parse_or_skip(entry, &mut diagnostics))
688        .map(|recipe| required_ingredients(&recipe))
689        .filter(|ingredients| !ingredients.is_empty())
690        .collect();
691
692    let total_recipes = missing.len();
693    let max_ingredients = req.max_ingredients.unwrap_or(usize::MAX);
694    let mut steps: Vec<IngredientStep> = Vec::new();
695    let mut cookable = 0;
696
697    while cookable < total_recipes && steps.len() < max_ingredients {
698        let Some(best) = most_wanted(&missing) else {
699            // Nothing left to choose: every remaining recipe wants nothing,
700            // which `allow_missing` cannot satisfy.
701            break;
702        };
703
704        let mut newly_cookable = 0;
705        missing.retain_mut(|wanted| {
706            wanted.remove(&best);
707            if wanted.len() <= req.allow_missing {
708                newly_cookable += 1;
709                false
710            } else {
711                true
712            }
713        });
714        cookable += newly_cookable;
715
716        steps.push(IngredientStep {
717            name: best,
718            new_recipes_unlocked: newly_cookable,
719            total_cookable: cookable,
720        });
721    }
722
723    Ok(Outcome::with_diagnostics(
724        PantryPlan {
725            steps,
726            total_recipes,
727        },
728        diagnostics,
729    ))
730}
731
732/// The ingredient wanted by the most recipes, ties broken alphabetically.
733fn most_wanted(missing: &[BTreeSet<String>]) -> Option<String> {
734    let mut scores: BTreeMap<&str, usize> = BTreeMap::new();
735    for wanted in missing {
736        for ingredient in wanted {
737            *scores.entry(ingredient.as_str()).or_insert(0) += 1;
738        }
739    }
740    scores
741        // Highest count wins; `Reverse` on the name turns "largest" into
742        // "alphabetically first" for the tie, which is what makes two runs
743        // over the same collection agree.
744        .into_iter()
745        .max_by_key(|&(name, count)| (count, Reverse(name)))
746        .map(|(name, _)| name.to_string())
747}
748
749// ---------------------------------------------------------------------------
750// add, remove, update
751// ---------------------------------------------------------------------------
752
753/// An item to add to the pantry.
754///
755/// Not `#[non_exhaustive]`: consumers construct this. `..Default::default()`
756/// keeps a literal working if it grows a field.
757#[derive(Debug, Clone, Default)]
758pub struct AddRequest {
759    /// The section to add it under, matched and written exactly as given once
760    /// trimmed — unlike [`ListRequest::section`], case counts, so adding to
761    /// `Dairy` when the file says `dairy` makes a second section.
762    pub section: String,
763    /// The ingredient's name.
764    pub name: String,
765    /// How much is in stock, as pantry files write it: `"500%g"`, `"2"`.
766    pub quantity: Option<String>,
767    /// When it was bought.
768    pub bought: Option<String>,
769    /// When it expires. See [`expiring`] for the spellings that can be read
770    /// back as a date.
771    pub expire: Option<String>,
772    /// The quantity at or below which it counts as low.
773    pub low: Option<String>,
774}
775
776impl AddRequest {
777    /// The request with the spaces around each field taken off, and blank
778    /// attributes left unset. A blank section or name is refused.
779    fn trimmed(self) -> Result<Self, CoreError> {
780        let section = self.section.trim().to_string();
781        let name = self.name.trim().to_string();
782        for (what, value) in [("section", &section), ("item name", &name)] {
783            if value.is_empty() {
784                return Err(CoreError::PantryEdit {
785                    message: format!("the {what} cannot be empty"),
786                });
787            }
788        }
789
790        let attribute = |value: Option<String>| {
791            value
792                .map(|v| v.trim().to_string())
793                .filter(|v| !v.is_empty())
794        };
795        Ok(Self {
796            section,
797            name,
798            quantity: attribute(self.quantity),
799            bought: attribute(self.bought),
800            expire: attribute(self.expire),
801            low: attribute(self.low),
802        })
803    }
804}
805
806/// Which item to take out of the pantry.
807///
808/// Not `#[non_exhaustive]`: consumers construct this.
809#[derive(Debug, Clone, Default)]
810pub struct RemoveRequest {
811    /// The section holding it, matched exactly.
812    pub section: String,
813    /// The item's name, matched exactly.
814    pub name: String,
815}
816
817/// What to change about an item already in the pantry.
818///
819/// Not `#[non_exhaustive]`: consumers construct this. At least one attribute
820/// must be set; see [`update`].
821#[derive(Debug, Clone, Default)]
822pub struct UpdateRequest {
823    /// The section holding it, matched exactly.
824    pub section: String,
825    /// The item's name, matched exactly. Not changed by an update — remove and
826    /// add to rename.
827    pub name: String,
828    /// The new quantity, or `None` to leave it as it is.
829    pub quantity: Option<String>,
830    /// The new bought date, or `None` to leave it as it is.
831    pub bought: Option<String>,
832    /// The new expiry date, or `None` to leave it as it is.
833    pub expire: Option<String>,
834    /// The new low-stock threshold, or `None` to leave it as it is.
835    pub low: Option<String>,
836}
837
838/// Add an item to the pantry and write it back.
839///
840/// The section is created if the file has no such section, and the file itself
841/// is created if there is none — under `<base_path>/config/pantry.conf`, which
842/// is where [`Context::discover`] looks first. That is the one case where this
843/// crate invents a path rather than being told one.
844///
845/// Spaces around the section, the name and each attribute are dropped first,
846/// and a blank attribute is left unset: the pantry is matched against recipe
847/// ingredients by name, and ` milk` would never match `milk`.
848///
849/// Returns the pantry as it now stands on disk, so a caller need not read it
850/// back, together with any warnings from parsing what was there before.
851///
852/// Only the entry asked for is touched; see [what a write
853/// touches](self#what-a-write-touches).
854///
855/// # Errors
856///
857/// - [`CoreError::ReadOnlyConfig`] if the context carries the pantry inline.
858///   There is nowhere to write it, and inventing a path would put an editor's
859///   unsaved buffer on someone's disk.
860/// - [`CoreError::PantryEdit`] if the section or the name is blank, or if the
861///   section already holds an item of that name, compared exactly once
862///   trimmed. Nothing is written; [`update`] is how an item is changed.
863/// - [`CoreError::Config`] if the existing file cannot be parsed at all, and
864///   [`CoreError::Io`] if it cannot be read or the new one cannot be written.
865pub fn add(ctx: &Context, req: AddRequest) -> Result<Outcome<PantryContents>, CoreError> {
866    let req = req.trimmed()?;
867    let attributes = edit::Attributes {
868        quantity: req.quantity,
869        bought: req.bought,
870        expire: req.expire,
871        low: req.low,
872    };
873    edit::check_general_attributes(&req.section, &req.name, &attributes)?;
874
875    let path = path_to_create(ctx)?;
876    let (mut doc, mut diagnostics) = read_document_or_empty(&path)?;
877
878    if edit::item_exists(&doc, &req.section, &req.name) {
879        return Err(CoreError::PantryEdit {
880            message: format!(
881                "item '{}' already exists in section '{}'",
882                req.name, req.section
883            ),
884        });
885    }
886
887    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
888    edit::insert(&mut doc, &req.section, &req.name, &attributes);
889
890    save(&path, &doc, diagnostics)
891}
892
893/// Take an item out of the pantry and write it back.
894///
895/// A section left with no items is removed too, because `cooklang` drops empty
896/// sections when it reads a file and keeping one would not survive the next
897/// read anyway.
898///
899/// Returns the pantry as it now stands on disk, and any warnings from parsing
900/// what was there before.
901///
902/// Only the entry asked for is touched; see [what a write
903/// touches](self#what-a-write-touches).
904///
905/// # Errors
906///
907/// - [`CoreError::MissingConfig`] if the context carries no pantry: there is
908///   nothing to take an item out of. Unlike [`add`], this does not create one.
909/// - [`CoreError::ReadOnlyConfig`] if it carries the pantry inline.
910/// - [`CoreError::PantryEdit`] if there is no such section, or no such item in
911///   it. Nothing is written.
912/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
913///   written.
914pub fn remove(ctx: &Context, req: RemoveRequest) -> Result<Outcome<PantryContents>, CoreError> {
915    let path = path_to_edit(ctx)?;
916    let (mut doc, mut diagnostics) = read_document(&path)?;
917
918    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
919    if !edit::section_exists(&doc, &req.section) {
920        return Err(section_not_found(&req.section));
921    }
922    if !edit::item_exists(&doc, &req.section, &req.name) {
923        return Err(item_not_found(&req.name, &req.section));
924    }
925
926    edit::remove(&mut doc, &req.section, &req.name);
927
928    save(&path, &doc, diagnostics)
929}
930
931/// Change an item already in the pantry and write it back.
932///
933/// Only the attributes set on the request are changed; the rest of the item is
934/// left as it was. There is no way to clear an attribute — `None` means "leave
935/// it", not "remove it" — so an item is cleared by removing and adding it.
936///
937/// Returns the pantry as it now stands on disk, and any warnings from parsing
938/// what was there before.
939///
940/// Only the entry asked for is touched; see [what a write
941/// touches](self#what-a-write-touches).
942///
943/// # Errors
944///
945/// - [`CoreError::PantryEdit`] if the request sets no attribute at all, since
946///   that could only rewrite the file to what it already said; or if there is
947///   no such section, or no such item in it. Nothing is written.
948/// - [`CoreError::MissingConfig`] if the context carries no pantry, and
949///   [`CoreError::ReadOnlyConfig`] if it carries one inline.
950/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
951///   written.
952pub fn update(ctx: &Context, req: UpdateRequest) -> Result<Outcome<PantryContents>, CoreError> {
953    let attributes = edit::Attributes {
954        quantity: req.quantity,
955        bought: req.bought,
956        expire: req.expire,
957        low: req.low,
958    };
959
960    // Checked before anything is read: an update of nothing is a mistake
961    // whether or not there is a pantry to make it in.
962    if attributes.is_empty() {
963        return Err(CoreError::PantryEdit {
964            message: format!(
965                "no attributes given to update on item '{}' in section '{}'",
966                req.name, req.section
967            ),
968        });
969    }
970
971    edit::check_general_attributes(&req.section, &req.name, &attributes)?;
972
973    let path = path_to_edit(ctx)?;
974    let (mut doc, mut diagnostics) = read_document(&path)?;
975
976    diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
977    if !edit::section_exists(&doc, &req.section) {
978        return Err(section_not_found(&req.section));
979    }
980    if !edit::item_exists(&doc, &req.section, &req.name) {
981        return Err(item_not_found(&req.name, &req.section));
982    }
983
984    edit::apply(&mut doc, &req.section, &req.name, &attributes)?;
985
986    save(&path, &doc, diagnostics)
987}
988
989fn section_not_found(section: &str) -> CoreError {
990    CoreError::PantryEdit {
991        message: format!("section '{section}' not found"),
992    }
993}
994
995fn item_not_found(name: &str, section: &str) -> CoreError {
996    CoreError::PantryEdit {
997        message: format!("item '{name}' not found in section '{section}'"),
998    }
999}
1000
1001/// The file [`add`] writes, which need not exist yet.
1002fn path_to_create(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
1003    match ctx.pantry() {
1004        ConfigSource::Path(path) => Ok(path.clone()),
1005        // Named with `discover`'s own constants, so that the file this creates
1006        // stays the one the next `discover` finds.
1007        ConfigSource::None => Ok(ctx
1008            .base_path()
1009            .join(crate::context::LOCAL_CONFIG_DIR)
1010            .join(crate::context::AUTO_PANTRY)),
1011        ConfigSource::Inline(_) => Err(read_only()),
1012    }
1013}
1014
1015/// The file [`remove`] and [`update`] write, which must exist: there is
1016/// nothing to take an item out of, or to change, without one.
1017fn path_to_edit(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
1018    match ctx.pantry() {
1019        ConfigSource::Path(path) => Ok(path.clone()),
1020        ConfigSource::None => Err(CoreError::MissingConfig {
1021            kind: "pantry".to_string(),
1022        }),
1023        ConfigSource::Inline(_) => Err(read_only()),
1024    }
1025}
1026
1027fn read_only() -> CoreError {
1028    CoreError::ReadOnlyConfig {
1029        kind: "pantry".to_string(),
1030    }
1031}
1032
1033fn parse_conf(
1034    path: &Utf8Path,
1035    text: &str,
1036) -> Result<(cooklang::pantry::PantryConf, Vec<Diagnostic>), CoreError> {
1037    let parsed = cooklang::pantry::parse_lenient(text);
1038    let diagnostics = collect_diagnostics(parsed.report(), Some(path));
1039    match parsed.output() {
1040        Some(conf) => Ok((conf.clone(), diagnostics)),
1041        None => Err(CoreError::Config {
1042            path: Some(path.to_owned()),
1043            message: parse_failure(&diagnostics, "pantry"),
1044        }),
1045    }
1046}
1047
1048/// Convert a section written as an array of names into the equivalent table,
1049/// and say so.
1050///
1051/// The array form has nowhere to put a key, so an edit to such a section has to
1052/// rewrite it. Doing that here, rather than leaving [`edit::insert`] to replace
1053/// the array wholesale, is what keeps the names already in it.
1054fn normalise_array_section(
1055    doc: &mut toml_edit::DocumentMut,
1056    section: &str,
1057    path: &Utf8Path,
1058) -> Vec<Diagnostic> {
1059    let converted = edit::normalise_array_section(doc, section);
1060    if converted.is_empty() {
1061        return Vec::new();
1062    }
1063    vec![Diagnostic::warning(format!(
1064        "section '{section}' was written as a list of names, which cannot hold quantities; \
1065         rewritten as a [{section}] section keeping {}",
1066        converted.join(", ")
1067    ))
1068    .at_file(path.to_owned())]
1069}
1070
1071/// Read a pantry file as an editable document, and the warnings `cooklang`
1072/// raises about it.
1073///
1074/// Parsed twice, deliberately and cheaply: once as TOML, which is what an edit
1075/// is applied to, and once through `cooklang`, whose lenient parse is what
1076/// produces the diagnostics a caller expects and what decides whether the file
1077/// is a *pantry* rather than merely valid TOML.
1078fn read_document(path: &Utf8Path) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1079    let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
1080        path: path.to_owned(),
1081        source,
1082    })?;
1083    parse_document(path, &text)
1084}
1085
1086/// As [`read_document`], but an absent file is an empty document rather than an
1087/// error — which is what lets [`add`] create one.
1088///
1089/// Missing is judged by the read failing rather than by asking whether the
1090/// file exists first, so that nothing can delete it in between.
1091fn read_document_or_empty(
1092    path: &Utf8Path,
1093) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1094    match std::fs::read_to_string(path) {
1095        Ok(text) => parse_document(path, &text),
1096        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
1097            Ok((toml_edit::DocumentMut::new(), Vec::new()))
1098        }
1099        Err(source) => Err(CoreError::Io {
1100            path: path.to_owned(),
1101            source,
1102        }),
1103    }
1104}
1105
1106fn parse_document(
1107    path: &Utf8Path,
1108    text: &str,
1109) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
1110    // `cooklang` first, so that a file it rejects is reported the way every
1111    // other pantry command reports it, rather than as a TOML error.
1112    let (_, diagnostics) = parse_conf(path, text)?;
1113    Ok((edit::parse(text, path)?, diagnostics))
1114}
1115
1116/// Write the edited document over `path` and read back what it now says.
1117///
1118/// Reading back rather than deriving the result from the edit is what keeps the
1119/// returned [`PantryContents`] honest: it is the file as the next command will
1120/// see it, normalisation and all.
1121fn save(
1122    path: &Utf8Path,
1123    doc: &toml_edit::DocumentMut,
1124    diagnostics: Vec<Diagnostic>,
1125) -> Result<Outcome<PantryContents>, CoreError> {
1126    let text = doc.to_string();
1127    write_atomically(path, &text)?;
1128
1129    // The re-read is for the value, not for its diagnostics: those are the same
1130    // ones already collected from reading the file, and reporting them twice
1131    // per edit is noise.
1132    let (conf, _) = parse_conf(path, &text)?;
1133    Ok(Outcome::with_diagnostics(
1134        PantryContents::from_conf(&conf),
1135        diagnostics,
1136    ))
1137}
1138
1139/// What to call a recipe in the results: its title, or its file stem.
1140///
1141/// The fallback is `cooklang-find`'s job and it always manages one for a
1142/// file-backed entry, so "unknown" is unreachable through a walk. Kept because
1143/// dropping a nameless recipe from the results would be worse than naming it
1144/// badly.
1145fn recipe_name(entry: &RecipeEntry) -> String {
1146    entry
1147        .name()
1148        .clone()
1149        .unwrap_or_else(|| "unknown".to_string())
1150}
1151
1152// ---------------------------------------------------------------------------
1153// Reading quantities and dates
1154// ---------------------------------------------------------------------------
1155
1156/// A quantity as pantry files write it: a number, an optional `%`, then an
1157/// optional unit. The unit group matches the empty string, so a bare count
1158/// parses with no unit rather than failing.
1159static QUANTITY: LazyLock<Regex> = LazyLock::new(|| {
1160    Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$").expect("the quantity pattern is valid")
1161});
1162
1163/// The unit of a quantity, lowercased, or `None` if it is not a quantity at
1164/// all. A bare count has an empty unit.
1165fn unit_of(quantity: &str) -> Option<String> {
1166    QUANTITY
1167        .captures(quantity)
1168        .map(|captures| captures[2].to_lowercase())
1169}
1170
1171/// Whether two quantities are written in the same unit, and so can be
1172/// compared. False if either is not a quantity.
1173fn units_match(quantity: &str, low_threshold: &str) -> bool {
1174    match (unit_of(quantity), unit_of(low_threshold)) {
1175        (Some(quantity), Some(threshold)) => quantity == threshold,
1176        _ => false,
1177    }
1178}
1179
1180/// The built-in "running out" thresholds, for items that set none of their
1181/// own. False for anything that is not a quantity.
1182fn is_low_quantity(quantity: &str) -> bool {
1183    let Some(captures) = QUANTITY.captures(quantity) else {
1184        return false;
1185    };
1186    let Ok(amount) = captures[1].parse::<f64>() else {
1187        return false;
1188    };
1189
1190    match captures[2].to_lowercase().as_str() {
1191        "g" | "ml" => amount <= 100.0,
1192        "kg" | "l" => amount < 0.5,
1193        // A bare count, `item`, `items`, and every unit not listed above.
1194        _ => amount <= 1.0,
1195    }
1196}
1197
1198/// The date spellings a pantry file may use, tried in this order.
1199const DATE_FORMATS: [&str; 6] = [
1200    "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
1201];
1202
1203/// Read a date in any of [`DATE_FORMATS`], or `None`.
1204fn parse_date(date: &str) -> Option<NaiveDate> {
1205    DATE_FORMATS
1206        .iter()
1207        .find_map(|format| NaiveDate::parse_from_str(date, format).ok())
1208}
1209
1210mod edit;
1211
1212#[cfg(test)]
1213mod tests;