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| §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 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", §ion), ("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;