use crate::CoreError;
use toml_edit::{DocumentMut, InlineTable, Item, Table, Value};
pub(super) const GENERAL: &str = "general";
pub(super) fn parse(text: &str, path: &camino::Utf8Path) -> Result<DocumentMut, CoreError> {
text.parse::<DocumentMut>()
.map_err(|source| CoreError::Config {
path: Some(path.to_owned()),
message: source.to_string(),
})
}
pub(super) fn section_exists(doc: &DocumentMut, section: &str) -> bool {
section == GENERAL || doc.get(section).is_some_and(Item::is_table_like)
}
pub(super) fn check_general_attributes(
section: &str,
name: &str,
attributes: &Attributes,
) -> Result<(), CoreError> {
if section != GENERAL {
return Ok(());
}
let unwritable: Vec<&str> = [
("bought", &attributes.bought),
("expire", &attributes.expire),
("low", &attributes.low),
]
.into_iter()
.filter(|(_, value)| value.is_some())
.map(|(key, _)| key)
.collect();
if unwritable.is_empty() {
return Ok(());
}
Err(CoreError::PantryEdit {
message: format!(
"item '{name}' is above the first section header, where only a quantity can be \
written; move it into a section to give it {}",
unwritable.join(", ")
),
})
}
pub(super) fn normalise_array_section(doc: &mut DocumentMut, section: &str) -> Vec<String> {
if section == GENERAL {
return Vec::new();
}
let Some(array) = doc.get(section).and_then(Item::as_array) else {
return Vec::new();
};
let names: Vec<String> = array
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
if names.len() != array.len() {
return Vec::new();
}
let mut table = Table::new();
table.set_implicit(false);
for name in &names {
table.insert(name, toml_edit::value(""));
}
doc.insert(section, Item::Table(table));
names
}
pub(super) fn item_exists(doc: &DocumentMut, section: &str, name: &str) -> bool {
match section_entries(doc, section) {
Some(entries) if section == GENERAL => {
entries.get(name).is_some_and(|item| !item.is_table_like())
}
Some(entries) => entries.get(name).is_some(),
None => false,
}
}
fn section_entries<'a>(doc: &'a DocumentMut, section: &str) -> Option<&'a Table> {
if section == GENERAL {
Some(doc.as_table())
} else {
doc.get(section).and_then(Item::as_table)
}
}
pub(super) fn insert(doc: &mut DocumentMut, section: &str, name: &str, attributes: &Attributes) {
let value = attributes.to_item(section);
if section == GENERAL {
doc.insert(name, value);
return;
}
if !doc.get(section).is_some_and(Item::is_table_like) {
let mut table = Table::new();
table.set_implicit(false);
doc.insert(section, Item::Table(table));
}
if let Some(table) = doc.get_mut(section).and_then(Item::as_table_like_mut) {
table.insert(name, value);
}
}
pub(super) fn remove(doc: &mut DocumentMut, section: &str, name: &str) {
if section == GENERAL {
doc.remove(name);
return;
}
let emptied = match doc.get_mut(section).and_then(Item::as_table_like_mut) {
Some(table) => {
table.remove(name);
table.is_empty()
}
None => false,
};
if emptied {
doc.remove(section);
}
}
pub(super) fn apply(
doc: &mut DocumentMut,
section: &str,
name: &str,
attributes: &Attributes,
) -> Result<(), CoreError> {
let existing = if section == GENERAL {
doc.as_table_mut().get_mut(name)
} else {
doc.get_mut(section)
.and_then(Item::as_table_like_mut)
.and_then(|table| table.get_mut(name))
};
let Some(existing) = existing else {
return Ok(());
};
let stays_short = existing.as_str().is_some()
&& attributes.bought.is_none()
&& attributes.expire.is_none()
&& attributes.low.is_none();
if stays_short {
if let Some(quantity) = &attributes.quantity {
*existing = toml_edit::value(quantity.as_str());
}
return Ok(());
}
let mut table = as_inline_table(existing, section, name)?;
attributes.write_into(&mut table);
*existing = toml_edit::value(table);
Ok(())
}
fn as_inline_table(item: &Item, section: &str, name: &str) -> Result<InlineTable, CoreError> {
if let Some(quantity) = item.as_str() {
let mut table = InlineTable::new();
table.insert("quantity", quantity.into());
return Ok(table);
}
if let Some(table) = item.as_inline_table() {
return Ok(table.clone());
}
if let Some(table) = item.as_table() {
return Ok(table.clone().into_inline_table());
}
Err(CoreError::PantryEdit {
message: format!(
"item '{name}' in section '{section}' is written as {}, which is not a quantity or a \
set of attributes — edit it by hand rather than have this overwrite it",
describe(item)
),
})
}
fn describe(item: &Item) -> &'static str {
match item.as_value() {
Some(Value::Integer(_)) | Some(Value::Float(_)) => "a bare number",
Some(Value::Boolean(_)) => "a boolean",
Some(Value::Array(_)) => "an array",
Some(Value::Datetime(_)) => "a date",
_ => "an unsupported value",
}
}
#[derive(Debug, Clone, Default)]
pub(super) struct Attributes {
pub quantity: Option<String>,
pub bought: Option<String>,
pub expire: Option<String>,
pub low: Option<String>,
}
impl Attributes {
pub(super) fn is_empty(&self) -> bool {
self.quantity.is_none()
&& self.bought.is_none()
&& self.expire.is_none()
&& self.low.is_none()
}
fn to_item(&self, section: &str) -> Item {
let short = section == GENERAL
|| (self.bought.is_none() && self.expire.is_none() && self.low.is_none());
if short {
return toml_edit::value(self.quantity.clone().unwrap_or_default());
}
let mut table = InlineTable::new();
self.write_into(&mut table);
toml_edit::value(table)
}
fn write_into(&self, table: &mut InlineTable) {
for (key, value) in [
("quantity", &self.quantity),
("bought", &self.bought),
("expire", &self.expire),
("low", &self.low),
] {
if let Some(value) = value {
table.insert(key, value.as_str().into());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(text: &str) -> DocumentMut {
parse(text, camino::Utf8Path::new("pantry.conf")).expect("parses")
}
fn quantity(value: &str) -> Attributes {
Attributes {
quantity: Some(value.to_string()),
..Default::default()
}
}
#[test]
fn a_top_level_item_with_attributes_is_untouched_by_an_unrelated_edit() {
let mut d = doc("# my pantry notes\n\
salt = { quantity = \"1%kg\", expire = \"2027-01-01\" }\n\
\n\
[fridge]\n\
milk = { quantity = \"1%l\", shelf = \"top\" }\n");
insert(&mut d, "fridge", "butter", &quantity("200%g"));
let out = d.to_string();
assert!(
out.contains("salt = { quantity = \"1%kg\", expire = \"2027-01-01\" }"),
"the top-level item must survive verbatim: {out}"
);
assert!(out.contains("# my pantry notes"), "comment lost: {out}");
assert!(
out.contains("shelf = \"top\""),
"unmodelled attribute lost: {out}"
);
assert!(out.contains("butter"), "the edit did not happen: {out}");
assert!(!out.contains("[salt]"), "salt became a section: {out}");
}
#[test]
fn comments_and_blank_lines_survive_a_removal() {
let mut d = doc("# top\n\
\n\
[fridge]\n\
# keep me\n\
milk = \"1%l\"\n\
butter = \"200%g\"\n");
remove(&mut d, "fridge", "butter");
let out = d.to_string();
assert!(out.contains("# top"), "{out}");
assert!(out.contains("# keep me"), "{out}");
assert!(out.contains("milk = \"1%l\""), "{out}");
assert!(!out.contains("butter"), "{out}");
}
#[test]
fn removing_the_last_item_removes_the_section() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n\n[larder]\nrice = \"1%kg\"\n");
remove(&mut d, "fridge", "milk");
let out = d.to_string();
assert!(!out.contains("[fridge]"), "{out}");
assert!(out.contains("[larder]"), "{out}");
}
#[test]
fn an_update_keeps_attributes_it_does_not_know() {
let mut d = doc("[fridge]\nmilk = { quantity = \"1%l\", shelf = \"top\" }\n");
apply(&mut d, "fridge", "milk", &quantity("2%l")).expect("applies");
let out = d.to_string();
assert!(out.contains("quantity = \"2%l\""), "{out}");
assert!(out.contains("shelf = \"top\""), "{out}");
}
#[test]
fn a_short_form_item_stays_short_when_only_the_quantity_changes() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n");
apply(&mut d, "fridge", "milk", &quantity("2%l")).expect("applies");
assert!(d.to_string().contains("milk = \"2%l\""), "{d}");
}
#[test]
fn a_short_form_item_grows_attributes_when_one_is_set() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n");
apply(
&mut d,
"fridge",
"milk",
&Attributes {
expire: Some("2027-01-01".to_string()),
..Default::default()
},
)
.expect("applies");
let out = d.to_string();
assert!(out.contains("quantity = \"1%l\""), "quantity kept: {out}");
assert!(out.contains("expire = \"2027-01-01\""), "{out}");
}
#[test]
fn an_update_refuses_a_value_it_cannot_interpret() {
let mut d = doc("[fridge]\nmilk = 3\n");
let error = apply(&mut d, "fridge", "milk", &quantity("2%l")).expect_err("refuses");
assert!(
error.to_string().contains("bare number"),
"the message should say what it found: {error}"
);
assert!(d.to_string().contains("milk = 3"), "unchanged: {d}");
}
#[test]
fn a_general_item_is_written_short_form_at_the_top() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n");
insert(
&mut d,
GENERAL,
"salt",
&Attributes {
quantity: Some("1%kg".to_string()),
expire: Some("2027-01-01".to_string()),
..Default::default()
},
);
let out = d.to_string();
assert!(out.contains("salt = \"1%kg\""), "{out}");
assert!(
out.find("salt").unwrap() < out.find("[fridge]").unwrap(),
"a top-level item must come before the first header: {out}"
);
let reparsed = cooklang::pantry::parse_lenient(&out);
let conf = reparsed.output().expect("parses");
assert!(conf.sections.contains_key(GENERAL), "{out}");
assert!(!conf.sections.contains_key("salt"), "{out}");
}
#[test]
fn a_new_section_is_created_for_a_new_item() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n");
insert(&mut d, "larder", "rice", &quantity("1%kg"));
let out = d.to_string();
assert!(out.contains("[larder]"), "{out}");
assert!(out.contains("rice = \"1%kg\""), "{out}");
}
#[test]
fn a_section_header_is_not_an_item_of_general() {
let d = doc("salt = \"1%kg\"\n\n[fridge]\nmilk = \"1%l\"\n");
assert!(item_exists(&d, GENERAL, "salt"));
assert!(
!item_exists(&d, GENERAL, "fridge"),
"a table is a section, not a top-level item"
);
assert!(item_exists(&d, "fridge", "milk"));
}
#[test]
fn an_item_with_no_attributes_is_written_with_an_empty_quantity() {
let mut d = doc("[fridge]\n");
insert(&mut d, "fridge", "milk", &Attributes::default());
assert!(d.to_string().contains("milk = \"\""), "{d}");
}
}