use crate::{
diagnostic::parse_failure,
find::{build_tree, listed_ingredients, parse_or_skip, walk},
fs_atomic::write_atomically,
parser::collect_diagnostics,
ConfigSource, Context, CoreError, Diagnostic, Outcome,
};
use camino::{Utf8Path, Utf8PathBuf};
use chrono::{Local, NaiveDate};
use cooklang_find::RecipeEntry;
use regex::Regex;
use std::{
cmp::Reverse,
collections::{BTreeMap, BTreeSet},
sync::LazyLock,
};
const ISO_DATE: &str = "%Y-%m-%d";
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryItem {
pub name: String,
pub section: String,
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
impl PantryItem {
pub fn is_low(&self) -> bool {
cooklang::pantry::PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
name: self.name.clone(),
bought: None,
expire: None,
quantity: self.quantity.clone(),
low: self.low.clone(),
})
.is_low()
}
fn from_cooklang(section: &str, item: &cooklang::pantry::PantryItem) -> Self {
Self {
name: item.name().to_string(),
section: section.to_string(),
quantity: item.quantity().map(ToOwned::to_owned),
bought: item.bought().map(ToOwned::to_owned),
expire: item.expire().map(ToOwned::to_owned),
low: item.low().map(ToOwned::to_owned),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantrySection {
pub name: String,
pub items: Vec<PantryItem>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryContents {
pub sections: Vec<PantrySection>,
}
impl PantryContents {
pub fn items(&self) -> impl Iterator<Item = &PantryItem> {
self.sections.iter().flat_map(|section| §ion.items)
}
fn from_conf(conf: &cooklang::pantry::PantryConf) -> Self {
Self {
sections: conf
.sections
.iter()
.map(|(name, items)| PantrySection {
name: name.clone(),
items: items
.iter()
.map(|item| PantryItem::from_cooklang(name, item))
.collect(),
})
.collect(),
}
}
}
pub fn load(ctx: &Context) -> Result<Outcome<PantryContents>, CoreError> {
let source = ctx.pantry();
let Some(text) = source.read()? else {
return Err(CoreError::MissingConfig {
kind: "pantry".to_string(),
});
};
let path = source.path();
tracing::trace!("loading pantry from {:?}", path);
let parsed = cooklang::pantry::parse_lenient(&text);
let diagnostics = collect_diagnostics(parsed.report(), path);
match parsed.output() {
Some(conf) => Ok(Outcome::with_diagnostics(
PantryContents::from_conf(conf),
diagnostics,
)),
None => Err(CoreError::Config {
path: path.map(ToOwned::to_owned),
message: parse_failure(&diagnostics, "pantry"),
}),
}
}
#[derive(Debug, Clone, Default)]
pub struct ListRequest {
pub section: Option<String>,
}
pub fn list(ctx: &Context, req: ListRequest) -> Result<Outcome<PantryContents>, CoreError> {
let mut outcome = load(ctx)?;
if let Some(section) = &req.section {
outcome
.value
.sections
.retain(|s| s.name.eq_ignore_ascii_case(section));
}
Ok(outcome)
}
#[derive(Debug, Clone, Default)]
pub struct DepletedRequest {
pub all: bool,
}
pub fn depleted(
ctx: &Context,
req: DepletedRequest,
) -> Result<Outcome<Vec<PantryItem>>, CoreError> {
let outcome = load(ctx)?;
let items = outcome
.value
.items()
.filter(|item| is_depleted(item, req.all))
.cloned()
.collect();
Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
}
fn is_depleted(item: &PantryItem, all: bool) -> bool {
if item.is_low() {
return true;
}
match &item.quantity {
None => all,
Some(quantity) => match &item.low {
Some(low) if units_match(quantity, low) => all,
_ => is_low_quantity(quantity),
},
}
}
#[derive(Debug, Clone)]
pub struct ExpiringRequest {
pub days: u32,
pub include_unknown: bool,
}
impl Default for ExpiringRequest {
fn default() -> Self {
Self {
days: 7,
include_unknown: false,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpiringItem {
pub item: PantryItem,
pub expire_date: Option<String>,
pub days_until_expiry: Option<i64>,
}
pub fn expiring(
ctx: &Context,
req: ExpiringRequest,
) -> Result<Outcome<Vec<ExpiringItem>>, CoreError> {
let outcome = load(ctx)?;
let items = expiring_on(&outcome.value, &req, Local::now().date_naive());
Ok(Outcome::with_diagnostics(items, outcome.diagnostics))
}
fn expiring_on(
contents: &PantryContents,
req: &ExpiringRequest,
today: NaiveDate,
) -> Vec<ExpiringItem> {
let threshold = today
.checked_add_signed(chrono::Duration::days(i64::from(req.days)))
.unwrap_or(NaiveDate::MAX);
let mut items: Vec<ExpiringItem> = contents
.items()
.filter_map(|item| match item.expire.as_deref().and_then(parse_date) {
Some(date) if date <= threshold => Some(ExpiringItem {
item: item.clone(),
expire_date: Some(date.format(ISO_DATE).to_string()),
days_until_expiry: Some((date - today).num_days()),
}),
Some(_) => None,
None if req.include_unknown => Some(ExpiringItem {
item: item.clone(),
expire_date: None,
days_until_expiry: None,
}),
None => None,
})
.collect();
items.sort_by_key(|item| item.days_until_expiry.unwrap_or(i64::MAX));
items
}
#[derive(Debug, Clone)]
pub struct RecipesRequest {
pub threshold: u8,
}
impl Default for RecipesRequest {
fn default() -> Self {
Self { threshold: 75 }
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PartialMatch {
pub name: String,
pub percentage: usize,
pub missing: Vec<String>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecipeMatches {
pub full: Vec<String>,
pub partial: Vec<PartialMatch>,
}
pub fn recipes(ctx: &Context, req: RecipesRequest) -> Result<Outcome<RecipeMatches>, CoreError> {
let loaded = load(ctx)?;
let mut diagnostics = loaded.diagnostics;
let stocked: BTreeSet<String> = loaded
.value
.items()
.map(|item| item.name.to_lowercase())
.collect();
let tree = build_tree(ctx.base_path())?;
let mut matches = RecipeMatches::default();
for entry in walk(&tree) {
let Some(recipe) = parse_or_skip(entry, &mut diagnostics) else {
continue;
};
let wanted: BTreeSet<String> = listed_ingredients(&recipe)
.iter()
.map(|name| name.to_lowercase())
.collect();
if wanted.is_empty() {
continue;
}
let available = wanted.iter().filter(|name| stocked.contains(*name)).count();
let percentage = available * 100 / wanted.len();
let name = recipe_name(entry);
if available == wanted.len() {
matches.full.push(name);
} else if percentage >= usize::from(req.threshold) {
matches.partial.push(PartialMatch {
name,
percentage,
missing: wanted
.iter()
.filter(|name| !stocked.contains(*name))
.cloned()
.collect(),
});
}
}
matches.full.sort();
matches.partial.sort();
Ok(Outcome::with_diagnostics(matches, diagnostics))
}
#[derive(Debug, Clone, Default)]
pub struct PlanRequest {
pub max_ingredients: Option<usize>,
pub allow_missing: usize,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IngredientStep {
pub name: String,
pub new_recipes_unlocked: usize,
pub total_cookable: usize,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PantryPlan {
pub steps: Vec<IngredientStep>,
pub total_recipes: usize,
}
impl PantryPlan {
pub fn cookable_recipes(&self) -> usize {
self.steps.last().map_or(0, |step| step.total_cookable)
}
pub fn coverage_percentage(&self) -> usize {
self.cookable_recipes() * 100 / self.total_recipes.max(1)
}
}
pub fn plan(ctx: &Context, req: PlanRequest) -> Result<Outcome<PantryPlan>, CoreError> {
let tree = build_tree(ctx.base_path())?;
let mut diagnostics = Vec::new();
let mut missing: Vec<BTreeSet<String>> = walk(&tree)
.into_iter()
.filter(|entry| !entry.is_menu())
.filter_map(|entry| parse_or_skip(entry, &mut diagnostics))
.map(|recipe| listed_ingredients(&recipe))
.filter(|ingredients| !ingredients.is_empty())
.collect();
let total_recipes = missing.len();
let max_ingredients = req.max_ingredients.unwrap_or(usize::MAX);
let mut steps: Vec<IngredientStep> = Vec::new();
let mut cookable = 0;
while cookable < total_recipes && steps.len() < max_ingredients {
let Some(best) = most_wanted(&missing) else {
break;
};
let mut newly_cookable = 0;
missing.retain_mut(|wanted| {
wanted.remove(&best);
if wanted.len() <= req.allow_missing {
newly_cookable += 1;
false
} else {
true
}
});
cookable += newly_cookable;
steps.push(IngredientStep {
name: best,
new_recipes_unlocked: newly_cookable,
total_cookable: cookable,
});
}
Ok(Outcome::with_diagnostics(
PantryPlan {
steps,
total_recipes,
},
diagnostics,
))
}
fn most_wanted(missing: &[BTreeSet<String>]) -> Option<String> {
let mut scores: BTreeMap<&str, usize> = BTreeMap::new();
for wanted in missing {
for ingredient in wanted {
*scores.entry(ingredient.as_str()).or_insert(0) += 1;
}
}
scores
.into_iter()
.max_by_key(|&(name, count)| (count, Reverse(name)))
.map(|(name, _)| name.to_string())
}
#[derive(Debug, Clone, Default)]
pub struct AddRequest {
pub section: String,
pub name: String,
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RemoveRequest {
pub section: String,
pub name: String,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateRequest {
pub section: String,
pub name: String,
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
pub fn add(ctx: &Context, req: AddRequest) -> Result<Outcome<PantryContents>, CoreError> {
let attributes = edit::Attributes {
quantity: req.quantity,
bought: req.bought,
expire: req.expire,
low: req.low,
};
edit::check_general_attributes(&req.section, &req.name, &attributes)?;
let path = path_to_create(ctx)?;
let (mut doc, mut diagnostics) = read_document_or_empty(&path)?;
if edit::item_exists(&doc, &req.section, &req.name) {
return Err(CoreError::PantryEdit {
message: format!(
"item '{}' already exists in section '{}'",
req.name, req.section
),
});
}
diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
edit::insert(&mut doc, &req.section, &req.name, &attributes);
save(&path, &doc, diagnostics)
}
pub fn remove(ctx: &Context, req: RemoveRequest) -> Result<Outcome<PantryContents>, CoreError> {
let path = path_to_edit(ctx)?;
let (mut doc, mut diagnostics) = read_document(&path)?;
diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
if !edit::section_exists(&doc, &req.section) {
return Err(section_not_found(&req.section));
}
if !edit::item_exists(&doc, &req.section, &req.name) {
return Err(item_not_found(&req.name, &req.section));
}
edit::remove(&mut doc, &req.section, &req.name);
save(&path, &doc, diagnostics)
}
pub fn update(ctx: &Context, req: UpdateRequest) -> Result<Outcome<PantryContents>, CoreError> {
let attributes = edit::Attributes {
quantity: req.quantity,
bought: req.bought,
expire: req.expire,
low: req.low,
};
if attributes.is_empty() {
return Err(CoreError::PantryEdit {
message: format!(
"no attributes given to update on item '{}' in section '{}'",
req.name, req.section
),
});
}
edit::check_general_attributes(&req.section, &req.name, &attributes)?;
let path = path_to_edit(ctx)?;
let (mut doc, mut diagnostics) = read_document(&path)?;
diagnostics.extend(normalise_array_section(&mut doc, &req.section, &path));
if !edit::section_exists(&doc, &req.section) {
return Err(section_not_found(&req.section));
}
if !edit::item_exists(&doc, &req.section, &req.name) {
return Err(item_not_found(&req.name, &req.section));
}
edit::apply(&mut doc, &req.section, &req.name, &attributes)?;
save(&path, &doc, diagnostics)
}
fn section_not_found(section: &str) -> CoreError {
CoreError::PantryEdit {
message: format!("section '{section}' not found"),
}
}
fn item_not_found(name: &str, section: &str) -> CoreError {
CoreError::PantryEdit {
message: format!("item '{name}' not found in section '{section}'"),
}
}
fn path_to_create(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
match ctx.pantry() {
ConfigSource::Path(path) => Ok(path.clone()),
ConfigSource::None => Ok(ctx
.base_path()
.join(crate::context::LOCAL_CONFIG_DIR)
.join(crate::context::AUTO_PANTRY)),
ConfigSource::Inline(_) => Err(read_only()),
}
}
fn path_to_edit(ctx: &Context) -> Result<Utf8PathBuf, CoreError> {
match ctx.pantry() {
ConfigSource::Path(path) => Ok(path.clone()),
ConfigSource::None => Err(CoreError::MissingConfig {
kind: "pantry".to_string(),
}),
ConfigSource::Inline(_) => Err(read_only()),
}
}
fn read_only() -> CoreError {
CoreError::ReadOnlyConfig {
kind: "pantry".to_string(),
}
}
fn parse_conf(
path: &Utf8Path,
text: &str,
) -> Result<(cooklang::pantry::PantryConf, Vec<Diagnostic>), CoreError> {
let parsed = cooklang::pantry::parse_lenient(text);
let diagnostics = collect_diagnostics(parsed.report(), Some(path));
match parsed.output() {
Some(conf) => Ok((conf.clone(), diagnostics)),
None => Err(CoreError::Config {
path: Some(path.to_owned()),
message: parse_failure(&diagnostics, "pantry"),
}),
}
}
fn normalise_array_section(
doc: &mut toml_edit::DocumentMut,
section: &str,
path: &Utf8Path,
) -> Vec<Diagnostic> {
let converted = edit::normalise_array_section(doc, section);
if converted.is_empty() {
return Vec::new();
}
vec![Diagnostic::warning(format!(
"section '{section}' was written as a list of names, which cannot hold quantities; \
rewritten as a [{section}] section keeping {}",
converted.join(", ")
))
.at_file(path.to_owned())]
}
fn read_document(path: &Utf8Path) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
path: path.to_owned(),
source,
})?;
parse_document(path, &text)
}
fn read_document_or_empty(
path: &Utf8Path,
) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
match std::fs::read_to_string(path) {
Ok(text) => parse_document(path, &text),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
Ok((toml_edit::DocumentMut::new(), Vec::new()))
}
Err(source) => Err(CoreError::Io {
path: path.to_owned(),
source,
}),
}
}
fn parse_document(
path: &Utf8Path,
text: &str,
) -> Result<(toml_edit::DocumentMut, Vec<Diagnostic>), CoreError> {
let (_, diagnostics) = parse_conf(path, text)?;
Ok((edit::parse(text, path)?, diagnostics))
}
fn save(
path: &Utf8Path,
doc: &toml_edit::DocumentMut,
diagnostics: Vec<Diagnostic>,
) -> Result<Outcome<PantryContents>, CoreError> {
let text = doc.to_string();
write_atomically(path, &text)?;
let (conf, _) = parse_conf(path, &text)?;
Ok(Outcome::with_diagnostics(
PantryContents::from_conf(&conf),
diagnostics,
))
}
fn recipe_name(entry: &RecipeEntry) -> String {
entry
.name()
.clone()
.unwrap_or_else(|| "unknown".to_string())
}
static QUANTITY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$").expect("the quantity pattern is valid")
});
fn unit_of(quantity: &str) -> Option<String> {
QUANTITY
.captures(quantity)
.map(|captures| captures[2].to_lowercase())
}
fn units_match(quantity: &str, low_threshold: &str) -> bool {
match (unit_of(quantity), unit_of(low_threshold)) {
(Some(quantity), Some(threshold)) => quantity == threshold,
_ => false,
}
}
fn is_low_quantity(quantity: &str) -> bool {
let Some(captures) = QUANTITY.captures(quantity) else {
return false;
};
let Ok(amount) = captures[1].parse::<f64>() else {
return false;
};
match captures[2].to_lowercase().as_str() {
"g" | "ml" => amount <= 100.0,
"kg" | "l" => amount < 0.5,
_ => amount <= 1.0,
}
}
const DATE_FORMATS: [&str; 6] = [
"%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
];
fn parse_date(date: &str) -> Option<NaiveDate> {
DATE_FORMATS
.iter()
.find_map(|format| NaiveDate::parse_from_str(date, format).ok())
}
mod edit;
#[cfg(test)]
mod tests;