cookcli-core 0.38.0

Recipe, shopping list, pantry and report operations for Cooklang, extracted from CookCLI
Documentation

cookcli-core

The logic behind CookCLI's commands, as a library.

cook recipe, cook shopping-list, cook search, cook doctor, cook pantry and cook report are all thin argument-parsing and output-formatting shells over this crate. It exists so that the other things that want to do those jobs — the Cooklang editor through its NAPI-RS addon, CookBot — can call the same code instead of maintaining a parallel reimplementation that drifts.

The shape

Almost everything public has the same signature:

fn(&Context, Request) -> Result<Outcome<T>, CoreError>
  • Context is the configuration bundle: a base path plus the aisle and pantry configuration. Context::new touches nothing; Context::discover is the opt-in that reproduces CookCLI's search order (<base>/config/<name>, then the platform configuration directory). A caller that already knows its configuration never has the user's ~/.config read behind its back.
  • Request is a plain struct with public fields, one per command. Adding an option to a command is adding a field, not a new function.
  • Outcome<T> is the result plus the diagnostics raised on the way — parse warnings, an aisle file that would not parse, a missing configuration. The CLI used to log those and drop them; here they come back to the caller, each attributed to the file it came from, so an editor can put a squiggle under the right line.
  • CoreError is the failure case: no result could be produced at all. It renders as a single lowercase line, library-style, with the long-form parse report kept in a field for callers that want to print it verbatim.

Paths or text

Recipes come in as a RecipeSource and configuration as a ConfigSource, and both have an in-memory variant:

RecipeSource::Path(path)              ConfigSource::Path(path)
RecipeSource::Content { text, name }  ConfigSource::Inline(text)
                                      ConfigSource::None

That is the case a path-only API cannot serve: an editor rendering the buffer the user is typing into, before it has ever been saved.

Example

use cookcli_core::{recipe, Context, CoreError, RecipeSource};

/// Read an unsaved buffer at double scale, and report what the parser thought
/// of it.
fn preview(text: &str) -> Result<String, CoreError> {
    // `new`, not `discover`: nothing here reads aisle or pantry configuration,
    // so there is no reason to go looking for any.
    let ctx = Context::new("/recipes".into());

    let outcome = recipe::read(
        &ctx,
        recipe::ReadRequest {
            source: RecipeSource::Content {
                text: text.to_string(),
                name: "unsaved buffer".to_string(),
            },
            scale: 2.0,
        },
    )?;

    for diagnostic in &outcome.diagnostics {
        eprintln!("{:?}: {}", diagnostic.severity, diagnostic.message);
    }

    Ok(outcome.value.title)
}

let buffer = "---\ntitle: Leek Soup\n---\n\nSlice the @leek{2}.\n";
assert_eq!(preview(buffer).unwrap(), "Leek Soup");

Consumer coverage

The editor's NAPI addon (packages/cooklang-native) is the first consumer, and this crate's API was checked against its real exported surface rather than designed against one imagined caller. Where each in-scope export lands:

Editor export cookcli-core
parse(input) parse_recipe(text, name, scale) — the Outcome carries the warnings, and errors arrive as CoreError::Parse with the same diagnostics attached, so the addon's { recipe, errors, warnings } shape is reconstructible. See the two notes below.
generateShoppingList(recipesJson, aisleConf?, pantryConf?) shopping_list::generate(&ctx, GenerateRequest { recipes, ignore_references, ..Default::default() }), with one ScaledRecipe { source: RecipeSource::Content { .. }, scale } per element of recipesJson, and the two configurations as ConfigSource::Inline on the Context. Buffers aggregate with each other and with files. extra_items adds ingredients no recipe calls for and defaults to none. See the note on references below.
findRecipe(baseDir, name) find::get_recipe(base_path, name), then RecipeEntry::content(). RecipeEntry is re-exported, so a consumer needs no cooklang-find dependency of its own.
renderReport(recipe, template, configJson) report::render(&ctx, RenderRequest { source, template, scale, datastore, base_path }). Takes the template as text rather than a path, so a buffer works; the aisle and pantry paths in the addon's config map onto Context::aisle / Context::pantry.
parseShoppingList / writeShoppingList / parseChecked / writeCheckEntry / checkedSet / compactChecked Pure text transforms over cooklang::shopping_list::{parse, write, parse_checked, write_check_entry, checked_set, compact_checked_log}, reachable through this crate's cooklang re-export. shopping_list::ShoppingListStore is the file-backed superset — the .shopping-list / .shopping-checked pair beside a recipe collection — which is what CookCLI and the web server use.

Out of scope, and deliberately absent: parseMenu (menu handling has not been extracted), startSync / stopSync / getSyncStatus / onSyncStatusChanged (the sync client is its own crate and pulls in tokio and reqwest, which this crate has no business depending on), and LspServer (cooklang-language-server).

Every in-scope export is expressible. Two boundaries are worth stating plainly rather than discovering later.

In-memory recipes and their references

ScaledRecipe::source accepts a buffer, but only for the recipe itself. A RecipeSource::Content recipe that references another recipe (@./sauce{}) still has that reference resolved from disk, under Context::base_path, because a reference names a file and nothing in the request carries a second buffer to resolve it against. So:

  • a buffer whose references all exist on disk works, and the referenced recipes' ingredients are expanded onto the list as usual;
  • a wholly in-memory recipe graph does not, and a reference to an unsaved file fails with CoreError::RecipeNotFound, exactly as it would for a recipe read from a path.

Two notes on parse

  • The parser configuration differs. PARSER here is Extensions::empty(), matching CookCLI. The addon's parse uses Extensions::all(). Adopting this crate therefore changes what the editor accepts; that is a decision to make deliberately, not an oversight.
  • There is no "parse without scaling". parse_recipe(text, name, 1.0) still calls Recipe::scale, which re-fits units — 1500 ml comes back as 1.5 l. The unscaled path exists internally but is not public.

License

MIT. See LICENSE.