Skip to content

feat: shopping list checked file support and comment syntax update#93

Merged
dubadub merged 7 commits intomainfrom
feat/shopping-list-checked-support
Apr 12, 2026
Merged

feat: shopping list checked file support and comment syntax update#93
dubadub merged 7 commits intomainfrom
feat/shopping-list-checked-support

Conversation

@dubadub
Copy link
Copy Markdown
Member

@dubadub dubadub commented Apr 10, 2026

Summary

  • Add .shopping-checked append-only log support per proposal 0016: parse/write + name (checked) and - name (unchecked) entries with case-insensitive last-entry-wins semantics
  • Add compaction logic to prune stale checked entries against the current shopping list
  • Change comment syntax from // to -- for line/inline comments, add [- ... -] block comment support
  • New public API: CheckEntry, parse_checked(), checked_set(), write_check_entry(), compact_checked()

Test plan

  • 7 new tests for checked file parsing, set computation, compaction, and roundtrip
  • 2 new tests for block comments (multi-line and inline)
  • Existing comment tests updated for -- syntax
  • All 29 tests passing

dubadub added 3 commits April 10, 2026 19:59
… lists

Implements `.shopping-checked` append-only log support per proposal 0016:
- Parse/write `+ name` (checked) and `- name` (unchecked) entries
- Case-insensitive matching with last-entry-wins semantics
- Compaction against current shopping list to remove stale entries
- Change comment syntax from `//` to `--` for line/inline comments
- Add `[- ... -]` block comment support
Exposes the shopping_list parse/write APIs and the checked-log state
tracking to iOS and Android via UniFFI, so mobile apps can use the
same .shopping-list and .shopping-checked file formats as CookCLI.
dubadub added a commit to cooklang/cookcli that referenced this pull request Apr 12, 2026
- Add per-recipe reference checkboxes so users control which sub-recipes
  get included when adding to the shopping list (all checked by default).
- Add menus as a single plan entry with nested recipes (and their
  sub-refs as grandchildren) instead of one top-level entry per recipe.
- Expose name as a derived, read-only field: recipe_display_name(path)
  is computed on load and on add, so any client-supplied name is no
  longer silently discarded.
- Fix migration bypass: add(), add_menu(), and remove() now call
  migrate_if_needed() before touching .shopping-list, so the legacy
  .shopping_list.txt can't be orphaned by a write before the first load.
- Fix compact() race: acquire an exclusive advisory file lock (fs2) for
  the read-modify-write so concurrent check()/uncheck() appends can't
  be lost. check()/uncheck() also lock while appending.
- Ignore .shopping-list, .shopping-checked, and .shopping_list.txt.bak
  in .gitignore.
- Flag the ../cooklang-rs path dependency with a TODO referencing
  cooklang/cooklang-rs#93 until that PR is released.
- Refresh recipe snapshot tests for the new upstream parser output
  (full unit names, scalable flag).
@dubadub
Copy link
Copy Markdown
Member Author

dubadub commented Apr 12, 2026

@claude review

dubadub added a commit to cooklang/cookcli that referenced this pull request Apr 12, 2026
The `path = "../cooklang-rs"` dep only works locally. Point at the
upstream branch feat/shopping-list-checked-support until
cooklang/cooklang-rs#93 is merged and released.
dubadub added 3 commits April 12, 2026 17:48
- Return ShoppingListError::Parse for unterminated block comments instead
  of silently swallowing everything after `[-`.
- Replace stripped block comments with a single space so adjacent tokens
  don't get glued together (`foo[-x-]bar` -> `foo bar`).
- Normalize whitespace in ingredient/recipe names so the double-space
  artifact from block-comment stripping no longer leaks through.
- Clarify `checked_set()` doc comment: the returned set contains
  lowercased names only.
- Derive Serialize/Deserialize on CheckEntry to match the rest of the
  public types in this module.
- Document parse_checked()'s malformed-line-skipping behavior.
- Simplify the compact_checked() sort closure.
- Sort shopping_checked_set's Vec<String> for deterministic FFI output.
- Fix typo in bindings/Cargo.toml keywords ("unuffi" -> "uniffi").
- Backtick `IndexMap`, `BTreeMap`, `IngredientList` in doc comments.
- Replace `or_insert_with(Vec::new)` with `or_default()` in pantry index.
- Use `contains_key` instead of `get(...).is_some()` in aisle tests.
- Explicit `'_` lifetime on `Vec<Event<'_>>` in parser test helpers.
- Normalize `PantryItem::WithAttributes` with no attributes back to
  `Simple` on parse so write→parse roundtrips preserve the variant.
Previously `compact_checked(entries, &ShoppingList)` walked the list
looking for `Ingredient` items. That works only if the caller's list
is fully expanded — callers that persist shopping lists as recipe
*references* (the on-disk `.shopping-list` format, CookCLI, UniFFI
clients) always got an empty ingredient set, so every compaction
silently dropped every checked entry.

Change the contract to be honest: callers pass an iterable of the
ingredient names the user currently sees. That shifts the expansion
responsibility back to the layer that has parser context. Makes
`collect_ingredient_names` public for callers whose lists happen to
contain `Ingredient` items directly. FFI wrapper takes `&[String]`.
@cooklang cooklang deleted a comment from pkg-pr-new Bot Apr 12, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 12, 2026

commit: 93fb44d

@cooklang cooklang deleted a comment from claude Bot Apr 12, 2026
@dubadub
Copy link
Copy Markdown
Member Author

dubadub commented Apr 12, 2026

@claude review

@claude
Copy link
Copy Markdown

claude Bot commented Apr 12, 2026

Claude finished @dubadub's task in 3m 56s —— View job


Code Review

  • Gather context and read changed files
  • Review src/shopping_list.rs changes
  • Review bindings/src/shopping_list.rs and bindings/src/lib.rs
  • Review parser changes (src/parser/step.rs, src/parser/text_block.rs)
  • Review other changes (src/aisle.rs, src/ingredient_list.rs, src/pantry.rs)
  • Compile and summarize review feedback

Overall this is a well-designed feature addition. The API surface is clean, tests are thorough, and the docstrings clearly communicate the contracts (especially the compact_checked iterator-based API). A few issues worth addressing before merging:

Breaking change: //-- comment syntax

src/shopping_list.rs:117

The old delimiter // is replaced with -- silently — any user with an existing .shopping-list file using // comments will have those comments parsed as ingredient names without any error or warning. This should be clearly documented in the changelog/release notes. A migration hint in the parse error (or at least a CHANGELOG entry) would help users.

Bug: Block comments inside -- line comments cause spurious errors

src/shopping_list.rs:144strip_block_comments runs before collect_lines, so it processes the entire input before line comments are stripped. A [- that appears after a -- on the same line is parsed as a block comment opener:

salt -- [- this looks commented out
pepper

This would error with "unterminated block comment" even though the [- is logically inside a -- line comment. The fix is to either process both comment types in a single pass, or strip line comments first and then scan for block comments. Fix this →

compact_checked loses original casing

src/shopping_list.rs:422-446

checked_set() lowercases all names into its HashMap, so the output of compact_checked always contains lowercase names — even when the user originally wrote + Olive Oil. This is partially documented but the doc says "preserve the lowercased form", which might surprise callers that want to re-display items. Consider either preserving the last-seen casing alongside the bool state in checked_set, or making the casing-loss more prominent in the docs.

// Currently state stores only bool, losing original casing:
let mut state: HashMap<String, bool> = HashMap::new();
// Could store the original name:
let mut state: HashMap<String, (bool, String)> = HashMap::new();

write_check_entry does not validate names

src/shopping_list.rs:402-407

A name containing \n (or \r\n) would produce multiple log lines and break parsing on the next read. A cheap guard would help:

pub fn write_check_entry(entry: &CheckEntry, mut w: impl std::io::Write) -> std::io::Result<()> {
    match entry {
        CheckEntry::Checked(name) => {
            debug_assert!(!name.contains('\n'), "name must not contain newlines");
            writeln!(w, "+ {name}")
        }
        CheckEntry::Unchecked(name) => {
            debug_assert!(!name.contains('\n'), "name must not contain newlines");
            writeln!(w, "- {name}")
        }
    }
}

Or return an Err for invalid names (though that changes the signature).

Minor: parse_checked_impl in bindings — needless .iter() with immediate deref

bindings/src/shopping_list.rs:144-149

// Current — collects into a Vec, then iterates by reference:
sl::parse_checked(input)
    .iter()
    .map(CheckEntry::from)  // From<&OriginalCheckEntry>
    .collect()

// Cleaner — consumes directly:
sl::parse_checked(input)
    .into_iter()
    .map(|e| CheckEntry::from(&e))
    .collect()

Not a bug, just a style nit.

Minor: collect_ingredient_names docstring is slightly misleading

src/shopping_list.rs:449-457

The function does recurse into recipe children to find Ingredient items. The doc "Lists that only store recipe references will produce an empty result" is technically correct (if all children are more recipe references with no ingredient items), but on a first read it sounds like the function doesn't recurse, which it does. Worth a small clarification.


Positives

  • The compact_checked<'a, I> generic-iterator design is excellent — makes the contract about expanded vs. on-disk ingredients explicit and avoids tying callers to ShoppingList.
  • The strip_block_comments space-replacement strategy (rather than empty-string removal) to prevent token gluing is a thoughtful edge case.
  • Pantry round-trip fix (WithAttributes-with-no-attributes → Simple) is a good correctness improvement.
  • Test coverage is solid: last-wins semantics, case-insensitivity, compaction stale-removal, and write roundtrip are all tested.
  • UniFFI binding layer cleanly separates wrapper types from core types and the conversions are symmetric.

@dubadub dubadub merged commit aa0ea37 into main Apr 12, 2026
7 checks passed
@dubadub dubadub deleted the feat/shopping-list-checked-support branch April 12, 2026 19:24
dubadub added a commit to cooklang/cookcli that referenced this pull request Apr 12, 2026
0.18.5 landed on crates.io with cooklang/cooklang-rs#93 merged,
including the name-based `compact_checked` API plus the typed
`ShoppingListError` follow-up. Repoint to the stable version and
drop the branch-based git dep — also collapses the duplicate
cooklang copies in the workspace (cooklang-find, cooklang-reports,
cooklang-language-server all resolve 0.18.5 now), addressing the
review concern about multiple cooklang versions compiling.
dubadub added a commit to cooklang/cookcli that referenced this pull request Apr 13, 2026
* feat: migrate shopping list to new format with server-side checked state

Replaces the old tab-delimited `.shopping_list.txt` with the new
`.shopping-list` format from proposal 0016 and adds `.shopping-checked`
for persistent ingredient check state:

- Rewrite ShoppingListStore to use cooklang-rs shopping_list parser
- Auto-migrate from legacy format on first load (renames old file to .bak)
- Add check/uncheck/checked/compact API endpoints
- Replace localStorage-based checked state with server-side persistence
- Compact checked log on recipe removal to prune stale entries

* feat: recipe refs, menu plans, and PR review fixes

- Add per-recipe reference checkboxes so users control which sub-recipes
  get included when adding to the shopping list (all checked by default).
- Add menus as a single plan entry with nested recipes (and their
  sub-refs as grandchildren) instead of one top-level entry per recipe.
- Expose name as a derived, read-only field: recipe_display_name(path)
  is computed on load and on add, so any client-supplied name is no
  longer silently discarded.
- Fix migration bypass: add(), add_menu(), and remove() now call
  migrate_if_needed() before touching .shopping-list, so the legacy
  .shopping_list.txt can't be orphaned by a write before the first load.
- Fix compact() race: acquire an exclusive advisory file lock (fs2) for
  the read-modify-write so concurrent check()/uncheck() appends can't
  be lost. check()/uncheck() also lock while appending.
- Ignore .shopping-list, .shopping-checked, and .shopping_list.txt.bak
  in .gitignore.
- Flag the ../cooklang-rs path dependency with a TODO referencing
  cooklang/cooklang-rs#93 until that PR is released.
- Refresh recipe snapshot tests for the new upstream parser output
  (full unit names, scalable flag).

* fix(ci): use git dependency for cooklang so CI can resolve it

The `path = "../cooklang-rs"` dep only works locally. Point at the
upstream branch feat/shopping-list-checked-support until
cooklang/cooklang-rs#93 is merged and released.

* fix(shopping-list): stop wiping checked log on recipe removal

`store.remove()` was calling `compact()`, which delegated to
`cooklang::shopping_list::compact_checked(entries, &ShoppingList)`.
That upstream helper only kept entries whose names appeared as
`Ingredient` items in the list — but our on-disk `.shopping-list`
persists recipe *references* only. Every checked entry was therefore
considered stale and dropped, so removing any recipe cleared the
whole checked file.

Pair with the cooklang-rs contract change: `compact_checked` now
accepts the user-visible ingredient names directly. Introduce
`aggregate_current_ingredient_names()` which walks the stored list
and expands each reference via `extract_ingredients` (honoring
`included_references` and per-recipe scale). Both the `remove` and
explicit `compact` endpoints feed those names into `store.compact()`.
Remove failures in aggregation degrade to a warn log so a recipe
removal can't fail on a render-time parse error.

Also indent nested sub-recipes under their parent recipe card so
Pizza Dough visibly nests under Neapolitan Pizza (border-l accent).

* fix(shopping-list): address PR review — XSS, atomic compact, error handling

Security (high):
- Escape every untrusted interpolation (recipe names, paths, ref names,
  ingredient names, category names) in shopping_list.html. A recipe file
  called `<img src=x onerror=alert(1)>.cook` is a legal filename and
  previously executed script via `innerHTML`.
- Move inline `onclick="removeRecipe('${path}')"` and
  `onchange="toggleItem('${itemId}', ..., '${name}')"` to delegated
  handlers keyed off `data-*` attributes. Script-attribute context no
  longer receives user-controlled data at all, so a single-quote break
  in a path/name can't escape.

Correctness:
- `aggregate_current_ingredient_names` now propagates parse errors via
  `with_context(..)?` instead of `let _ =`. On remove, the handler still
  treats compact as best-effort — aggregation failure warns and skips
  compaction, so a broken recipe can never wipe the checked log.
- `compact()` now stages to `.shopping-checked.tmp` and renames into
  place. A crash between truncate and write previously left the file
  zero-length; rename is atomic on POSIX so the original is preserved
  until the new content is fully durable.

Refactor:
- Extract `to_multiplier(scale)` — was duplicated three times across
  `add`, `add_menu` (outer), and `add_menu` (inner).

Dependency:
- Repoint `cooklang` git dep from the now-merged
  `feat/shopping-list-checked-support` branch to `main` (picks up
  0.18.5-dev with #93 merged plus the typed `ShoppingListError` follow-
  up). To swap to a crates.io version once the next release lands.

* chore(deps): switch cooklang to crates.io 0.18.5

0.18.5 landed on crates.io with cooklang/cooklang-rs#93 merged,
including the name-based `compact_checked` API plus the typed
`ShoppingListError` follow-up. Repoint to the stable version and
drop the branch-based git dep — also collapses the duplicate
cooklang copies in the workspace (cooklang-find, cooklang-reports,
cooklang-language-server all resolve 0.18.5 now), addressing the
review concern about multiple cooklang versions compiling.

* fix(shopping-list): serialize checked-log writes with in-process mutex

File-level flock doesn't prevent two Axum tasks in the same process from
racing on `.shopping-checked` — the kernel treats them as one lock owner,
so a concurrent compact could still truncate between an append's open and
write. Replace flock with a `tokio::sync::Mutex<()>` on AppState held by
every check/uncheck/compact handler (including the post-remove compact).

Drops the fs2 dependency and simplifies `compact()` to a plain read +
atomic temp-file rename.

Also refreshes 5 snapshot fixtures to match cooklang 0.18.5 output.

* fix(shopping-list): address PR review — clear race, dup recipes, URL encoding

- `clear_shopping_list` now acquires `checked_log_lock` so a concurrent
  check/uncheck can't recreate `.shopping-checked` between our remove_file
  and the caller's view of a cleared list.

- `aggregate_current_ingredient_names` now resets `seen` per top-level
  shopping-list entry. The list may legitimately contain the same recipe
  multiple times (e.g. duplicates from legacy format); sharing one `seen`
  across all entries misreported the second occurrence as a circular
  dependency, skipped compaction, and let stale checks accumulate.

- Replaced `encodeURI(path)` in shopping_list.html with a segment-wise
  `encodeRecipePath` helper that uses `encodeURIComponent` per segment.
  `encodeURI` leaves `#`, `?`, `&`, `=` alone (they're valid URI
  delimiters), which breaks recipe filenames that contain them.

- `compact()` now removes the `.shopping-checked.tmp` file on write or
  rename failure so crashes don't leave dangling temp files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant