Skip to content

Commit d12d03c

Browse files
committed
fix: use IndexMap and toml_edit for pantry serialization
- Switch PantryConf.sections from BTreeMap to IndexMap to preserve file order through parse-modify-write cycles. - Enable toml's preserve_order feature so item order within sections is preserved too. - Replace toml::to_string_pretty with toml_edit-based serialization in the public to_toml_string() / write() functions. This properly handles non-ASCII keys, uses inline tables for item attributes, and correctly serializes general (top-level) items.
1 parent dc0e3d3 commit d12d03c

3 files changed

Lines changed: 77 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ unicase = "2.7.0"
2727
indexmap = { version = "2", features = ["serde"] }
2828
yansi = "1.0.1"
2929
serde_yaml = "0.9.34"
30-
toml = { version = "0.8", optional = true }
30+
toml = { version = "0.8", optional = true, features = ["preserve_order"] }
31+
toml_edit = { version = "0.22", optional = true }
3132
tsify = { version = "0.5", optional = true }
3233
wasm-bindgen = { version = "0.2", optional = true }
3334

@@ -50,7 +51,7 @@ default = ["aisle", "bundled_units", "shopping_list"]
5051
bundled_units = ["toml", "prettyplease", "quote", "syn", "proc-macro2"]
5152
aisle = []
5253
shopping_list = []
53-
pantry = ["toml"]
54+
pantry = ["toml", "toml_edit"]
5455
ts = ["wasm-bindgen", "tsify"]
5556

5657
[[bench]]

src/pantry.rs

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use std::{
2424
collections::{BTreeMap, HashMap},
2525
};
2626

27+
use indexmap::IndexMap;
28+
2729
use serde::{Deserialize, Serialize};
2830
use thiserror::Error;
2931

@@ -40,9 +42,9 @@ use crate::{
4042
/// format.
4143
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
4244
pub struct PantryConf {
43-
/// Map of sections to their items (BTreeMap for consistent ordering)
45+
/// Map of sections to their items (IndexMap to preserve file order)
4446
#[serde(flatten)]
45-
pub sections: BTreeMap<String, Vec<PantryItem>>,
47+
pub sections: IndexMap<String, Vec<PantryItem>>,
4648

4749
/// Index for fast ingredient lookups (lowercase name -> (section, index))
4850
/// Using BTreeMap for better cache locality and predictable iteration
@@ -327,7 +329,7 @@ fn parse_core(
327329
message: "Expected TOML table at root".to_string(),
328330
})?;
329331

330-
let mut sections = BTreeMap::new();
332+
let mut sections = IndexMap::new();
331333
let mut general_items = Vec::new(); // For top-level items
332334

333335
for (section_name, section_value) in toml_table {
@@ -656,11 +658,74 @@ pub fn parse_lenient(input: &str) -> PassResult<PantryConf> {
656658
}
657659
}
658660

661+
/// Serialize a [`PantryConf`] to a TOML string.
662+
///
663+
/// Uses `toml_edit` so that keys are properly escaped (including non-ASCII
664+
/// characters) and item attributes are written as inline tables.
665+
pub fn to_toml_string(conf: &PantryConf) -> String {
666+
let mut doc = toml_edit::DocumentMut::new();
667+
668+
// General items go at the top level as plain strings (the parser treats
669+
// top-level inline tables as section headers, not items).
670+
if let Some(general_items) = conf.sections.get("general") {
671+
for item in general_items {
672+
doc[item.name()] = general_item_to_toml_item(item);
673+
}
674+
}
675+
676+
for (section_name, items) in &conf.sections {
677+
if section_name == "general" {
678+
continue;
679+
}
680+
let mut table = toml_edit::Table::new();
681+
for item in items {
682+
table[item.name()] = item_to_toml_item(item);
683+
}
684+
doc[section_name] = toml_edit::Item::Table(table);
685+
}
686+
687+
doc.to_string()
688+
}
689+
659690
/// Write a [`PantryConf`] in TOML format
660691
pub fn write(conf: &PantryConf, mut write: impl std::io::Write) -> std::io::Result<()> {
661-
let toml_string = toml::to_string_pretty(conf)
662-
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
663-
write.write_all(toml_string.as_bytes())
692+
write.write_all(to_toml_string(conf).as_bytes())
693+
}
694+
695+
/// Convert a pantry item to an inline-table `toml_edit::Item` for use
696+
/// inside a `[section]`.
697+
fn item_to_toml_item(item: &PantryItem) -> toml_edit::Item {
698+
match item {
699+
PantryItem::Simple(_) => toml_edit::value(toml_edit::InlineTable::new()),
700+
PantryItem::WithAttributes(attrs) => {
701+
let mut table = toml_edit::InlineTable::new();
702+
if let Some(qty) = &attrs.quantity {
703+
table.insert("quantity", qty.as_str().into());
704+
}
705+
if let Some(bought) = &attrs.bought {
706+
table.insert("bought", bought.as_str().into());
707+
}
708+
if let Some(expire) = &attrs.expire {
709+
table.insert("expire", expire.as_str().into());
710+
}
711+
if let Some(low) = &attrs.low {
712+
table.insert("low", low.as_str().into());
713+
}
714+
toml_edit::value(table)
715+
}
716+
}
717+
}
718+
719+
/// Convert a pantry item to a plain-string `toml_edit::Item` for use at the
720+
/// top level ("general" section). The parser only supports `key = "string"`
721+
/// at the top level.
722+
fn general_item_to_toml_item(item: &PantryItem) -> toml_edit::Item {
723+
match item {
724+
PantryItem::Simple(_) => toml_edit::value(""),
725+
PantryItem::WithAttributes(attrs) => {
726+
toml_edit::value(attrs.quantity.as_deref().unwrap_or(""))
727+
}
728+
}
664729
}
665730

666731
/// Error generated by [`parse`].
@@ -987,7 +1052,7 @@ rice = "5%kg"
9871052
#[test]
9881053
fn test_index_performance() {
9891054
// Create a large pantry to test performance
990-
let mut sections = BTreeMap::new();
1055+
let mut sections = IndexMap::new();
9911056

9921057
// Add 100 sections with 100 items each = 10,000 items
9931058
for section_num in 0..100 {

0 commit comments

Comments
 (0)