A top-level pantry item written with attributes is read as a section, not as an item. Its attribute names become item names and their values become quantities. No warning is raised.
Reproduction
let conf = cooklang::pantry::parse_lenient(
r#"salt = { quantity = "1%kg", expire = "2027-01-01" }"#
).output().unwrap().clone();
|
|
| Expected |
section general, one item salt — quantity 1%kg, expire 2027-01-01 |
| Actual |
section salt, two items — quantity = 1%kg, expire = 2027-01-01 |
The same text one line lower, under a header, reads correctly:
[larder]
salt = { quantity = "1%kg", expire = "2027-01-01" }
# -> [larder] -> [("salt", quantity "1%kg", expire "2027-01-01")] ✔
So the item shape is understood; only its position changes the meaning. parse_lenient's report is empty in the broken case — nothing is warned about.
Cause
parse walks toml::Value and treats every top-level Value::Table as a section table:
https://github.com/cooklang/cooklang-rs/blob/main/src/pantry.rs#L356-L358
toml::Value::Table(section_table) => {
// This is a section table like [freezer]
The toml crate's Value has one Table variant, so [fridge] and salt = { … } are indistinguishable by the time the walk sees them. A top-level string is already special-cased into general just above; an inline table has no such case and falls through to the section arm.
Why it is worth fixing rather than documenting
salt = { quantity = "1%kg" } is not an odd thing to write. It is exactly what to_toml_string emits for any item with attributes, so a file can arrive in this shape by round-tripping through this crate rather than by being hand-written:
- write a
general item with attributes -> salt = { … } at top level
- read it back -> a section named
salt
The two halves of this module disagree about the same text.
Suggested direction
toml_edit keeps the distinction the toml crate erases, and this crate already depends on it (Cargo.toml:31), so no new dependency is needed:
match item {
Item::Table(_) => // [header] -> a section
Item::Value(v) if v.as_inline_table().is_some() => // key = {..} -> an item
Item::Value(v) if v.as_str().is_some() => // key = ".." -> an item
}
Verified against toml_edit 0.22:
salt inline table key = { .. } -> an ITEM
fridge header table [key] -> a SECTION
Failing that, a warning from parse_lenient when a top-level table's keys are all known attribute names would at least make the misreading visible.
Note on the writer
to_toml_string writes general items in the short name = "quantity" form only, so a general item's bought, expire and low cannot currently be written at all. If the parse is fixed, that restriction can be lifted too — the two are the same ambiguity seen from either end.
Context
Found in CookCLI, where this made cook pantry add/remove/update destroy an unrelated top-level item on any edit: the file was re-serialised from the parsed model, so salt was written back out as [salt]. Fixed downstream in cooklang/cookcli#444 by editing the TOML document in place instead of rebuilding it, so CookCLI no longer corrupts the file — but it still cannot display such an item correctly, and it now refuses --expire on a top-level item rather than write a shape that would read back wrong. Both of those are downstream workarounds for this parse.
Original report: cooklang/cookcli#429
A top-level pantry item written with attributes is read as a section, not as an item. Its attribute names become item names and their values become quantities. No warning is raised.
Reproduction
general, one itemsalt— quantity1%kg, expire2027-01-01salt, two items —quantity=1%kg,expire=2027-01-01The same text one line lower, under a header, reads correctly:
So the item shape is understood; only its position changes the meaning.
parse_lenient's report is empty in the broken case — nothing is warned about.Cause
parsewalkstoml::Valueand treats every top-levelValue::Tableas a section table:https://github.com/cooklang/cooklang-rs/blob/main/src/pantry.rs#L356-L358
The
tomlcrate'sValuehas oneTablevariant, so[fridge]andsalt = { … }are indistinguishable by the time the walk sees them. A top-level string is already special-cased intogeneraljust above; an inline table has no such case and falls through to the section arm.Why it is worth fixing rather than documenting
salt = { quantity = "1%kg" }is not an odd thing to write. It is exactly whatto_toml_stringemits for any item with attributes, so a file can arrive in this shape by round-tripping through this crate rather than by being hand-written:generalitem with attributes ->salt = { … }at top levelsaltThe two halves of this module disagree about the same text.
Suggested direction
toml_editkeeps the distinction thetomlcrate erases, and this crate already depends on it (Cargo.toml:31), so no new dependency is needed:Verified against
toml_edit0.22:Failing that, a warning from
parse_lenientwhen a top-level table's keys are all known attribute names would at least make the misreading visible.Note on the writer
to_toml_stringwritesgeneralitems in the shortname = "quantity"form only, so ageneralitem'sbought,expireandlowcannot currently be written at all. If the parse is fixed, that restriction can be lifted too — the two are the same ambiguity seen from either end.Context
Found in CookCLI, where this made
cook pantry add/remove/updatedestroy an unrelated top-level item on any edit: the file was re-serialised from the parsed model, sosaltwas written back out as[salt]. Fixed downstream in cooklang/cookcli#444 by editing the TOML document in place instead of rebuilding it, so CookCLI no longer corrupts the file — but it still cannot display such an item correctly, and it now refuses--expireon a top-level item rather than write a shape that would read back wrong. Both of those are downstream workarounds for this parse.Original report: cooklang/cookcli#429