Skip to content

feat(core): extract command logic into a cookcli-core library crate - #434

Open
dubadub wants to merge 45 commits into
mainfrom
feat/cookcli-core-library
Open

feat(core): extract command logic into a cookcli-core library crate#434
dubadub wants to merge 45 commits into
mainfrom
feat/cookcli-core-library

Conversation

@dubadub

@dubadub dubadub commented Aug 13, 2026

Copy link
Copy Markdown
Member

Extracts CookCLI's command logic into a new cookcli-core library crate, so the Cooklang editor (via its NAPI-RS addon) and CookBot can call it directly instead of maintaining parallel reimplementations. The CLI becomes a clap-and-print shell over it.

Design doc: docs/superpowers/specs/2026-08-10-cookcli-core-library-design.md
Plan: docs/superpowers/plans/2026-08-10-cookcli-core-extraction.md

What changed

crates/core is a new workspace member holding:

  • Six commandsrecipe::read, shopping_list::generate, search, doctor (validate, aisle, pantry, plus broken-reference resolution), pantry (all eight subcommands), report
  • The formatters — human, markdown, cooklang, latex, typst, schema, plus number formatting
  • Context with opt-in configuration discovery, replacing two divergent copies in the CLI
  • The shopping-list store, lifted out of the server feature that a library consumer compiles out

Every command has the shape:

fn(&Context, Request) -> Result<Outcome<T>, CoreError>

Two things that shape follows from:

  • Outcome<T> carries diagnostics. cooklang parses leniently, and the CLI previously logged those warnings and discarded them. They are now returned — including hints like a ready-to-apply frontmatter replacement, which is the payload an editor quick-fix needs.
  • Inputs arrive as RecipeSource / ConfigSource, so recipes and configs can be paths or in-memory text. An editor works on unsaved buffers; a path-only API cannot serve its main use case.

CoreError is a #[non_exhaustive] thiserror enum rather than anyhow, so consumers can distinguish "recipe not found" from "parse failed" from "malformed config". Core contains no anyhow, and no panic!/unwrap/exit outside tests — a panic crossing a NAPI boundary kills the editor.

Behaviour

The CLI's test suite is the contract, and it is unchanged: zero pre-existing snapshots were modified across the whole branch.

Deliberate, recorded changes:

  • A >>-titled recipe on disk now shows its declared title rather than its filename. The title rule was implemented twice against two metadata parsers, so identical bytes gave different results depending on how they arrived.
  • cook report x.cook:nan errors instead of emitting NaN quantities, matching cook recipe.
  • Error wording is capitalised at the CLI boundary via a shared cli_error; core keeps single-lowercase-line Display.

Bugs found and fixed here

Bugs found and left alone

Filed rather than fixed, because each is a product decision or out of scope: #414 (-f cooklang corrupts recipes on round-trip), #415 (all 26 ignored tests are shopping-list tests, 11 fail when run), #416, #419, #424 (mutual references silently double-count), #425, #430, #437.

Test plan

  • cargo test — 347 passed, 0 failed, 26 ignored (346 on Windows; one test is #[cfg(unix)])
  • cargo test --workspace — 676 passed
  • cargo test --no-default-features — 270 passed
  • cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings
  • cargo rustc -p cookcli-core --lib -- -W missing_docs — zero
  • cargo publish -p cookcli-core --dry-run
  • Confirm no pre-existing snapshot moved: git diff main --name-status -- tests/snapshots/ shows 14 lines, all A, all shopping_list_characterization_test__*. Use --name-status, not --stat, which truncates filenames

Release note

release-please's rust strategy manages both crates in lockstep when the root manifest declares a [workspace] — verified from its source. cookcli-core will therefore first publish at the CLI's next version, not the 0.1.0 currently in its manifest, and the release workflow now publishes core before the CLI.

Scope note

cook doctor pantry and cook doctor aisle were not in the original plan — only doctor validate was — but they were the last command logic left in the CLI, so they are included. src/doctor.rs is now clap definitions and printing only.

Still CLI-resident and deliberately out of scope: util::parse_recipe_from_entry's remaining callers in web/, server/, build/ and util/menu_scale.rs, and src/server/handlers/pantry.rs's parallel pantry CRUD, which has its own serialiser and non-atomic writes.

dubadub added 30 commits August 10, 2026 18:41
Spec 1 of 2: extract the recipe, shopping-list, search, doctor, pantry
and report commands into a cookcli-core crate with a typed API, leaving
cookcli as a clap formatting shell.

Spec 2 (consumer wiring + persistent-list CLI verbs) follows separately.
14 tasks: workspace scaffold, core types, parser, formatters, then one
command per task with the CLI reduced to a shell.

Task 6 adds characterization snapshots for shopping-list before Task 7
refactors it: all 26 ignored tests in the suite are shopping-list tests
and 11 fail when run, so the command had no regression coverage.
…n plan

build.rs hard-fails without static/css/output.css and
static/js/editor.bundle.js, which are gitignored, so a fresh worktree
cannot build until they are generated or copied.
The repo root is both the workspace root and a member package, so bare
cargo commands scope to cookcli alone. cookcli-core's tests therefore ran
nowhere in CI, which would have silently skipped every test added by the
extraction work.
Task 5: CoreError::Parse now has a single-line Display per library
convention, so the CLI must re-attach cooklang's rendered report to keep
its output unchanged.

Task 14: release.yaml runs a bare 'cargo publish', which will fail once
cookcli path-depends on cookcli-core. Publish core first, and verify
release-please's workspace handling rather than assuming it.
Address API-durability review findings ahead of Task 2, while the crate
still has no consumers and these changes are not yet breaking:

- name the span type (Span { start, end }) instead of an opaque tuple
- mark Severity, RecipeSource and ConfigSource #[non_exhaustive]
- keep CoreError Display to a single line; the parse report stays in
  the rendered field for the CLI to print
- give CoreError::Io the path it failed on, dropping #[from] so every
  call site must name it
- make CoreError::Config path optional, for inline configurations
- skip null file/span in Location JSON, pinned by roundtrip tests
- add Diagnostic::hint for symmetry with warning/error
- rename ConfigSource::is_none to is_unset
- derive PartialEq on both source enums
- document the Ok-with-error-diagnostics contract on Outcome
- warn(missing_docs) and document the whole public surface
- trim the manifest to the dependencies the crate actually uses
- add crates/core/LICENSE to match the declared license
Fresh subagents implement each task, so the conventions settled during
Task 1's review need to live in the plan rather than in one agent's
context.
CoreError::Io covers reads and writes, but its Display said "failed to
read". Task 7's shopping-list store and Task 11's pantry mutations both
write through it, and would have reported a failed write as a failed
read. Change the message to "i/o error on {path}" while ConfigSource::read
is still the only caller and the string is not snapshot-pinned.

Also make every_display_is_a_single_line live up to its name: its input
list is hand-maintained, so add a compile-time exhaustiveness guard that
breaks when a variant is added, and assert the documented lowercase half
of the contract, not just the single-line half. Both guards were verified
by temporarily introducing a variant and an uppercase message.

Extend the diagnostic roundtrip test with the empty-location case.
Task 2 adds a third copy of config resolution alongside main.rs's and
lib.rs's. Drift between those two is the bug Task 2 repairs, so the
cleanup needs to be an explicit step rather than assumed.
Tasks 2 and 3 each shipped tests that passed while asserting nothing,
both caught only in review. The failure mode is consistent: assertions
check shape rather than value.
Strengthen parser tests from shape assertions to value assertions: the
previous suite passed under severity inversion, report truncation, a wrong
scale factor, a wrong label index and an ignored ansi flag.

Also capture cooklang's `hints` (often a ready-to-apply fix the CLI's `warn!`
discarded), mark `Diagnostic`/`Location` non_exhaustive, take `&SourceReport`
in `render_report` so it serves metadata parses, re-export `cooklang`, reject
non-finite scale, and replace the `into_result` panic with an error.

Drops `Diagnostic::at_span`, which had no caller outside its own tests.
The human, markdown, cooklang, latex, typst, schema.org and number
formatters move to `cookcli-core::format`, and `cookcli` gains a
dependency on core so it can call them. Core's `PARSER` becomes the
single definition; the CLI re-exports it and the `format` module under
their old paths.

The human formatter no longer relies on yansi's global colour switch:
colour is an explicit `format::Style` argument, and `Style::Plain`
strips escapes at the writer. The CLI passes `Style::Ansi`, so its
output is unchanged.

`anyhow` is gone from the moved files — core does not depend on it —
and the formatters return `std::io::Result` instead.
src/util/format.rs held the only three tests among the moved formatter
files, and they compiled into both the lib and bin targets, so they
counted twice. Verified lib 72->69, bin 72->69, integration targets
unchanged.
Task 4 moved 2800 lines faithfully and still shipped three API problems:
a &str where an enum existed, 130 lines of untested public config frozen
into semver, and a format_number that loses the sign of negatives.
Follow-up to 04abf60, applying the review's lens: the code was moved
faithfully but not reconsidered as a public surface.

- print_latex/print_typst take PaperSize instead of &str, so a wrong
  paper name is a compile error rather than a document that will not
  typeset.
- print_human is generic over its writer again, like its six siblings;
  StripWriter became generic, which removes the reason the signature
  was &mut dyn.
- The markdown options (Options, Headings, DescriptionStyle,
  FrontMatterName, print_md_with_options) are pub(crate): ~130 lines of
  untested configuration with one in-repo caller, and deriving serde
  would pin the wire format too.
- format_number no longer mangles negatives. `-0.25` rendered as "3/4",
  losing both sign and magnitude, because floor() rounds away from zero.
- Dropped the yansi global from the tests and replaced it with direct
  StripWriter tests, including an escape split across two writes, which
  the type's doc claimed and nothing checked.
- Added a cooklang round-trip test. It found that formatting is not
  idempotent: a wrapped step accumulates one leading space per pass.
  That is a pre-existing defect and a behaviour change to fix, so it is
  recorded as an ignored test rather than fixed here.
- Removed textwrap, humantime, anstyle and anstyle-yansi from the root
  manifest; 04abf60 orphaned them.
…ction

All 26 ignored tests in the suite are shopping-list tests and 11 fail
when run, so the command had no regression coverage (#415). These
snapshots record current behaviour so the cookcli-core extraction can
be verified as behaviour-preserving. They assert what the output IS,
not what it should be.
Moves the whole of `cook shopping-list` below the argument layer into
`cookcli-core`, so the editor addon and CookBot can build the same list
without reimplementing it.

- `core::find::get_recipe` resolves a name or path, distinguishing genuine
  absence (`RecipeNotFound`) from found-but-unusable (`Io`).
- `core::shopping_list::generate` loads the aisle and pantry configuration
  from the `Context`, expands recipe references, merges duplicates, and
  subtracts the pantry, returning warnings as attributed `Diagnostic`s
  instead of logging them. `extract_ingredients` stays public for the web
  server, which accumulates one recipe at a time.
- The output builders move verbatim into `core::format::shopping_list`,
  including the missing `plain` parameter on the YAML writer (#419).
- `shopping_list::run` is now a shell: directory expansion, `name:factor`
  splitting and output writing only.

Behaviour is pinned by the 14 characterization snapshots, which are
unmoved, and by new unit tests in core.

Two defects found while re-reading the moved code as library surface:

- `cli_error` labelled every `CoreError::Io` a recipe, so an unreadable
  `config/pantry.conf` reported "Failed to read recipe". The variant
  carries a path and no file kind, so the wording is now resource-neutral.
- Relying on discovery's `is_file()` probe to distinguish an explicitly
  named pantry from a discovered one covered a missing file but not an
  unreadable one, which newly hard-errored. The shell now probes the
  discovered pantry and warns, as before.

Mutation testing: aggregation, pantry subtraction, `ignore_references`,
diagnostics, aisle categorisation, scaling, unit joining and the lookup
error mapping are all caught by tests. Two survivors are recorded in
comments rather than fixed: the `CircularReference` guard is unreachable
because expansion is bounded rather than recursive (as it was before), and
`PantryConf::rebuild_index` is redundant because `parse_lenient` already
builds the index.
Reference expansion is bounded rather than recursive, so the guard that
built `CoreError::CircularReference` could never fire: `extract_ingredients`
started every call with an empty `seen` map and never recursed into itself.
Mutation testing confirmed it — disabling the guard failed no test.

A public error variant nothing can return is a lie in the API. A consumer
matching on it writes dead code, and its presence implies a cycle-safety
property the crate does not have: two recipes referencing each other
silently double-count the ingredients of the one they start from
(#424).

Removes the variant, the guard, and the `seen` map that existed only to
feed it, which collapses `extract_into` back into `extract_ingredients`.
Both exhaustiveness tests are updated. The `Cycles are not detected`
section on `extract_ingredients` and a comment where the variant used to
live point at #424 and say what reintroducing it would take, so the fix is
not rediscovered from scratch.

Also pins the pantry distinction restored in the previous commit: a pantry
named with `--pantry` that cannot be read is fatal, while one merely
discovered in `config/` warns and is skipped. Nothing covered this, and the
two branches are one line apart. Permission bits are the only way to make a
file `is_file()` accepts and `read_to_string` rejects — testing mere
existence is what missed the case originally — so the test probes whether
the mode is enforced and returns early when it is not, rather than
asserting something untrue under root. Permissions are restored before any
assertion runs, so a failure cannot orphan the temp directory.

Mutation results for that branch: removing the discovered-pantry probe and
making an explicitly named pantry non-fatal are each caught by the new test.
Task 7 added a unix-only pantry permissions test, so Windows compiles
one fewer test. CI runs windows-latest, so the difference needs to be
in the contract rather than discovered as a false regression.
Moves `cook search` into `cookcli_core::search::search`, leaving the CLI
with argument joining, delegation and printing. Output is unchanged:
quoted, one per line, relative to the search root, in relevance order.

`SearchHit` carries the path it was found at and the recipe's title
alongside the relative path. Both are already computed by the walk — the
title comes from front matter it has to parse anyway — and an editor
listing results needs a path it can open and a name it can show, neither
of which `relative_path` alone provides.

`query` is a single string rather than a list of terms, because the
underlying scoring matches the whole query against file names and its
whitespace-separated parts against contents. A list would imply a
per-term structure that no scoring rule can observe.

Corrects the help text and docs, which described multiple terms as AND.
They are not: a file scoring above zero on any one term is a hit, so an
extra term widens the results. That is `cooklang-find`'s behaviour and is
now pinned by a test rather than misdescribed.

Adds `CoreError::Search` for a root that cannot be searched at all, which
a directory whose name contains glob syntax reaches. Reporting it as a
failed read would send the user looking at permissions.

CLI tests 312 -> 313: nothing covered the space-join, since the existing
multi-term test searched two words from the same recipe and passed with
the trailing terms discarded.
dubadub added 15 commits August 12, 2026 16:45
`doctor validate` is the first command whose payload *is* its
diagnostics, so `cookcli_core::doctor::validate` returns them as data:
`Ok` even for a collection of entirely broken recipes, `Err` only when
the walk could not start.

`RecipeValidation` carries both halves — structured `Diagnostic`s for
consumers and cooklang's own source-quoting report for the CLI to print
verbatim. Unlike `CoreError::Parse.rendered`, which is always plain, that
report's colour is chosen by the caller through `ValidateRequest::style`,
so a NAPI consumer showing it in a web view does not get escape codes.
The CLI asks for `Style::Ansi` and prints it unchanged.

The five totals are methods on `ValidationReport` rather than stored
counters: they hold nothing `recipes` does not, and deriving them is what
keeps a total from disagreeing with the recipes it counts. The reference
map is a derived view for the same reason.

Two visible changes fall out of the move. Recipes now come back in path
order, where the walk previously leaked `cooklang-find`'s `HashMap`
ordering into a report that changed between runs. And a root that cannot
be walked is reported as `Cannot search '<root>': no such directory`
rather than in cooklang-find's words.

`doctor aisle` and `doctor pantry` are untouched.
The plan claimed a malformed aisle.conf fell back silently. main already
logged both the warnings and the failure through warn!. The real gap was
that the information was unstructured and unreachable by a library
consumer, which Task 7 closed.
The five read-only `cook pantry` subcommands — list, depleted, expiring,
recipes and plan — now build a request, call `cookcli-core::pantry` and
format what comes back. Pantry parsing, the low-stock rules, date reading
and the greedy coverage algorithm all move into the library; the wording,
the emoji and the `--format` matching stay here.

`load` reads through `Context::pantry`, a `ConfigSource`, so an editor can
hand over pantry text it has not saved rather than a path. A context with
no pantry at all is the new `CoreError::MissingConfig`, which the CLI words
as it always has. `plan` needs no pantry: it works out what to stock from
the recipe collection alone.

Two things change on the way, both of them ordering that was previously
left to a `HashMap`:

- `pantry recipes` sorts its matches by name, and the missing ingredients
  within each, rather than reporting them in walk order.
- `pantry plan` breaks a tie between equally-wanted ingredients
  alphabetically. Its output used to differ on every single run.

Also fixes a panic: `pantry expiring -d 4294967295` ran off the end of the
calendar and unwound through `NaiveDate + TimeDelta`.

Adding, removing and updating items are untouched, and still own the pantry
file's loader and writer.
Adds `cookcli_core::report::render`, the last command extraction. The
CLI keeps argument parsing, the prototype warning, template file
reading, path resolution, the exit(1) on a render failure and the
println! of the result; everything else moves to core.

A library must never end the host process, so core returns
CoreError::Render instead of calling std::process::exit. That variant
gains a `rendered` field alongside `message`, mirroring CoreError::Parse:
cooklang-reports' format_with_source() is a multi-line report with source
context and hints, and putting it in Display would break the single-line
convention every other variant keeps. The CLI prints `rendered` verbatim,
so its output is byte-identical.

Inline aisle and pantry configuration is honoured. cooklang-reports'
Config only takes paths, but its aisled(), excluding_pantry() and
from_pantry() functions read `aisle_content` / `pantry_content` out of
the template state, so a ConfigSource::Inline is injected there via
Config::with_context rather than being rejected or written to a temp file.

Non-finite scale factors are now rejected with CoreError::InvalidScale.
`cook report recipe.cook:nan` previously produced NaN quantities; it now
fails the way `cook recipe recipe.cook:nan` already did.

Adds tests/report_test.rs: `cook report` had no end-to-end coverage at
all. All ten tests were verified to pass against the pre-refactor
implementation as well, and nineteen scenarios were diffed before and
after for byte-identical output.
The .shopping-list / .shopping-checked store was behind the `server`
cargo feature — exactly the feature a library consumer compiles out —
while the Cooklang editor keeps a near-identical wrapper over the same
cooklang::shopping_list API and the same file pair. Move it to
cookcli-core so both can share one implementation.

Its writes were not fully atomic: save_list used std::fs::write, which
truncates before writing, so a failed write left a truncated
.shopping-list. Only compact() staged and renamed. Both now go through
one shared write_atomically, hoisted out of the pantry writer (Task 11b
had duplicated it there) into a crate-private fs_atomic module that also
fsyncs the staged file before the rename.

anyhow is replaced by CoreError, with a new InvalidShoppingList variant
for a .shopping-list that cannot be parsed — treating one as empty would
silently discard the user's list. ShoppingListApiItem becomes
StoredEntry: there is no API layer in a library.
Delete the CLI's two divergent Context copies (#417).
src/main.rs's aisle()/pantry() fell back to the platform config
directory and src/lib.rs's did not, and the lib.rs copy was the one the
test suite exercised, which is why the drift went unnoticed. Both now
re-export cookcli_core::Context, built with Context::discover so the CLI
keeps ambient discovery; the base-path canonicalisation and is_dir check
stay in configure_context, since core deliberately does neither.

global_file_path went the same way: the session.json and sync.db call
sites use cookcli_core::global_config_path.

Add crates/core/README.md with the call shape, the paths-or-text
contract and the editor consumer-coverage mapping, wired as a doctest
under cfg(doctest) so the example cannot rot, plus readme = "README.md".

Publish cookcli-core before cookcli in the release workflow: crates.io
rejects a package whose path+version dependency is unpublished, so the
first release after this merges would otherwise fail. The core step is
gated on the version not already being on crates.io rather than
continue-on-error, so a genuine failure still fails the job.
ScaledRecipe named a String, resolved against Context::base_path through
cooklang-find, so shopping_list::generate could only aggregate recipes
that already existed on disk. That made the editor's
generateShoppingList — an array of { content, scale } that never touches
the filesystem — inexpressible, and it is the single function most
responsible for this extraction existing.

Replace the name with a RecipeSource, matching recipe::read, which has
resolved both a path and a buffer since Task 5. A Content recipe is
parsed from its text and attributed to its supplied name; a Path recipe
is looked up exactly as before.

Only the starting recipe can come from memory. A Content recipe's
@./sauce{} reference is still resolved from disk under base_path,
because a reference names a file and nothing in the request carries a
second buffer to resolve it against. Documented on ScaledRecipe and
extract_ingredients, and pinned both ways: the reference expands when
the file exists, and fails with RecipeNotFound when it does not.

Nine core tests cover the new path, including two mutation guards the
coordinator called for — that Content never reads the file its name
happens to match, and that the supplied name is what identifies a buffer
that will not parse. Seven mutations were killed, including dropping the
scale, fabricating a file path for a buffer's diagnostics, and skipping
reference expansion for Content.

CLI behaviour is unchanged: src/shopping_list.rs and the two server
handlers now build RecipeSource::Path, and all 14 shopping-list
characterization snapshots are untouched.
The criterion said no command module contains parsing logic, but the plan
only ever scoped doctor validate. doctor pantry, doctor aisle and 12
parse_recipe_from_entry callers were never in scope.
…core

`cook doctor aisle` and `cook doctor pantry` were the last command logic
left in the CLI's command modules: both walked the recipe tree, parsed
every recipe and aggregated ingredients themselves.

Both are now one core call each — `doctor::aisle_coverage` and
`doctor::pantry_coverage` — over a shared collection walk lifted out of
`pantry` into `find`. `doctor validate` no longer resolves recipe
references itself either; `doctor::broken_references` does, against the
root the report records.

Neither subcommand had any test coverage before; thirteen
characterization tests pin what they print.
Addresses review of the doctor extraction.

- `config_error` returned a constant, so `cook doctor pantry` and
  `cook pantry list` gave different messages for one unparseable file.
  `parse_failure` moves to `diagnostic` and both now use it.
- `pantry_coverage`'s diagnostic collection had no test: deleting the
  line left every doctor test passing. Mirrored the aisle test.
- `cook doctor` with no subcommand no longer aborts on a check it
  cannot run; it prints the failure and runs the rest.
- Documented that a broken reference is not a diagnostic, so
  `has_errors()` alone is not the verdict, and that the ingredient
  order is by code point rather than alphabetical.
Moving the comparison from `eq_ignore_ascii_case` to `to_lowercase` on
both sides made non-ASCII names match case-insensitively, so `@Öl`
against an aisle entry `öl` is no longer reported as uncategorised.
That was an undeclared behaviour change; pin it and say so.
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