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| §ion.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;