Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 164 additions & 5 deletions crates/core/src/pantry/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ pub(super) fn remove(doc: &mut DocumentMut, section: &str, name: &str) {
}
}

/// The root key other than `except` that `name` would collide with, compared
/// ignoring case: a section, or an item above the first `[header]`.
///
/// Ignoring case because `Dairy` next to `dairy` reads as one section to anyone
/// looking at the page, while the parser would keep them apart.
pub(super) fn root_key_like(doc: &DocumentMut, name: &str, except: &str) -> Option<String> {
doc.as_table()
.iter()
.map(|(key, _)| key)
.find(|key| *key != except && key.to_lowercase() == name.to_lowercase())
.map(str::to_string)
}

/// Rename `section` to `new_name`, keeping where it is in the file.
///
/// The root is rebuilt in its own order with only that key changed, so a
/// section written inline (`fridge = { … }`) keeps its place among the
/// top-level entries; a `[header]` table carries its own position, and its
/// decor — the comment above the header — moves with it. The caller has
/// checked that `section` exists and that `new_name` is free.
pub(super) fn rename_section(doc: &mut DocumentMut, section: &str, new_name: &str) {
let root = doc.as_table_mut();
let keys: Vec<String> = root.iter().map(|(key, _)| key.to_string()).collect();
let entries: Vec<(toml_edit::Key, Item)> = keys
.iter()
.filter_map(|key| root.remove_entry(key))
.collect();

for (key, item) in entries {
let key = if key.get() == section {
toml_edit::Key::new(new_name)
.with_leaf_decor(key.leaf_decor().clone())
.with_dotted_decor(key.dotted_decor().clone())
} else {
key
};
root.insert_formatted(&key, item);
}
}

/// Apply `attributes` to the item already at `section`/`name`.
///
/// Only the attributes that are set are written. Everything else the entry
Expand Down Expand Up @@ -231,16 +271,23 @@ pub(super) fn apply(
&& attributes.bought.is_none()
&& attributes.expire.is_none()
&& attributes.low.is_none();
// The spacing and comment around the value — `milk = "1%l" # semi-skimmed`
// — belong to the line, not to the amount, so they stay.
let decor = existing.as_value().map(|value| value.decor().clone());

if stays_short {
if let Some(quantity) = &attributes.quantity {
*existing = toml_edit::value(quantity.as_str());
}
return Ok(());
} else {
let mut table = as_inline_table(existing, section, name)?;
attributes.write_into(&mut table);
*existing = toml_edit::value(table);
}

let mut table = as_inline_table(existing, section, name)?;
attributes.write_into(&mut table);
*existing = toml_edit::value(table);
if let (Some(decor), Some(value)) = (decor, existing.as_value_mut()) {
*value.decor_mut() = decor;
}
Ok(())
}

Expand Down Expand Up @@ -332,8 +379,24 @@ impl Attributes {
("expire", &self.expire),
("low", &self.low),
] {
if let Some(value) = value {
let Some(value) = value else { continue };
if table.contains_key(key) {
table.insert(key, value.as_str().into());
continue;
}
// A key added after the last one takes over the space that
// closed the table, `{ quantity = "1%l" }` becoming
// `{ quantity = "1%l", expire = "…" }` and not `"1%l" , expire`.
let closing = table.iter_mut().last().and_then(|(_, last)| {
let suffix = last.decor().suffix()?.as_str()?.to_string();
suffix.trim().is_empty().then(|| {
last.decor_mut().set_suffix("");
suffix
})
});
table.insert(key, value.as_str().into());
if let (Some(closing), Some(added)) = (closing, table.get_mut(key)) {
added.decor_mut().set_suffix(closing);
}
}
}
Expand Down Expand Up @@ -419,8 +482,51 @@ mod tests {
assert!(out.contains("shelf = \"top\""), "{out}");
}

/// An attribute added to an item written inline is spaced like the ones
/// already there, whichever way the file spaces them.
#[test]
fn an_added_attribute_is_spaced_like_its_neighbours() {
let expire = Attributes {
expire: Some("2026-11-02".to_string()),
..Default::default()
};
let mut d = doc("[fridge]\nmilk = { quantity = \"2%l\" }\ncream = {quantity=\"1\"}\n");
apply(&mut d, "fridge", "milk", &expire).expect("applies");
apply(&mut d, "fridge", "cream", &expire).expect("applies");

let out = d.to_string();
assert!(
out.contains("milk = { quantity = \"2%l\", expire = \"2026-11-02\" }"),
"{out}"
);
assert!(
out.contains("cream = {quantity=\"1\", expire = \"2026-11-02\"}"),
"{out}"
);
}

/// Changing only the quantity of a short-form item leaves it short form,
/// rather than expanding it to `{ quantity = "..." }`.
#[test]
fn a_comment_after_an_item_survives_an_update() {
let mut d = doc("[fridge]\nmilk = \"1%l\" # semi-skimmed\neggs = \"6\" # free range\n");
apply(&mut d, "fridge", "milk", &quantity("2%l")).unwrap();
apply(
&mut d,
"fridge",
"eggs",
&Attributes {
expire: Some("2027-01-01".to_string()),
..Default::default()
},
)
.unwrap();
assert_eq!(
d.to_string(),
"[fridge]\nmilk = \"2%l\" # semi-skimmed\neggs = { quantity = \"6\", expire = \"2027-01-01\" } # free range\n"
);
}

#[test]
fn a_short_form_item_stays_short_when_only_the_quantity_changes() {
let mut d = doc("[fridge]\nmilk = \"1%l\"\n");
Expand Down Expand Up @@ -520,4 +626,57 @@ mod tests {
insert(&mut d, "fridge", "milk", &Attributes::default());
assert!(d.to_string().contains("milk = \"\""), "{d}");
}

/// A rename touches the header and nothing else: the section stays where
/// it was, its comment stays above it, and its items keep their forms.
#[test]
fn a_renamed_section_keeps_its_place_comments_and_items() {
let mut d = doc("salt = \"1%kg\"\n\
\n\
# cold things\n\
[fridge]\n\
milk = \"1%l\" # semi-skimmed\n\
eggs = { quantity = \"6\", expire = \"2027-01-01\" }\n\
\n\
[freezer]\n\
peas = \"500%g\"\n");
rename_section(&mut d, "fridge", "Fridge door");

assert_eq!(
d.to_string(),
"salt = \"1%kg\"\n\
\n\
# cold things\n\
[\"Fridge door\"]\n\
milk = \"1%l\" # semi-skimmed\n\
eggs = { quantity = \"6\", expire = \"2027-01-01\" }\n\
\n\
[freezer]\n\
peas = \"500%g\"\n"
);
}

/// A section written inline among the top-level entries keeps its place
/// among them rather than moving to the end.
#[test]
fn an_inline_section_keeps_its_place_among_top_level_entries() {
let mut d = doc("fridge = { milk = \"1%l\" }\nshelf = { rice = \"1%kg\" }\n");
rename_section(&mut d, "fridge", "cold");
assert_eq!(
d.to_string(),
"cold = { milk = \"1%l\" }\nshelf = { rice = \"1%kg\" }\n"
);
}

#[test]
fn a_name_in_use_is_found_ignoring_case_but_not_the_section_itself() {
let d = doc("salt = \"1%kg\"\n[fridge]\nmilk = \"1%l\"\n[freezer]\npeas = \"1\"\n");
assert_eq!(
root_key_like(&d, "FREEZER", "fridge").as_deref(),
Some("freezer")
);
assert_eq!(root_key_like(&d, "Salt", "fridge").as_deref(), Some("salt"));
assert_eq!(root_key_like(&d, "Fridge", "fridge"), None);
assert_eq!(root_key_like(&d, "pantry", "fridge"), None);
}
}
104 changes: 103 additions & 1 deletion crates/core/src/pantry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
//! [`plan`] is the odd one out: it answers "what should I stock?" by looking at
//! the recipe collection alone, and never reads the pantry at all.
//!
//! [`add`], [`remove`] and [`update`] change the pantry and write it back.
//! [`add`], [`remove`], [`update`] and [`rename_section`] change the pantry and
//! write it back.
//! They are the only functions in this crate that write to a file the user
//! owns, so read [`write_atomically`] and **[what a write
//! touches](#what-a-write-touches)** before calling them.
Expand Down Expand Up @@ -986,6 +987,107 @@ pub fn update(ctx: &Context, req: UpdateRequest) -> Result<Outcome<PantryContent
save(&path, &doc, diagnostics)
}

/// Which section to rename, and what to.
///
/// Not `#[non_exhaustive]`: consumers construct this.
#[derive(Debug, Clone, Default)]
pub struct RenameSectionRequest {
/// The section to rename, matched exactly.
pub section: String,
/// Its new name, written exactly as given once trimmed.
pub new_name: String,
}

/// Give a section a new name and write the pantry back.
///
/// The section keeps its place in the file, its items, and the comments around
/// it; only its name changes. Changing nothing but the case of its own name is
/// allowed.
///
/// Returns the pantry as it now stands on disk, and any warnings from parsing
/// what was there before.
///
/// # Errors
///
/// - [`CoreError::PantryEdit`] if the new name is blank or the same as the old
/// one; if either name is `general`, which is not a `[header]` but the
/// entries above the first one; if there is no such section; or if another
/// section — or an item above the first header — already goes by the new
/// name, ignoring case. Nothing is written.
/// - [`CoreError::MissingConfig`] if the context carries no pantry, and
/// [`CoreError::ReadOnlyConfig`] if it carries one inline.
/// - As [`load`] otherwise, plus [`CoreError::Io`] if the file cannot be
/// written.
pub fn rename_section(
ctx: &Context,
req: RenameSectionRequest,
) -> Result<Outcome<PantryContents>, CoreError> {
let edit_error = |message: String| CoreError::PantryEdit { message };
let section = req.section;
let new_name = req.new_name.trim().to_string();

if new_name.is_empty() {
return Err(edit_error("the section name cannot be empty".to_string()));
}
if new_name == section {
return Err(edit_error(format!(
"section '{section}' is already called that"
)));
}
if [section.as_str(), new_name.as_str()].contains(&edit::GENERAL) {
return Err(edit_error(format!(
"'{}' is the name for the items above the first section header and cannot be renamed",
edit::GENERAL
)));
}

let path = path_to_edit(ctx)?;
let (mut doc, mut diagnostics) = read_document(&path)?;

diagnostics.extend(normalise_array_section(&mut doc, &section, &path));
if !edit::section_exists(&doc, &section) {
return Err(section_not_found(&section));
}
if let Some(taken) = edit::root_key_like(&doc, &new_name, &section) {
return Err(edit_error(format!(
"there is already a section or item called '{taken}'"
)));
}

edit::rename_section(&mut doc, &section, &new_name);

save(&path, &doc, diagnostics)
}

/// Replace the whole pantry file with `text`, as someone editing it by hand
/// would, once it reads as a pantry.
///
/// `text` is written exactly as given — this is the one write that touches
/// everything, because everything is what was asked for. It is checked the way
/// every edit reads a file first, so a pantry the other functions here could not
/// edit afterwards is never written, and it is written the same way they write,
/// atomically and through a symlink.
///
/// Returns the pantry `text` describes, and the warnings from parsing it.
///
/// # Errors
///
/// - [`CoreError::Config`] if `text` is not a pantry, quoting the parser's
/// complaint and the line it is on. Nothing is written.
/// - [`CoreError::MissingConfig`] if the context carries no pantry, and
/// [`CoreError::ReadOnlyConfig`] if it carries one inline.
/// - [`CoreError::Io`] if the file cannot be written.
pub fn replace(ctx: &Context, text: &str) -> Result<Outcome<PantryContents>, CoreError> {
let path = path_to_edit(ctx)?;
let (_, diagnostics) = parse_document(&path, text)?;
write_atomically(&path, text)?;
let (conf, _) = parse_conf(&path, text)?;
Ok(Outcome::with_diagnostics(
PantryContents::from_conf(&conf),
diagnostics,
))
}

fn section_not_found(section: &str) -> CoreError {
CoreError::PantryEdit {
message: format!("section '{section}' not found"),
Expand Down
Loading
Loading