use std::{
borrow::Cow,
collections::{BTreeMap, HashMap},
};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{
error::{CowStr, Label, RichError, SourceDiag, SourceReport, Stage},
span::Span,
PassResult,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PantryConf {
#[serde(flatten)]
pub sections: IndexMap<String, Vec<PantryItem>>,
#[serde(skip)]
ingredient_index: BTreeMap<String, Vec<(String, usize)>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PantryItem {
Simple(String),
WithAttributes(ItemWithAttributes),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ItemWithAttributes {
#[serde(rename = "name")]
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bought: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expire: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quantity: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub low: Option<String>,
}
fn parse_quantity(quantity: &str) -> Option<(f64, String)> {
let cleaned = quantity.replace('%', " ");
let parts: Vec<&str> = cleaned.split_whitespace().collect();
if let Some(number_str) = parts.first() {
if let Ok(value) = number_str.parse::<f64>() {
let unit = if parts.len() > 1 {
parts[1].to_lowercase()
} else {
String::new() };
return Some((value, unit));
}
}
None
}
impl PantryItem {
pub fn name(&self) -> &str {
match self {
PantryItem::Simple(name) => name,
PantryItem::WithAttributes(item) => &item.name,
}
}
pub fn parsed_quantity(&self) -> Option<(f64, String)> {
self.quantity().and_then(parse_quantity)
}
pub fn bought(&self) -> Option<&str> {
match self {
PantryItem::Simple(_) => None,
PantryItem::WithAttributes(item) => item.bought.as_deref(),
}
}
pub fn expire(&self) -> Option<&str> {
match self {
PantryItem::Simple(_) => None,
PantryItem::WithAttributes(item) => item.expire.as_deref(),
}
}
pub fn quantity(&self) -> Option<&str> {
match self {
PantryItem::Simple(_) => None,
PantryItem::WithAttributes(item) => item.quantity.as_deref(),
}
}
pub fn is_low(&self) -> bool {
match self {
PantryItem::Simple(_) => false,
PantryItem::WithAttributes(item) => {
if let (Some(quantity), Some(low_threshold)) = (&item.quantity, &item.low) {
if let (
Some((current_val, current_unit)),
Some((threshold_val, threshold_unit)),
) = (parse_quantity(quantity), parse_quantity(low_threshold))
{
if current_unit == threshold_unit {
return current_val <= threshold_val;
}
}
}
false
}
}
}
pub fn low(&self) -> Option<&str> {
match self {
PantryItem::Simple(_) => None,
PantryItem::WithAttributes(item) => item.low.as_deref(),
}
}
}
impl PantryConf {
pub fn rebuild_index(&mut self) {
self.ingredient_index.clear();
for (section_name, items) in &self.sections {
for (idx, item) in items.iter().enumerate() {
let lowercase_name = item.name().to_lowercase();
self.ingredient_index
.entry(lowercase_name)
.or_insert_with(Vec::new)
.push((section_name.clone(), idx));
}
}
}
pub fn all_items(&self) -> impl Iterator<Item = &PantryItem> {
self.sections.values().flat_map(|items| items.iter())
}
pub fn section_items(&self, section: &str) -> Option<&[PantryItem]> {
self.sections.get(section).map(|v| v.as_slice())
}
pub fn items_by_section(&self) -> HashMap<&str, &str> {
let mut map = HashMap::new();
for (section, items) in &self.sections {
for item in items {
map.insert(item.name(), section.as_str());
}
}
map
}
pub fn has_ingredient(&self, ingredient_name: &str) -> bool {
let search_name = ingredient_name.to_lowercase();
self.ingredient_index.contains_key(&search_name)
}
pub fn find_ingredient(&self, ingredient_name: &str) -> Option<(&str, &PantryItem)> {
let search_name = ingredient_name.to_lowercase();
if let Some(locations) = self.ingredient_index.get(&search_name) {
if let Some((section_name, idx)) = locations.first() {
if let Some(items) = self.sections.get(section_name) {
if let Some(item) = items.get(*idx) {
return Some((section_name.as_str(), item));
}
}
}
}
None
}
pub fn find_all_ingredients(&self, ingredient_name: &str) -> Vec<(&str, &PantryItem)> {
let search_name = ingredient_name.to_lowercase();
let mut results = Vec::new();
if let Some(locations) = self.ingredient_index.get(&search_name) {
for (section_name, idx) in locations {
if let Some(items) = self.sections.get(section_name) {
if let Some(item) = items.get(*idx) {
results.push((section_name.as_str(), item));
}
}
}
}
results
}
#[cfg(feature = "aisle")]
pub fn has_recipe_ingredient(&self, ingredient: &crate::Ingredient) -> bool {
self.has_ingredient(&ingredient.name)
}
pub fn expired_items(&self, current_date: &str) -> Vec<(&str, &PantryItem)> {
let mut expired = Vec::new();
for (section, items) in &self.sections {
for item in items {
if let Some(expire_date) = item.expire() {
if expire_date < current_date {
expired.push((section.as_str(), item));
}
}
}
}
expired
}
pub fn low_stock_items(&self) -> Vec<(&str, &PantryItem)> {
let mut low_stock = Vec::new();
for (section, items) in &self.sections {
for item in items {
if item.quantity().is_some() {
low_stock.push((section.as_str(), item));
}
}
}
low_stock
}
}
fn parse_core(
input: &str,
lenient: bool,
mut report: Option<&mut SourceReport>,
) -> Result<PantryConf, PantryConfError> {
let toml_value: toml::Value = toml::from_str(input).map_err(|e| PantryConfError::Parse {
message: format!("TOML parse error: {}", e),
})?;
let toml_table = toml_value
.as_table()
.ok_or_else(|| PantryConfError::Parse {
message: "Expected TOML table at root".to_string(),
})?;
let mut sections = IndexMap::new();
let mut general_items = Vec::new();
for (section_name, section_value) in toml_table {
let mut items = Vec::new();
match section_value {
toml::Value::String(quantity) => {
general_items.push(PantryItem::WithAttributes(ItemWithAttributes {
name: section_name.clone(),
bought: None,
expire: None,
quantity: Some(quantity.clone()),
low: None,
}));
continue; }
toml::Value::Table(section_table) => {
for (item_key, item_value) in section_table {
match item_value {
toml::Value::String(quantity) => {
items.push(PantryItem::WithAttributes(ItemWithAttributes {
name: item_key.clone(),
bought: None,
expire: None,
quantity: Some(quantity.clone()),
low: None,
}));
}
toml::Value::Table(attrs) => {
let mut item_table = attrs.clone();
let bought = item_table.remove("bought").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let expire = item_table.remove("expire").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let quantity = item_table.remove("quantity").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let low = item_table.remove("low").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
if !item_table.is_empty() && lenient {
if let Some(report) = report.as_mut() {
for key in item_table.keys() {
let warning = SourceDiag::warning(
format!("Unknown field '{}' in item '{}'", key, item_key),
(Span::new(0, 0), Some("valid attributes are: bought, expire, quantity, low".into())),
Stage::Parse,
);
report.push(warning);
}
}
}
items.push(PantryItem::WithAttributes(ItemWithAttributes {
name: item_key.clone(),
bought,
expire,
quantity,
low,
}));
}
_ => {
let msg = format!(
"Invalid value type for item '{}' in section '{}'",
item_key, section_name
);
if lenient {
if let Some(report) = report.as_mut() {
let warning = SourceDiag::warning(
msg.clone(),
(Span::new(0, 0), Some("expected string or table".into())),
Stage::Parse,
);
report.push(warning);
}
} else {
return Err(PantryConfError::Parse { message: msg });
}
}
}
}
}
toml::Value::Array(array) => {
for (idx, item_value) in array.iter().enumerate() {
match item_value {
toml::Value::String(name) => {
items.push(PantryItem::Simple(name.clone()));
}
toml::Value::Table(table) => {
items.push(parse_item_from_table(
table.clone(),
section_name,
lenient,
report.as_deref_mut(),
)?);
}
_ => {
let msg = format!(
"Invalid item type at index {} in section '{}'",
idx, section_name
);
if lenient {
if let Some(report) = report.as_mut() {
let warning = SourceDiag::warning(
msg.clone(),
(Span::new(0, 0), Some("expected string or table".into())),
Stage::Parse,
);
report.push(warning);
}
} else {
return Err(PantryConfError::Parse { message: msg });
}
}
}
}
}
_ => {
let msg = format!("Invalid section type for '{}'", section_name);
if lenient {
if let Some(report) = report.as_mut() {
let warning = SourceDiag::warning(
msg.clone(),
(
Span::new(0, 0),
Some("expected string, table, or array".into()),
),
Stage::Parse,
);
report.push(warning);
}
} else {
return Err(PantryConfError::Parse { message: msg });
}
}
}
if !items.is_empty() {
sections.insert(section_name.clone(), items);
}
}
if !general_items.is_empty() {
sections.insert("general".to_string(), general_items);
}
let mut ingredient_index = BTreeMap::new();
for (section_name, items) in §ions {
for (idx, item) in items.iter().enumerate() {
let lowercase_name = item.name().to_lowercase();
ingredient_index
.entry(lowercase_name)
.or_insert_with(Vec::new)
.push((section_name.clone(), idx));
}
}
Ok(PantryConf {
sections,
ingredient_index,
})
}
fn parse_item_from_table(
mut table: toml::map::Map<String, toml::Value>,
section_name: &str,
lenient: bool,
mut report: Option<&mut SourceReport>,
) -> Result<PantryItem, PantryConfError> {
let bought = table.remove("bought").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let expire = table.remove("expire").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let quantity = table.remove("quantity").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let low = table.remove("low").and_then(|val| {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
});
let name = if let Some(val) = table.remove("name") {
if let toml::Value::String(s) = val {
Some(s)
} else {
None
}
} else {
let mut found_name = None;
let mut found_key = None;
for (key, value) in table.iter() {
if let toml::Value::String(_) = value {
found_name = Some(key.clone());
found_key = Some(key.clone());
break;
}
}
if let Some(key) = found_key {
table.remove(&key);
}
found_name
};
let name = name.ok_or_else(|| PantryConfError::Parse {
message: format!("Item in section '{}' missing name field", section_name),
})?;
if !table.is_empty() && lenient {
if let Some(report) = report.as_mut() {
for key in table.keys() {
let warning = SourceDiag::warning(
format!("Unknown field '{}' in item '{}'", key, name),
(Span::new(0, 0), Some("item should have only one name field plus optional bought, expire, quantity, low".into())),
Stage::Parse,
);
report.push(warning);
}
}
}
Ok(PantryItem::WithAttributes(ItemWithAttributes {
name,
bought,
expire,
quantity,
low,
}))
}
pub fn parse(input: &str) -> Result<PantryConf, PantryConfError> {
parse_core(input, false, None)
}
pub fn parse_lenient(input: &str) -> PassResult<PantryConf> {
let mut report = SourceReport::empty();
match parse_core(input, true, Some(&mut report)) {
Ok(conf) => PassResult::new(Some(conf), report),
Err(e) => {
let diag = SourceDiag::error(e.to_string(), (Span::new(0, 0), None), Stage::Parse);
report.push(diag);
PassResult::new(None, report)
}
}
}
pub fn to_toml_string(conf: &PantryConf) -> String {
let mut doc = toml_edit::DocumentMut::new();
if let Some(general_items) = conf.sections.get("general") {
for item in general_items {
doc[item.name()] = general_item_to_toml_item(item);
}
}
for (section_name, items) in &conf.sections {
if section_name == "general" {
continue;
}
let mut table = toml_edit::Table::new();
for item in items {
table[item.name()] = item_to_toml_item(item);
}
doc[section_name] = toml_edit::Item::Table(table);
}
doc.to_string()
}
pub fn write(conf: &PantryConf, mut write: impl std::io::Write) -> std::io::Result<()> {
write.write_all(to_toml_string(conf).as_bytes())
}
fn item_to_toml_item(item: &PantryItem) -> toml_edit::Item {
match item {
PantryItem::Simple(_) => toml_edit::value(toml_edit::InlineTable::new()),
PantryItem::WithAttributes(attrs) => {
let mut table = toml_edit::InlineTable::new();
if let Some(qty) = &attrs.quantity {
table.insert("quantity", qty.as_str().into());
}
if let Some(bought) = &attrs.bought {
table.insert("bought", bought.as_str().into());
}
if let Some(expire) = &attrs.expire {
table.insert("expire", expire.as_str().into());
}
if let Some(low) = &attrs.low {
table.insert("low", low.as_str().into());
}
toml_edit::value(table)
}
}
}
fn general_item_to_toml_item(item: &PantryItem) -> toml_edit::Item {
match item {
PantryItem::Simple(_) => toml_edit::value(""),
PantryItem::WithAttributes(attrs) => {
toml_edit::value(attrs.quantity.as_deref().unwrap_or(""))
}
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PantryConfError {
#[error("Error parsing input: {message}")]
Parse { message: String },
}
impl RichError for PantryConfError {
fn labels(&self) -> Cow<'_, [Label]> {
use crate::error::label;
match self {
PantryConfError::Parse { .. } => vec![label!(Span::new(0, 0))],
}
.into()
}
fn hints(&self) -> Cow<'_, [CowStr]> {
match self {
PantryConfError::Parse { .. } => {
vec!["Check TOML syntax".into()]
}
}
.into()
}
fn severity(&self) -> crate::error::Severity {
crate::error::Severity::Error
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_pantry() {
let input = r#"
[freezer]
cranberries = "500%g"
spinach = { bought = "05.05.2024", expire = "05.05.2025", quantity = "1%kg" }
[fridge]
milk = { expire = "10.05.2024", quantity = "1%L" }
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 2);
let freezer = &p.sections["freezer"];
assert_eq!(freezer.len(), 2);
let cranberries = freezer.iter().find(|i| i.name() == "cranberries").unwrap();
assert_eq!(cranberries.quantity(), Some("500%g"));
assert!(cranberries.bought().is_none());
assert!(cranberries.expire().is_none());
let spinach = freezer.iter().find(|i| i.name() == "spinach").unwrap();
assert_eq!(spinach.bought(), Some("05.05.2024"));
assert_eq!(spinach.expire(), Some("05.05.2025"));
assert_eq!(spinach.quantity(), Some("1%kg"));
let fridge = &p.sections["fridge"];
assert_eq!(fridge.len(), 1);
let milk = fridge.iter().find(|i| i.name() == "milk").unwrap();
assert_eq!(milk.bought(), None);
assert_eq!(milk.expire(), Some("10.05.2024"));
assert_eq!(milk.quantity(), Some("1%L"));
}
#[test]
fn string_as_quantity() {
let input = r#"
[pantry]
rice = "5%kg"
pasta = "1%kg"
flour = "2%kg"
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 1);
let pantry = &p.sections["pantry"];
assert_eq!(pantry.len(), 3);
for item in pantry {
assert!(item.quantity().is_some());
assert!(item.bought().is_none());
assert!(item.expire().is_none());
}
let rice = pantry.iter().find(|i| i.name() == "rice").unwrap();
assert_eq!(rice.quantity(), Some("5%kg"));
}
#[test]
fn simple_items() {
let input = r#"
pantry = ["rice", "pasta", "flour"]
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 1);
let pantry = &p.sections["pantry"];
assert_eq!(pantry.len(), 3);
assert_eq!(pantry[0].name(), "rice");
assert_eq!(pantry[1].name(), "pasta");
assert_eq!(pantry[2].name(), "flour");
}
#[test]
fn mixed_items() {
let input = r#"
cupboard = ["salt", { pepper = "pepper", bought = "01.01.2024" }, "sugar"]
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 1);
let cupboard = &p.sections["cupboard"];
assert_eq!(cupboard.len(), 3);
assert_eq!(cupboard[0].name(), "salt");
assert_eq!(cupboard[1].name(), "pepper");
assert_eq!(cupboard[1].bought(), Some("01.01.2024"));
assert_eq!(cupboard[2].name(), "sugar");
}
#[test]
fn empty_file() {
let p = parse("").unwrap();
assert!(p.sections.is_empty());
}
#[test]
fn empty_section() {
let input = r#"
empty = []
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 0);
}
#[test]
fn top_level_items() {
let input = r#"
paprika = "1%jar"
salt = "500%g"
[freezer]
spinach = "200%g"
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 2);
assert!(p.sections.contains_key("general"));
assert!(p.sections.contains_key("freezer"));
let general_items = &p.sections["general"];
assert_eq!(general_items.len(), 2);
let paprika = general_items
.iter()
.find(|i| i.name() == "paprika")
.unwrap();
assert_eq!(paprika.quantity(), Some("1%jar"));
let salt = general_items.iter().find(|i| i.name() == "salt").unwrap();
assert_eq!(salt.quantity(), Some("500%g"));
let freezer_items = &p.sections["freezer"];
assert_eq!(freezer_items.len(), 1);
assert_eq!(freezer_items[0].name(), "spinach");
assert_eq!(freezer_items[0].quantity(), Some("200%g"));
}
#[test]
fn optional_sections() {
let input = r#"
[freezer]
ice = "1%kg"
"#;
let p = parse(input).unwrap();
assert_eq!(p.sections.len(), 1);
assert!(p.sections.contains_key("freezer"));
assert!(!p.sections.contains_key("fridge"));
assert!(!p.sections.contains_key("pantry"));
let input2 = "";
let p2 = parse(input2).unwrap();
assert_eq!(p2.sections.len(), 0);
let input3 = r#"
[freezer]
[pantry]
rice = "5%kg"
"#;
let p3 = parse(input3).unwrap();
assert_eq!(p3.sections.len(), 1); assert!(p3.sections.contains_key("pantry"));
assert!(!p3.sections.contains_key("freezer")); }
#[test]
fn items_by_section() {
let input = r#"
freezer = ["ice cream", "frozen peas"]
fridge = ["milk", "cheese"]
"#;
let p = parse(input).unwrap();
let map = p.items_by_section();
assert_eq!(map.get("ice cream"), Some(&"freezer"));
assert_eq!(map.get("frozen peas"), Some(&"freezer"));
assert_eq!(map.get("milk"), Some(&"fridge"));
assert_eq!(map.get("cheese"), Some(&"fridge"));
}
#[test]
fn parse_lenient_with_unknown_attrs() {
let input = r#"
[freezer]
ice = { color = "white", texture = "solid" }
"#;
let result = parse_lenient(input);
let (parsed, warnings) = result.into_result().unwrap();
assert_eq!(parsed.sections.len(), 1);
assert_eq!(parsed.sections["freezer"].len(), 1);
assert_eq!(parsed.sections["freezer"][0].name(), "ice");
assert!(warnings.has_warnings());
let warning_count = warnings.iter().count();
assert_eq!(warning_count, 2); }
#[test]
fn test_has_ingredient() {
let input = r#"
[freezer]
spinach = "1%kg"
ice_cream = "2%L"
[pantry]
Rice = "5%kg"
pasta = "1%kg"
"#;
let p = parse(input).unwrap();
assert!(p.has_ingredient("spinach"));
assert!(p.has_ingredient("Spinach"));
assert!(p.has_ingredient("SPINACH"));
assert!(p.has_ingredient("rice")); assert!(p.has_ingredient("RICE"));
assert!(p.has_ingredient("pasta"));
assert!(!p.has_ingredient("chicken"));
assert!(!p.has_ingredient("milk"));
}
#[test]
fn test_find_ingredient() {
let input = r#"
[freezer]
spinach = { bought = "01.01.2024", expire = "01.02.2024", quantity = "1%kg" }
[pantry]
rice = "5%kg"
"#;
let p = parse(input).unwrap();
let result = p.find_ingredient("spinach");
assert!(result.is_some());
let (section, item) = result.unwrap();
assert_eq!(section, "freezer");
assert_eq!(item.name(), "spinach");
assert_eq!(item.quantity(), Some("1%kg"));
let result = p.find_ingredient("RICE");
assert!(result.is_some());
let (section, item) = result.unwrap();
assert_eq!(section, "pantry");
assert_eq!(item.name(), "rice");
}
#[test]
fn test_expired_items() {
let input = r#"
[fridge]
milk = { expire = "10.01.2024", quantity = "1%L" }
cheese = { expire = "20.01.2024" }
yogurt = { expire = "05.01.2024" }
[pantry]
rice = "5%kg"
"#;
let p = parse(input).unwrap();
let expired = p.expired_items("15.01.2024");
assert_eq!(expired.len(), 2);
let names: Vec<&str> = expired.iter().map(|(_, item)| item.name()).collect();
assert!(names.contains(&"milk"));
assert!(names.contains(&"yogurt"));
assert!(!names.contains(&"cheese"));
}
#[test]
fn test_index_performance() {
let mut sections = IndexMap::new();
for section_num in 0..100 {
let section_name = format!("section_{}", section_num);
let mut items = Vec::new();
for item_num in 0..100 {
items.push(PantryItem::WithAttributes(ItemWithAttributes {
name: format!("item_{}_{}", section_num, item_num),
bought: None,
expire: None,
quantity: Some("1%kg".to_string()),
low: None,
}));
}
sections.insert(section_name, items);
}
let mut ingredient_index = BTreeMap::new();
for (section_name, items) in §ions {
for (idx, item) in items.iter().enumerate() {
let lowercase_name = item.name().to_lowercase();
ingredient_index
.entry(lowercase_name)
.or_insert_with(Vec::new)
.push((section_name.clone(), idx));
}
}
let pantry = PantryConf {
sections,
ingredient_index,
};
assert!(pantry.has_ingredient("item_50_50"));
assert!(pantry.has_ingredient("ITEM_99_99")); assert!(!pantry.has_ingredient("nonexistent"));
let result = pantry.find_ingredient("item_75_25");
assert!(result.is_some());
let (section, item) = result.unwrap();
assert_eq!(section, "section_75");
assert_eq!(item.name(), "item_75_25");
}
#[test]
fn test_rebuild_index() {
let mut pantry = PantryConf::default();
pantry.sections.insert(
"test".to_string(),
vec![PantryItem::Simple("rice".to_string())],
);
assert!(!pantry.has_ingredient("rice"));
pantry.rebuild_index();
assert!(pantry.has_ingredient("rice"));
assert!(pantry.has_ingredient("RICE"));
}
#[test]
fn roundtrip() {
let input = r#"cupboard = [
"salt",
{ name = "pepper", bought = "01.01.2024" },
"sugar"
]
freezer = [
"cranberries",
{ name = "spinach", bought = "05.05.2024", expire = "05.05.2025", quantity = "1%kg" }
]
fridge = [
{ name = "milk", expire = "10.05.2024", quantity = "1%L" }
]
"#;
let p = parse(input).unwrap();
let mut buffer = Vec::new();
write(&p, &mut buffer).unwrap();
let serialized = String::from_utf8(buffer).unwrap();
let p2 = parse(&serialized).unwrap();
assert_eq!(p, p2);
}
}