Skip to content

pantry: a top-level item with attributes is parsed as a section #102

Description

@dubadub

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions