use anyhow::{Context, Result};
use chrono::prelude::*;
use clap::{Args, Subcommand, ValueEnum};
use cooklang::pantry::PantryItem;
use cooklang_find::build_tree;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use crate::{util::parse_recipe_from_entry, Context as AppContext};
fn load_pantry_conf(path: &camino::Utf8PathBuf) -> Result<cooklang::pantry::PantryConf> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read pantry file at {path}"))?;
let result = cooklang::pantry::parse_lenient(&content);
result.output().cloned().ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse pantry configuration: {:?}",
result.report()
)
})
}
fn write_pantry_conf(
path: &camino::Utf8PathBuf,
conf: &cooklang::pantry::PantryConf,
) -> Result<()> {
let content = cooklang::pantry::to_toml_string(conf);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {parent}"))?;
}
std::fs::write(path, content).with_context(|| format!("Failed to write pantry file at {path}"))
}
fn pantry_path_or_default(ctx: &AppContext) -> camino::Utf8PathBuf {
ctx.pantry()
.unwrap_or_else(|| ctx.base_path().join("config").join("pantry.conf"))
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Human,
Json,
Yaml,
}
#[derive(Debug, Args)]
pub struct PantryArgs {
#[arg(short = 'b', long, value_name = "PATH")]
pub base_path: Option<camino::Utf8PathBuf>,
#[arg(short = 'f', long, value_enum, default_value = "human")]
pub format: OutputFormat,
#[command(subcommand)]
pub command: PantryCommand,
}
#[derive(Debug, Subcommand)]
pub enum PantryCommand {
#[command(alias = "d")]
Depleted(DepletedArgs),
#[command(alias = "e")]
Expiring(ExpiringArgs),
#[command(alias = "r")]
Recipes(RecipesArgs),
#[command(alias = "pl")]
Plan(PlanArgs),
#[command(alias = "ls")]
List(ListArgs),
#[command(alias = "a")]
Add(AddArgs),
#[command(alias = "rm")]
Remove(RemoveArgs),
#[command(alias = "up")]
Update(UpdateArgs),
}
#[derive(Debug, Args)]
pub struct DepletedArgs {
#[arg(long)]
pub all: bool,
}
#[derive(Debug, Args)]
pub struct ExpiringArgs {
#[arg(short = 'd', long, default_value = "7")]
pub days: u32,
#[arg(long)]
pub include_unknown: bool,
}
#[derive(Debug, Args)]
pub struct RecipesArgs {
#[arg(short = 'p', long)]
pub partial: bool,
#[arg(long, default_value = "75")]
pub threshold: u8,
}
#[derive(Debug, Args)]
pub struct PlanArgs {
#[arg(short = 'n', long)]
pub max_ingredients: Option<usize>,
#[arg(short = 's', long, default_value = "0")]
pub skip: usize,
#[arg(short = 'm', long, default_value = "0")]
pub allow_missing: usize,
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub section: Option<String>,
}
#[derive(Debug, Args)]
pub struct AddArgs {
pub section: String,
pub name: String,
#[arg(long)]
pub quantity: Option<String>,
#[arg(long)]
pub bought: Option<String>,
#[arg(long)]
pub expire: Option<String>,
#[arg(long)]
pub low: Option<String>,
}
#[derive(Debug, Args)]
pub struct RemoveArgs {
pub section: String,
pub name: String,
}
#[derive(Debug, Args)]
pub struct UpdateArgs {
pub section: String,
pub name: String,
#[arg(long)]
pub quantity: Option<String>,
#[arg(long)]
pub bought: Option<String>,
#[arg(long)]
pub expire: Option<String>,
#[arg(long)]
pub low: Option<String>,
}
#[derive(Debug, Serialize)]
struct ListSection {
name: String,
items: Vec<ListItem>,
}
#[derive(Debug, Serialize)]
struct ListItem {
name: String,
quantity: Option<String>,
bought: Option<String>,
expire: Option<String>,
low: Option<String>,
}
#[derive(Debug, Serialize)]
struct ListOutput {
sections: Vec<ListSection>,
}
#[derive(Debug, Serialize)]
struct DepletedOutput {
items: Vec<DepletedItem>,
}
#[derive(Debug, Serialize)]
struct DepletedItem {
name: String,
section: String,
quantity: Option<String>,
low_threshold: Option<String>,
is_low: bool,
}
#[derive(Debug, Serialize)]
struct ExpiringOutput {
items: Vec<ExpiringItem>,
}
#[derive(Debug, Serialize)]
struct ExpiringItem {
name: String,
section: String,
expire_date: Option<String>,
days_until_expiry: Option<i64>,
status: String,
}
#[derive(Debug, Serialize)]
struct RecipesOutput {
full_matches: Vec<String>,
partial_matches: Vec<PartialMatch>,
}
#[derive(Debug, Serialize)]
struct PartialMatch {
recipe: String,
percentage: usize,
missing_ingredients: Vec<String>,
}
#[derive(Debug, Serialize)]
struct PlanOutput {
total_recipes: usize,
cookable_recipes: usize,
coverage_percentage: usize,
ingredients: Vec<IngredientStep>,
}
#[derive(Debug, Serialize)]
struct IngredientStep {
name: String,
new_recipes_unlocked: usize,
total_cookable: usize,
}
pub fn run(ctx: &AppContext, args: PantryArgs) -> Result<()> {
let new_ctx;
let ctx = if let Some(base_path) = args.base_path {
let absolute_base_path = crate::util::resolve_to_absolute_path(&base_path)?;
new_ctx = AppContext::new(absolute_base_path);
&new_ctx
} else {
ctx
};
let format = args.format;
match args.command {
PantryCommand::Depleted(depleted_args) => run_depleted(ctx, depleted_args, format),
PantryCommand::Expiring(expiring_args) => run_expiring(ctx, expiring_args, format),
PantryCommand::Recipes(recipes_args) => run_recipes(ctx, recipes_args, format),
PantryCommand::Plan(plan_args) => run_plan(ctx, plan_args, format),
PantryCommand::List(list_args) => run_list(ctx, list_args, format),
PantryCommand::Add(add_args) => run_add(ctx, add_args),
PantryCommand::Remove(remove_args) => run_remove(ctx, remove_args),
PantryCommand::Update(update_args) => run_update(ctx, update_args),
}
}
fn run_depleted(ctx: &AppContext, args: DepletedArgs, format: OutputFormat) -> Result<()> {
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let content = std::fs::read_to_string(&pantry_path)
.with_context(|| format!("Failed to read pantry file at {pantry_path}"))?;
let result = cooklang::pantry::parse_lenient(&content);
let pantry_conf = result.output().ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse pantry configuration: {:?}",
result.report()
)
})?;
let mut depleted_items = Vec::new();
for (section, items) in &pantry_conf.sections {
for item in items {
let is_low = item.is_low();
let should_show = if is_low {
true
} else {
match item {
PantryItem::Simple(_) => args.all,
PantryItem::WithAttributes(attrs) => {
if let Some(low) = &attrs.low {
if let Some(quantity) = &attrs.quantity {
if units_match(quantity, low) {
args.all
} else {
is_low_quantity(quantity)
}
} else {
args.all
}
} else {
if let Some(quantity) = &attrs.quantity {
is_low_quantity(quantity)
} else {
args.all
}
}
}
}
};
if should_show {
let quantity = item.quantity().map(|q| q.to_string());
let low_threshold = item.low().map(|l| l.to_string());
depleted_items.push(DepletedItem {
name: item.name().to_string(),
section: section.clone(),
quantity,
low_threshold,
is_low,
});
}
}
}
match format {
OutputFormat::Human => {
if depleted_items.is_empty() {
println!("No depleted items found!");
} else {
println!("Depleted or Low Stock Items:");
println!("============================");
let mut current_section = String::new();
for item in &depleted_items {
if item.section != current_section {
println!("\n{}:", item.section.to_uppercase());
current_section = item.section.clone();
}
print!(" • {}", item.name);
if let Some(ref qty) = item.quantity {
print!(" ({qty})");
}
if let Some(ref low) = item.low_threshold {
print!(" [low when < {low}]");
}
println!();
}
}
}
OutputFormat::Json => {
let output = DepletedOutput {
items: depleted_items,
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Yaml => {
let output = DepletedOutput {
items: depleted_items,
};
println!("{}", serde_yaml::to_string(&output)?);
}
}
Ok(())
}
fn run_expiring(ctx: &AppContext, args: ExpiringArgs, format: OutputFormat) -> Result<()> {
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let content = std::fs::read_to_string(&pantry_path)
.with_context(|| format!("Failed to read pantry file at {pantry_path}"))?;
let result = cooklang::pantry::parse_lenient(&content);
let pantry_conf = result.output().ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse pantry configuration: {:?}",
result.report()
)
})?;
let today = Local::now().date_naive();
let threshold_date = today + chrono::Duration::days(args.days as i64);
let mut expiring_list = Vec::new();
for (section, items) in &pantry_conf.sections {
for item in items {
let expire_date = item.expire().and_then(parse_date);
if let Some(date) = expire_date {
if date <= threshold_date {
let days_until = (date - today).num_days();
let status = if days_until < 0 {
format!("EXPIRED {} days ago", -days_until)
} else if days_until == 0 {
"EXPIRES TODAY".to_string()
} else if days_until == 1 {
"expires tomorrow".to_string()
} else {
format!("expires in {days_until} days")
};
expiring_list.push(ExpiringItem {
name: item.name().to_string(),
section: section.clone(),
expire_date: Some(date.format("%Y-%m-%d").to_string()),
days_until_expiry: Some(days_until),
status,
});
}
} else if args.include_unknown {
expiring_list.push(ExpiringItem {
name: item.name().to_string(),
section: section.clone(),
expire_date: None,
days_until_expiry: None,
status: "No expiry date".to_string(),
});
}
}
}
expiring_list.sort_by_key(|item| item.days_until_expiry.unwrap_or(i64::MAX));
match format {
OutputFormat::Human => {
println!("Items Expiring Within {} Days:", args.days);
println!("================================");
let with_dates: Vec<_> = expiring_list
.iter()
.filter(|i| i.expire_date.is_some())
.collect();
let without_dates: Vec<_> = expiring_list
.iter()
.filter(|i| i.expire_date.is_none())
.collect();
if !with_dates.is_empty() {
println!("\nExpiring Soon:");
for item in &with_dates {
println!(
" • {} - {} ({}) [{}]",
item.name,
item.expire_date.as_ref().unwrap(),
item.status,
item.section
);
}
}
if !without_dates.is_empty() {
println!("\nNo Expiry Date Set:");
for item in &without_dates {
println!(" • {} [{}]", item.name, item.section);
}
}
if expiring_list.is_empty() {
println!("\nNo expiring items found!");
}
}
OutputFormat::Json => {
let output = ExpiringOutput {
items: expiring_list,
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Yaml => {
let output = ExpiringOutput {
items: expiring_list,
};
println!("{}", serde_yaml::to_string(&output)?);
}
}
Ok(())
}
fn run_recipes(ctx: &AppContext, args: RecipesArgs, format: OutputFormat) -> Result<()> {
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let content = std::fs::read_to_string(&pantry_path)
.with_context(|| format!("Failed to read pantry file at {pantry_path}"))?;
let result = cooklang::pantry::parse_lenient(&content);
let pantry_conf = result.output().ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse pantry configuration: {:?}",
result.report()
)
})?;
let mut pantry_ingredients = HashSet::new();
for items in pantry_conf.sections.values() {
for item in items {
pantry_ingredients.insert(item.name().to_lowercase());
}
}
let tree = build_tree(ctx.base_path()).context("Failed to build recipe tree")?;
let mut full_matches = Vec::new();
let mut partial_matches_raw = Vec::new();
fn process_tree(
tree: &cooklang_find::RecipeTree,
pantry_ingredients: &HashSet<String>,
full_matches: &mut Vec<String>,
partial_matches: &mut Vec<(String, usize, Vec<String>)>,
args: &RecipesArgs,
) {
if let Some(entry) = &tree.recipe {
if let Ok(recipe) = parse_recipe_from_entry(entry, 1.0) {
let mut recipe_ingredients = HashSet::new();
for ingredient in &recipe.ingredients {
if ingredient.reference.is_some() {
continue;
}
if ingredient.modifiers().should_be_listed() {
recipe_ingredients
.insert(ingredient.display_name().to_string().to_lowercase());
}
}
if !recipe_ingredients.is_empty() {
let available_count = recipe_ingredients
.iter()
.filter(|ing| pantry_ingredients.contains(*ing))
.count();
let total_count = recipe_ingredients.len();
let percentage = (available_count * 100) / total_count;
let recipe_name = entry.name().as_deref().unwrap_or("unknown").to_string();
if available_count == total_count {
full_matches.push(recipe_name);
} else if args.partial && percentage >= args.threshold as usize {
let missing: Vec<_> = recipe_ingredients
.iter()
.filter(|ing| !pantry_ingredients.contains(*ing))
.cloned()
.collect();
partial_matches.push((recipe_name, percentage, missing));
}
}
}
}
for subtree in tree.children.values() {
process_tree(
subtree,
pantry_ingredients,
full_matches,
partial_matches,
args,
);
}
}
process_tree(
&tree,
&pantry_ingredients,
&mut full_matches,
&mut partial_matches_raw,
&args,
);
match format {
OutputFormat::Human => {
println!("Recipes You Can Make with Pantry Items:");
println!("========================================");
if !full_matches.is_empty() {
println!("\n✓ Complete Matches (all ingredients available):");
for recipe in &full_matches {
println!(" • {recipe}");
}
}
if !partial_matches_raw.is_empty() {
println!(
"\nâš Partial Matches ({}%+ ingredients available):",
args.threshold
);
for (recipe, percentage, missing) in &partial_matches_raw {
println!(" • {recipe} ({percentage}% available)");
println!(" Missing: {}", missing.join(", "));
}
}
if full_matches.is_empty() && partial_matches_raw.is_empty() {
if args.partial {
println!(
"\nNo recipes found with at least {}% of ingredients available.",
args.threshold
);
} else {
println!("\nNo recipes found with all ingredients available.");
println!("Tip: Use --partial to see recipes you can mostly make.");
}
}
}
OutputFormat::Json => {
let partial_matches: Vec<PartialMatch> = partial_matches_raw
.into_iter()
.map(|(recipe, percentage, missing)| PartialMatch {
recipe,
percentage,
missing_ingredients: missing,
})
.collect();
let output = RecipesOutput {
full_matches,
partial_matches,
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Yaml => {
let partial_matches: Vec<PartialMatch> = partial_matches_raw
.into_iter()
.map(|(recipe, percentage, missing)| PartialMatch {
recipe,
percentage,
missing_ingredients: missing,
})
.collect();
let output = RecipesOutput {
full_matches,
partial_matches,
};
println!("{}", serde_yaml::to_string(&output)?);
}
}
Ok(())
}
fn run_plan(ctx: &AppContext, args: PlanArgs, format: OutputFormat) -> Result<()> {
let tree = build_tree(ctx.base_path()).context("Failed to build recipe tree")?;
struct RecipeInfo {
ingredients: HashSet<String>,
}
let mut recipes: Vec<RecipeInfo> = Vec::new();
fn process_tree(tree: &cooklang_find::RecipeTree, recipes: &mut Vec<RecipeInfo>) {
if let Some(entry) = &tree.recipe {
if entry.is_menu() {
return;
}
if let Ok(recipe) = parse_recipe_from_entry(entry, 1.0) {
let mut recipe_ingredients = HashSet::new();
for ingredient in &recipe.ingredients {
if ingredient.reference.is_some() {
continue;
}
if ingredient.modifiers().should_be_listed() {
let name = ingredient.display_name().to_string();
recipe_ingredients.insert(name);
}
}
if !recipe_ingredients.is_empty() {
recipes.push(RecipeInfo {
ingredients: recipe_ingredients,
});
}
}
}
for subtree in tree.children.values() {
process_tree(subtree, recipes);
}
}
process_tree(&tree, &mut recipes);
if recipes.is_empty() {
match format {
OutputFormat::Human => println!("No recipes found in collection."),
OutputFormat::Json => {
let output = PlanOutput {
total_recipes: 0,
cookable_recipes: 0,
coverage_percentage: 0,
ingredients: vec![],
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Yaml => {
let output = PlanOutput {
total_recipes: 0,
cookable_recipes: 0,
coverage_percentage: 0,
ingredients: vec![],
};
println!("{}", serde_yaml::to_string(&output)?);
}
}
return Ok(());
}
let mut recipe_missing: Vec<HashSet<String>> =
recipes.iter().map(|r| r.ingredients.clone()).collect();
let mut selected_ingredients: Vec<IngredientStep> = Vec::new();
let mut cookable_count = 0;
let total_recipes = recipes.len();
let max_ingredients = args.max_ingredients.unwrap_or(usize::MAX);
while cookable_count < total_recipes && selected_ingredients.len() < max_ingredients {
let mut ingredient_scores: HashMap<String, usize> = HashMap::new();
for missing_set in &recipe_missing {
for ingredient in missing_set {
*ingredient_scores.entry(ingredient.clone()).or_insert(0) += 1;
}
}
if ingredient_scores.is_empty() {
break; }
let best_ingredient = ingredient_scores
.iter()
.max_by_key(|(_, &count)| count)
.map(|(ing, _)| ing.clone())
.unwrap();
let mut new_recipe_missing = Vec::new();
let mut newly_cookable = 0;
for mut missing_set in recipe_missing {
missing_set.remove(&best_ingredient);
if missing_set.len() <= args.allow_missing {
newly_cookable += 1;
} else {
new_recipe_missing.push(missing_set);
}
}
recipe_missing = new_recipe_missing;
cookable_count += newly_cookable;
selected_ingredients.push(IngredientStep {
name: best_ingredient,
new_recipes_unlocked: newly_cookable,
total_cookable: cookable_count,
});
}
let coverage_percentage = (cookable_count * 100) / total_recipes.max(1);
match format {
OutputFormat::Human => {
println!("Optimal Pantry Plan (Greedy Coverage):");
println!("=======================================");
if args.allow_missing > 0 {
println!(
"Note: Allowing recipes with up to {} missing ingredient{}",
args.allow_missing,
if args.allow_missing == 1 { "" } else { "s" }
);
}
println!();
if args.skip > 0 && args.skip < selected_ingredients.len() {
let skipped_coverage = selected_ingredients[args.skip - 1].total_cookable;
let skipped_pct = (skipped_coverage * 100) / total_recipes.max(1);
println!("Already have (first {} ingredients):", args.skip);
println!(
" → Can cook {} out of {} recipes ({}% coverage)",
skipped_coverage, total_recipes, skipped_pct
);
println!();
println!("Recommended additions:");
println!();
for (i, step) in selected_ingredients.iter().enumerate().skip(args.skip) {
let new_str = if step.new_recipes_unlocked == 1 {
"recipe"
} else {
"recipes"
};
println!(
"{:3}. {:<40} (+{} {}, {} total)",
i + 1,
step.name,
step.new_recipes_unlocked,
new_str,
step.total_cookable
);
}
} else {
println!(
"With these {} ingredients, you can cook {} out of {} recipes:",
selected_ingredients.len(),
cookable_count,
total_recipes
);
println!();
for (i, step) in selected_ingredients.iter().enumerate() {
let new_str = if step.new_recipes_unlocked == 1 {
"recipe"
} else {
"recipes"
};
println!(
"{:3}. {:<40} (+{} {}, {} total)",
i + 1,
step.name,
step.new_recipes_unlocked,
new_str,
step.total_cookable
);
}
}
println!();
println!("Final coverage: {}% of recipes", coverage_percentage);
}
OutputFormat::Json => {
let output = PlanOutput {
total_recipes,
cookable_recipes: cookable_count,
coverage_percentage,
ingredients: selected_ingredients,
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Yaml => {
let output = PlanOutput {
total_recipes,
cookable_recipes: cookable_count,
coverage_percentage,
ingredients: selected_ingredients,
};
println!("{}", serde_yaml::to_string(&output)?);
}
}
Ok(())
}
fn run_list(ctx: &AppContext, args: ListArgs, format: OutputFormat) -> Result<()> {
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let pantry_conf = load_pantry_conf(&pantry_path)?;
let sections: Vec<ListSection> = pantry_conf
.sections
.iter()
.filter(|(name, _)| {
args.section
.as_ref()
.map(|s| s.eq_ignore_ascii_case(name))
.unwrap_or(true)
})
.map(|(name, items)| ListSection {
name: name.clone(),
items: items
.iter()
.map(|item| ListItem {
name: item.name().to_string(),
quantity: item.quantity().map(|q| q.to_string()),
bought: item.bought().map(|b| b.to_string()),
expire: item.expire().map(|e| e.to_string()),
low: item.low().map(|l| l.to_string()),
})
.collect(),
})
.collect();
if let Some(ref filter) = args.section {
if sections.is_empty() {
anyhow::bail!("Section '{}' not found in pantry", filter);
}
}
match format {
OutputFormat::Human => {
if sections.is_empty() {
println!("Pantry is empty.");
return Ok(());
}
println!("Pantry Items:");
println!("=============");
for section in §ions {
println!("\n{}:", section.name.to_uppercase());
for item in §ion.items {
print!(" • {}", item.name);
if let Some(ref qty) = item.quantity {
print!(" - {qty}");
}
let mut extras = Vec::new();
if let Some(ref expire) = item.expire {
extras.push(format!("expires: {expire}"));
}
if let Some(ref low) = item.low {
extras.push(format!("low: {low}"));
}
if let Some(ref bought) = item.bought {
extras.push(format!("bought: {bought}"));
}
if !extras.is_empty() {
print!(" ({})", extras.join(", "));
}
println!();
}
}
}
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&ListOutput { sections })?
);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&ListOutput { sections })?);
}
}
Ok(())
}
fn run_add(ctx: &AppContext, args: AddArgs) -> Result<()> {
let pantry_path = pantry_path_or_default(ctx);
let mut pantry_conf = if pantry_path.exists() {
load_pantry_conf(&pantry_path)?
} else {
cooklang::pantry::PantryConf::default()
};
let new_item = if args.quantity.is_some()
|| args.bought.is_some()
|| args.expire.is_some()
|| args.low.is_some()
{
PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
name: args.name.clone(),
quantity: args.quantity,
bought: args.bought,
expire: args.expire,
low: args.low,
})
} else {
PantryItem::Simple(args.name.clone())
};
let section_items = pantry_conf
.sections
.entry(args.section.clone())
.or_default();
if section_items.iter().any(|i| i.name() == args.name) {
anyhow::bail!(
"Item '{}' already exists in section '{}'. Use `pantry update` to change it.",
args.name,
args.section
);
}
section_items.push(new_item);
pantry_conf.rebuild_index();
write_pantry_conf(&pantry_path, &pantry_conf)?;
println!("Added '{}' to section '{}'.", args.name, args.section);
Ok(())
}
fn run_remove(ctx: &AppContext, args: RemoveArgs) -> Result<()> {
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let mut pantry_conf = load_pantry_conf(&pantry_path)?;
let items = pantry_conf
.sections
.get_mut(&args.section)
.ok_or_else(|| anyhow::anyhow!("Section '{}' not found", args.section))?;
let before = items.len();
items.retain(|i| i.name() != args.name);
if items.len() == before {
anyhow::bail!(
"Item '{}' not found in section '{}'",
args.name,
args.section
);
}
if items.is_empty() {
pantry_conf.sections.shift_remove(&args.section);
}
pantry_conf.rebuild_index();
write_pantry_conf(&pantry_path, &pantry_conf)?;
println!("Removed '{}' from section '{}'.", args.name, args.section);
Ok(())
}
fn run_update(ctx: &AppContext, args: UpdateArgs) -> Result<()> {
if args.quantity.is_none()
&& args.bought.is_none()
&& args.expire.is_none()
&& args.low.is_none()
{
anyhow::bail!("No attributes specified. Provide at least one of --quantity, --bought, --expire, --low.");
}
let pantry_path = ctx
.pantry()
.ok_or_else(|| anyhow::anyhow!("No pantry configuration found"))?;
let mut pantry_conf = load_pantry_conf(&pantry_path)?;
let items = pantry_conf
.sections
.get_mut(&args.section)
.ok_or_else(|| anyhow::anyhow!("Section '{}' not found", args.section))?;
let item = items
.iter_mut()
.find(|i| i.name() == args.name)
.ok_or_else(|| {
anyhow::anyhow!(
"Item '{}' not found in section '{}'",
args.name,
args.section
)
})?;
let updated = match item {
PantryItem::Simple(name) => {
PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
name: name.clone(),
quantity: args.quantity,
bought: args.bought,
expire: args.expire,
low: args.low,
})
}
PantryItem::WithAttributes(attrs) => {
PantryItem::WithAttributes(cooklang::pantry::ItemWithAttributes {
name: attrs.name.clone(),
quantity: args.quantity.or_else(|| attrs.quantity.clone()),
bought: args.bought.or_else(|| attrs.bought.clone()),
expire: args.expire.or_else(|| attrs.expire.clone()),
low: args.low.or_else(|| attrs.low.clone()),
})
}
};
*item = updated;
pantry_conf.rebuild_index();
write_pantry_conf(&pantry_path, &pantry_conf)?;
println!("Updated '{}' in section '{}'.", args.name, args.section);
Ok(())
}
fn units_match(quantity: &str, low_threshold: &str) -> bool {
let parse_unit = |s: &str| -> Option<String> {
regex::Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$")
.ok()
.and_then(|re| re.captures(s))
.map(|captures| {
captures
.get(2)
.map(|m| m.as_str().to_lowercase())
.unwrap_or_default()
})
};
match (parse_unit(quantity), parse_unit(low_threshold)) {
(Some(q_unit), Some(l_unit)) => q_unit == l_unit,
_ => false,
}
}
fn is_low_quantity(quantity: &str) -> bool {
if let Some(captures) = regex::Regex::new(r"^(\d+(?:\.\d+)?)\s*%?\s*(.*)$")
.ok()
.and_then(|re| re.captures(quantity))
{
if let Ok(amount) = captures.get(1).unwrap().as_str().parse::<f64>() {
let unit = captures.get(2).map(|m| m.as_str()).unwrap_or("");
match unit.to_lowercase().as_str() {
"g" | "ml" => amount <= 100.0,
"kg" | "l" => amount < 0.5,
"" | "item" | "items" => amount <= 1.0,
_ => amount <= 1.0,
}
} else {
false
}
} else {
false
}
}
fn parse_date(date_str: &str) -> Option<NaiveDate> {
let formats = [
"%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%m/%d/%Y", "%Y.%m.%d", "%d-%m-%Y",
];
for format in &formats {
if let Ok(date) = NaiveDate::parse_from_str(date_str, format) {
return Some(date);
}
}
None
}