From a56d2ccfa738b7a123d9b56fd84a140003d76be9 Mon Sep 17 00:00:00 2001 From: cr3bs <82143395+cr3bs@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:19:04 +0200 Subject: [PATCH 1/5] refactor: move parser into application context --- src/doctor.rs | 19 ++++++---- src/lib.rs | 17 ++++++++- src/main.rs | 18 ++++++++- src/pantry.rs | 17 ++++++--- src/recipe/read.rs | 16 ++++---- src/server/handlers/menus.rs | 18 +++++---- src/server/handlers/recipes.rs | 13 ++++--- src/server/handlers/shopping_list.rs | 55 ++++++++++++++++------------ src/server/mod.rs | 6 +-- src/server/ui.rs | 23 ++++++------ src/shopping_list.rs | 9 +++-- src/util/mod.rs | 42 +++++++++++---------- 12 files changed, 156 insertions(+), 97 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index d53bc94d..08001d31 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -210,6 +210,7 @@ fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> { // Walk through the tree to find and process all recipes fn process_recipes( + ctx: &Context, tree: &cooklang_find::RecipeTree, all_ingredients: &mut BTreeSet, pantry_ingredients: &mut BTreeSet, @@ -221,7 +222,7 @@ fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> { *recipe_count += 1; // Parse the recipe - let recipe = match parse_recipe_from_entry(entry, 1.0) { + let recipe = match parse_recipe_from_entry(ctx, entry, 1.0) { Ok(r) => r, Err(e) => { let name = entry.name().as_deref().unwrap_or("unknown"); @@ -255,6 +256,7 @@ fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> { // Recursively check children for subtree in tree.children.values() { process_recipes( + ctx, subtree, all_ingredients, pantry_ingredients, @@ -265,6 +267,7 @@ fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> { } process_recipes( + ctx, &tree, &mut all_ingredients, &mut pantry_ingredients, @@ -337,6 +340,7 @@ fn run_aisle(ctx: &Context, args: AisleArgs) -> Result<()> { // Walk through the tree to find and process all recipes fn process_recipes( + ctx: &Context, tree: &cooklang_find::RecipeTree, all_ingredients: &mut BTreeSet, recipe_count: &mut usize, @@ -346,7 +350,7 @@ fn run_aisle(ctx: &Context, args: AisleArgs) -> Result<()> { *recipe_count += 1; // Parse the recipe - let recipe = match parse_recipe_from_entry(entry, 1.0) { + let recipe = match parse_recipe_from_entry(ctx, entry, 1.0) { Ok(r) => r, Err(e) => { let name = entry.name().as_deref().unwrap_or("unknown"); @@ -371,11 +375,11 @@ fn run_aisle(ctx: &Context, args: AisleArgs) -> Result<()> { // Recursively check children for subtree in tree.children.values() { - process_recipes(subtree, all_ingredients, recipe_count); + process_recipes(ctx, subtree, all_ingredients, recipe_count); } } - process_recipes(&tree, &mut all_ingredients, &mut recipe_count); + process_recipes(ctx, &tree, &mut all_ingredients, &mut recipe_count); println!( "Scanned {} recipes, found {} unique ingredients", @@ -439,6 +443,7 @@ fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> { // Validate recipes and collect references fn validate_recipes( + ctx: &Context, tree: &cooklang_find::RecipeTree, base_path: &Utf8PathBuf, stats: &mut (usize, usize, usize, usize, usize), @@ -462,7 +467,7 @@ fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> { match fs::read_to_string(&recipe_path) { Ok(content) => { // Parse with our configured parser to get all errors and warnings - let parsed = crate::util::PARSER.parse(&content); + let parsed = ctx.parser().parse(&content); let errors: Vec<_> = parsed.report().errors().collect(); let warnings: Vec<_> = parsed.report().warnings().collect(); @@ -520,7 +525,7 @@ fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> { // Recursively check children for subtree in tree.children.values() { - validate_recipes(subtree, base_path, stats, recipe_refs); + validate_recipes(ctx, subtree, base_path, stats, recipe_refs); } } @@ -531,7 +536,7 @@ fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> { total_errors, total_warnings, ); - validate_recipes(&tree, base_path, &mut stats, &mut recipe_references); + validate_recipes(ctx, &tree, base_path, &mut stats, &mut recipe_references); ( total_recipes, recipes_with_errors, diff --git a/src/lib.rs b/src/lib.rs index 5485a64f..67d86c36 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,9 @@ +use std::sync::OnceLock; + // Re-export modules for testing use anyhow::{Context as _, Result}; use camino::{Utf8Path, Utf8PathBuf}; +use cooklang::{Converter, CooklangParser, Extensions}; // Commands - make them available as public modules pub mod doctor; @@ -23,11 +26,19 @@ pub mod util; // Context struct for testing - matches the one in main.rs pub struct Context { base_path: Utf8PathBuf, + parser: OnceLock, } impl Context { pub fn new(base_path: Utf8PathBuf) -> Self { - Self { base_path } + Self { + base_path, + parser: OnceLock::new(), + } + } + + pub fn parser(&self) -> &CooklangParser { + self.parser.get_or_init(configure_parser) } pub fn aisle(&self) -> Option { @@ -53,6 +64,10 @@ impl Context { } } +fn configure_parser() -> CooklangParser { + CooklangParser::new(Extensions::empty(), Converter::default()) +} + const APP_NAME: &str = "cook"; const UTF8_PATH_PANIC: &str = "cook only supports UTF-8 paths."; diff --git a/src/main.rs b/src/main.rs index 0981902c..0d39ee23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,11 +28,14 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +use std::sync::OnceLock; + use crate::util::resolve_to_absolute_path; use anyhow::{bail, Context as AnyhowContext, Result}; use args::{CliArgs, Command}; use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; +use cooklang::{Converter, CooklangParser, Extensions}; // commands mod doctor; @@ -82,11 +85,19 @@ pub fn main() -> Result<()> { pub struct Context { base_path: Utf8PathBuf, + parser: OnceLock, } impl Context { pub fn new(base_path: Utf8PathBuf) -> Self { - Self { base_path } + Self { + base_path: base_path, + parser: OnceLock::new(), + } + } + + pub fn parser(&self) -> &CooklangParser { + self.parser.get_or_init(configure_parser) } pub fn aisle(&self) -> Option { @@ -118,6 +129,10 @@ impl Context { } } +fn configure_parser() -> CooklangParser { + CooklangParser::new(Extensions::empty(), Converter::default()) +} + fn configure_context() -> Result { let args = CliArgs::parse(); let base_path = match args.command { @@ -138,6 +153,7 @@ fn configure_context() -> Result { Ok(Context { base_path: absolute_base_path, + parser: OnceLock::new(), }) } diff --git a/src/pantry.rs b/src/pantry.rs index 25eee612..214b37f9 100644 --- a/src/pantry.rs +++ b/src/pantry.rs @@ -595,6 +595,7 @@ fn run_recipes(ctx: &AppContext, args: RecipesArgs, format: OutputFormat) -> Res // Recursively process recipes in the tree fn process_tree( + ctx: &AppContext, tree: &cooklang_find::RecipeTree, pantry_ingredients: &HashSet, full_matches: &mut Vec, @@ -604,7 +605,7 @@ fn run_recipes(ctx: &AppContext, args: RecipesArgs, format: OutputFormat) -> Res // Check if this node has a recipe if let Some(entry) = &tree.recipe { // Parse the recipe - if let Ok(recipe) = parse_recipe_from_entry(entry, 1.0) { + if let Ok(recipe) = parse_recipe_from_entry(ctx, entry, 1.0) { // Get all ingredients from the recipe (excluding recipe references) let mut recipe_ingredients = HashSet::new(); for ingredient in &recipe.ingredients { @@ -647,6 +648,7 @@ fn run_recipes(ctx: &AppContext, args: RecipesArgs, format: OutputFormat) -> Res // Recursively check children for subtree in tree.children.values() { process_tree( + ctx, subtree, pantry_ingredients, full_matches, @@ -657,6 +659,7 @@ fn run_recipes(ctx: &AppContext, args: RecipesArgs, format: OutputFormat) -> Res } process_tree( + ctx, &tree, &pantry_ingredients, &mut full_matches, @@ -748,7 +751,11 @@ fn run_plan(ctx: &AppContext, args: PlanArgs, format: OutputFormat) -> Result<() let mut recipes: Vec = Vec::new(); // Recursively process recipes in the tree - fn process_tree(tree: &cooklang_find::RecipeTree, recipes: &mut Vec) { + fn process_tree( + ctx: &AppContext, + tree: &cooklang_find::RecipeTree, + recipes: &mut Vec, + ) { // Check if this node has a recipe if let Some(entry) = &tree.recipe { // Skip .menu files - only process .cook files @@ -757,7 +764,7 @@ fn run_plan(ctx: &AppContext, args: PlanArgs, format: OutputFormat) -> Result<() } // Parse the recipe - if let Ok(recipe) = parse_recipe_from_entry(entry, 1.0) { + if let Ok(recipe) = parse_recipe_from_entry(ctx, entry, 1.0) { let mut recipe_ingredients = HashSet::new(); // Get all ingredients from the recipe (excluding recipe references) @@ -782,11 +789,11 @@ fn run_plan(ctx: &AppContext, args: PlanArgs, format: OutputFormat) -> Result<() // Recursively check children for subtree in tree.children.values() { - process_tree(subtree, recipes); + process_tree(ctx, subtree, recipes); } } - process_tree(&tree, &mut recipes); + process_tree(ctx, &tree, &mut recipes); if recipes.is_empty() { match format { diff --git a/src/recipe/read.rs b/src/recipe/read.rs index dc1ce3f9..588beb99 100644 --- a/src/recipe/read.rs +++ b/src/recipe/read.rs @@ -35,7 +35,7 @@ use std::io::Read; use camino::Utf8PathBuf; use crate::{ - util::{split_recipe_name_and_scaling_factor, write_to_output, PARSER}, + util::{split_recipe_name_and_scaling_factor, write_to_output}, Context, }; use cooklang_find::RecipeEntry; @@ -118,7 +118,7 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { let recipe_entry = cooklang_find::get_recipe(vec![ctx.base_path().clone()], name.into()) .map_err(|e| anyhow::anyhow!("Recipe not found: {}", e))?; - let recipe = crate::util::parse_recipe_from_entry(&recipe_entry, scale)?; + let recipe = crate::util::parse_recipe_from_entry(ctx, &recipe_entry, scale)?; (recipe, recipe_entry.name().clone().unwrap_or(String::new())) } else { // Read from stdin and create a RecipeEntry @@ -132,7 +132,7 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { .context("Failed to create recipe entry from stdin")?; // Use the same parsing function as for file-based recipes - let recipe = crate::util::parse_recipe_from_entry(&recipe_entry, scale)?; + let recipe = crate::util::parse_recipe_from_entry(ctx, &recipe_entry, scale)?; (recipe, recipe_entry.name().clone().unwrap_or(String::new())) }; @@ -158,7 +158,7 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { &recipe, &title, scale, - PARSER.converter(), + ctx.parser().converter(), writer, )?, OutputFormat::Json => { @@ -176,28 +176,28 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { &recipe, &title, scale, - PARSER.converter(), + ctx.parser().converter(), writer, )?, OutputFormat::Latex => crate::util::cooklang_to_latex::print_latex( &recipe, &title, scale, - PARSER.converter(), + ctx.parser().converter(), writer, )?, OutputFormat::Typst => crate::util::cooklang_to_typst::print_typst( &recipe, &title, scale, - PARSER.converter(), + ctx.parser().converter(), writer, )?, OutputFormat::Schema => crate::util::cooklang_to_schema::print_schema( &recipe, &title, scale, - PARSER.converter(), + ctx.parser().converter(), writer, args.pretty, )?, diff --git a/src/server/handlers/menus.rs b/src/server/handlers/menus.rs index c818399f..c8067b8a 100644 --- a/src/server/handlers/menus.rs +++ b/src/server/handlers/menus.rs @@ -168,13 +168,14 @@ pub async fn get_menu( )); } - let recipe = crate::util::parse_recipe_from_entry(&entry, scale).map_err(|e| { - tracing::error!("Failed to parse menu: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - json_error(format!("Failed to parse menu: {e}")), - ) - })?; + let recipe = + crate::util::parse_recipe_from_entry(&state.context, &entry, scale).map_err(|e| { + tracing::error!("Failed to parse menu: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + json_error(format!("Failed to parse menu: {e}")), + ) + })?; // Build metadata as a JSON object let metadata = if recipe.metadata.map.is_empty() { @@ -411,6 +412,7 @@ pub async fn get_menu( /// Scan all menus for a section whose date matches today. /// Returns the first match with menu name, path, and formatted date. pub fn find_todays_menu( + state: &AppState, base_path: &camino::Utf8Path, tree: &RecipeTree, ) -> Option { @@ -428,7 +430,7 @@ pub fn find_todays_menu( Err(_) => continue, }; - let recipe = match crate::util::parse_recipe_from_entry(&entry, 1.0) { + let recipe = match crate::util::parse_recipe_from_entry(&state.context, &entry, 1.0) { Ok(r) => r, Err(_) => continue, }; diff --git a/src/server/handlers/recipes.rs b/src/server/handlers/recipes.rs index d17213cf..20fdacdd 100644 --- a/src/server/handlers/recipes.rs +++ b/src/server/handlers/recipes.rs @@ -1,4 +1,4 @@ -use crate::{server::AppState, util::PARSER}; +use crate::{server::AppState}; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -73,10 +73,11 @@ pub async fn recipe( })?; let recipe = - crate::util::parse_recipe_from_entry(&entry, query.scale.unwrap_or(1.0)).map_err(|e| { - tracing::error!("Failed to parse recipe: {e}"); - (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e)) - })?; + crate::util::parse_recipe_from_entry(&state.context, &entry, query.scale.unwrap_or(1.0)) + .map_err(|e| { + tracing::error!("Failed to parse recipe: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, json_error(&e)) + })?; // Get the image path if available let image_path = entry.title_image().clone().and_then(|img_path| { @@ -113,7 +114,7 @@ pub async fn recipe( } let grouped_ingredients = recipe - .group_ingredients(PARSER.converter()) + .group_ingredients(state.context.parser().converter()) .into_iter() .map(|entry| { serde_json::json!({ diff --git a/src/server/handlers/shopping_list.rs b/src/server/handlers/shopping_list.rs index 4bbc15c5..ad64b485 100644 --- a/src/server/handlers/shopping_list.rs +++ b/src/server/handlers/shopping_list.rs @@ -1,8 +1,7 @@ -use crate::server::{ - shopping_list_store::{recipe_display_name, ShoppingListApiItem, ShoppingListStore}, - AppState, -}; -use crate::util::{extract_ingredients, PARSER}; +use crate::{Context, server::{ + AppState, shopping_list_store::{ShoppingListApiItem, ShoppingListStore, recipe_display_name} +}}; +use crate::util::{extract_ingredients}; use anyhow::Context as _; use axum::{extract::State, http::StatusCode, Json}; use camino::Utf8PathBuf; @@ -35,11 +34,12 @@ pub async fn shopping_list( }; extract_ingredients( + &state.context, &recipe_with_scale, &mut list, &mut seen, &state.base_path, - PARSER.converter(), + state.context.parser().converter(), false, entry.included_references.as_deref(), ) @@ -107,7 +107,7 @@ pub async fn shopping_list( }; // Use common names from aisle configuration - list = list.use_common_names(&aisle, PARSER.converter()); + list = list.use_common_names(&aisle, state.context.parser().converter()); // Track pantry items that were found and subtracted (excluding zero quantities) let mut pantry_items = Vec::new(); @@ -136,7 +136,7 @@ pub async fn shopping_list( // Apply pantry subtraction if pantry is available let final_list = if let Some(ref pantry) = pantry_conf { - list.subtract_pantry(pantry, PARSER.converter()) + list.subtract_pantry(pantry, state.context.parser().converter()) } else { list }; @@ -396,11 +396,12 @@ fn aggregate_current_ingredient_names(state: &AppState) -> anyhow::Result anyhow::Result Option<(f64, String)> { Some((value, unit.to_string())) } -fn resolve_recipe_info(base_path: &Utf8PathBuf, recipe_path: &str) -> anyhow::Result { +fn resolve_recipe_info( + ctx: &Context, + base_path: &Utf8PathBuf, + recipe_path: &str, +) -> anyhow::Result { let entry = crate::util::get_recipe(base_path, recipe_path)?; - let recipe = crate::util::parse_recipe_from_entry(&entry, 1.0)?; + let recipe = crate::util::parse_recipe_from_entry(ctx, &entry, 1.0)?; let mut sub_refs = Vec::new(); for ingredient in &recipe.ingredients { @@ -509,7 +515,7 @@ pub async fn add_menu_to_shopping_list( })?; // Parse at scale 1.0 to get raw quantities for recipe references - let menu = crate::util::parse_recipe_from_entry(&entry, 1.0).map_err(|e| { + let menu = crate::util::parse_recipe_from_entry(&state.context, &entry, 1.0).map_err(|e| { tracing::error!("Failed to parse menu: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -530,17 +536,18 @@ pub async fn add_menu_to_shopping_list( // Resolve this recipe's sub-recipe references, default servings, and yield let ref_path_for_find = recipe_ref.path(std::path::MAIN_SEPARATOR_STR); - let info = match resolve_recipe_info(&state.base_path, &ref_path_for_find) { - Ok(info) => info, - Err(e) => { - tracing::warn!( - "Could not resolve referenced recipe '{}': {}", - ref_display, - e - ); - RecipeInfo::default() - } - }; + let info = + match resolve_recipe_info(&state.context, &state.base_path, &ref_path_for_find) { + Ok(info) => info, + Err(e) => { + tracing::warn!( + "Could not resolve referenced recipe '{}': {}", + ref_display, + e + ); + RecipeInfo::default() + } + }; // Convert `{target%unit}` on the menu reference into a scale // multiplier for `.shopping-list`. Per the Cooklang spec diff --git a/src/server/mod.rs b/src/server/mod.rs index 983c3b12..ffed2681 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -245,9 +245,7 @@ pub async fn run(ctx: Context, args: ServerArgs) -> Result<()> { } fn build_state(ctx: Context, args: ServerArgs) -> Result> { - let Context { base_path } = ctx; - - let path = args.base_path.as_ref().unwrap_or(&base_path); + let path = args.base_path.as_ref().unwrap_or(ctx.base_path()); let absolute_path = resolve_to_absolute_path(path)?; if absolute_path.is_file() { @@ -306,6 +304,7 @@ fn build_state(ctx: Context, args: ServerArgs) -> Result> { }; Ok(Arc::new(AppState { + context: server_ctx, base_path: absolute_path, aisle_path, pantry_path, @@ -352,6 +351,7 @@ async fn shutdown_signal() { } pub struct AppState { + pub context: Context, pub base_path: Utf8PathBuf, pub aisle_path: Option, pub pantry_path: Option, diff --git a/src/server/ui.rs b/src/server/ui.rs index 9bb7d59f..92cca412 100644 --- a/src/server/ui.rs +++ b/src/server/ui.rs @@ -139,7 +139,7 @@ async fn recipes_handler( }); let todays_menu = if path.is_none() { - crate::server::handlers::find_todays_menu(base, &tree) + crate::server::handlers::find_todays_menu(&state, base, &tree) } else { None }; @@ -242,7 +242,7 @@ async fn recipe_page( }; } - let recipe = match crate::util::parse_recipe_from_entry(&entry, scale) { + let recipe = match crate::util::parse_recipe_from_entry(&state.context, &entry, scale) { Ok(recipe) => recipe, Err(e) => { tracing::error!("Failed to parse recipe: {e}"); @@ -291,14 +291,14 @@ async fn recipe_page( ), > = std::collections::HashMap::new(); - for entry in recipe.group_ingredients(crate::util::PARSER.converter()) { + for entry in recipe.group_ingredients(state.context.parser().converter()) { let ingredient = entry.ingredient; let display_name = ingredient.display_name().to_string(); grouped_ingredients .entry(display_name) .and_modify(|(merged_qty, igrs)| { - merged_qty.merge(&entry.quantity, crate::util::PARSER.converter()); + merged_qty.merge(&entry.quantity, state.context.parser().converter()); igrs.push(ingredient); }) .or_insert_with(|| (entry.quantity.clone(), vec![ingredient])); @@ -370,7 +370,7 @@ async fn recipe_page( }); } - for item in &recipe.group_cookware(crate::util::PARSER.converter()) { + for item in &recipe.group_cookware(state.context.parser().converter()) { cookware.push(CookwareData { name: item.cookware.name.to_string(), }); @@ -533,7 +533,7 @@ async fn recipe_page( let display_name = ingredient.display_name().to_string(); let qty = if let Some(q) = &ingredient.quantity { let mut grouped_qty = cooklang::quantity::GroupedQuantity::empty(); - grouped_qty.add(q, crate::util::PARSER.converter()); + grouped_qty.add(q, state.context.parser().converter()); grouped_qty } else { cooklang::quantity::GroupedQuantity::empty() @@ -543,7 +543,7 @@ async fn recipe_page( .entry(display_name) .and_modify(|(merged_qty, igrs)| { if let Some(q) = &ingredient.quantity { - merged_qty.add(q, crate::util::PARSER.converter()); + merged_qty.add(q, state.context.parser().converter()); } igrs.push(ingredient); }) @@ -1115,10 +1115,11 @@ async fn menu_page_handler( state: Arc, lang: LanguageIdentifier, ) -> Result { - let recipe = crate::util::parse_recipe_from_entry(&entry, scale).map_err(|e| { - tracing::error!("Failed to parse menu: {e}"); - format!("Failed to parse menu: {e}") - })?; + let recipe = + crate::util::parse_recipe_from_entry(&state.context, &entry, scale).map_err(|e| { + tracing::error!("Failed to parse menu: {e}"); + format!("Failed to parse menu: {e}") + })?; // Get the image path if available let image_path = entry.title_image().clone().and_then(|img_path| { diff --git a/src/shopping_list.rs b/src/shopping_list.rs index f233163c..7eb45bcf 100644 --- a/src/shopping_list.rs +++ b/src/shopping_list.rs @@ -43,7 +43,7 @@ use cooklang::{ use serde::Serialize; use crate::{ - util::{extract_ingredients, write_to_output, PARSER}, + util::{extract_ingredients, write_to_output}, Context, }; @@ -248,22 +248,23 @@ pub fn run(ctx: &Context, args: ShoppingListArgs) -> Result<()> { for entry in expanded_recipes { extract_ingredients( + ctx, &entry, &mut list, &mut seen, ctx.base_path(), - PARSER.converter(), + ctx.parser().converter(), ignore_references, None, // CLI always includes all references )?; } // Use common names from aisle configuration - list = list.use_common_names(&aisle, PARSER.converter()); + list = list.use_common_names(&aisle, ctx.parser().converter()); // Subtract pantry quantities from shopping list if let Some(pantry_conf) = &pantry { - list = list.subtract_pantry(pantry_conf, PARSER.converter()); + list = list.subtract_pantry(pantry_conf, ctx.parser().converter()); } write_to_output(args.output.as_deref(), |w| { diff --git a/src/util/mod.rs b/src/util/mod.rs index e6983f8d..d2ac33bf 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -39,26 +39,24 @@ pub mod format; use anyhow::{Context as _, Result}; use camino::{Utf8Path, Utf8PathBuf}; use clap::CommandFactory; -use cooklang::{ - ingredient_list::IngredientList, quantity::Value, Converter, CooklangParser, Extensions, Recipe, -}; +use cooklang::{ingredient_list::IngredientList, quantity::Value, Converter, Recipe}; use cooklang_find::RecipeEntry; use std::collections::BTreeMap; use std::sync::Arc; -use std::sync::LazyLock; use tracing::warn; -pub const RECIPE_SCALING_DELIMITER: char = ':'; +use crate::Context; -pub static PARSER: LazyLock = LazyLock::new(|| { - // Use no extensions but with default converter for basic unit support - CooklangParser::new(Extensions::empty(), Converter::default()) -}); +pub const RECIPE_SCALING_DELIMITER: char = ':'; /// Parse a Recipe from a RecipeEntry with the given scaling factor -pub fn parse_recipe_from_entry(entry: &RecipeEntry, scaling_factor: f64) -> Result> { +pub fn parse_recipe_from_entry( + ctx: &Context, + entry: &RecipeEntry, + scaling_factor: f64, +) -> Result> { let content = entry.content().context("Failed to read recipe content")?; - let parsed = PARSER.parse(&content); + let parsed = ctx.parser().parse(&content); // Log any warnings if parsed.report().has_warnings() { @@ -91,7 +89,7 @@ pub fn parse_recipe_from_entry(entry: &RecipeEntry, scaling_factor: f64) -> Resu let (mut recipe, _warnings) = parsed.into_result().expect("already checked for errors"); // Scale the recipe - recipe.scale(scaling_factor, PARSER.converter()); + recipe.scale(scaling_factor, ctx.parser().converter()); Ok(Arc::new(recipe)) } @@ -151,6 +149,7 @@ pub fn resolve_to_absolute_path(path: &Utf8Path) -> anyhow::Result } pub fn extract_ingredients( + ctx: &Context, entry: &str, list: &mut IngredientList, seen: &mut BTreeMap, @@ -187,7 +186,7 @@ pub fn extract_ingredients( let recipe_entry = get_recipe(base_path, name).with_context(|| format!("Failed to find recipe '{name}'"))?; - let recipe = parse_recipe_from_entry(&recipe_entry, scaling_factor)?; + let recipe = parse_recipe_from_entry(ctx, &recipe_entry, scaling_factor)?; let ref_indices = list.add_recipe(&recipe, converter, ignore_references); tracing::debug!( @@ -258,7 +257,7 @@ pub fn extract_ingredients( let content = ref_entry .content() .context("Failed to read recipe content")?; - let parsed = PARSER.parse(&content); + let parsed = ctx.parser().parse(&content); // Check for parsing errors and format them with line context if parsed.report().has_errors() { @@ -286,7 +285,7 @@ pub fn extract_ingredients( quantity.unit().unwrap_or("(no unit)") ); recipe - .scale_to_target(target_value, quantity.unit(), PARSER.converter()) + .scale_to_target(target_value, quantity.unit(), ctx.parser().converter()) .context(format!( "Failed to scale recipe '{}' with target {} {}", ref_path, @@ -301,7 +300,7 @@ pub fn extract_ingredients( } None => { // No quantity specified, use CLI scaling only - parse_recipe_from_entry(&ref_entry, scaling_factor)? + parse_recipe_from_entry(ctx, &ref_entry, scaling_factor)? } }; @@ -338,7 +337,8 @@ pub fn extract_ingredients( let nested_content = nested_entry_path .content() .context("Failed to read nested recipe")?; - let (nested_recipe, _) = PARSER + let (nested_recipe, _) = ctx + .parser() .parse(&nested_content) .into_result() .context("Failed to parse nested recipe")?; @@ -354,7 +354,11 @@ pub fn extract_ingredients( let target = target_servings.to_string().parse().unwrap_or(1.0); tracing::debug!("Scaling nested recipe to {} servings", target); scaled_nested - .scale_to_target(target, Some("servings"), PARSER.converter()) + .scale_to_target( + target, + Some("servings"), + ctx.parser().converter(), + ) .context("Failed to scale nested recipe")?; // Now add this properly scaled nested recipe's ingredients @@ -367,7 +371,7 @@ pub fn extract_ingredients( if let Value::Number(num) = quantity.value() { let scaling = num.to_string().parse().unwrap_or(1.0); let mut scaled_nested = nested_recipe; - scaled_nested.scale(scaling, PARSER.converter()); + scaled_nested.scale(scaling, ctx.parser().converter()); list.add_recipe(&Arc::new(scaled_nested), converter, false); } } From 3c7f69e1fce7a8ff43af913c2f0d445acc7e956b Mon Sep 17 00:00:00 2001 From: cr3bs <82143395+cr3bs@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:21:23 +0200 Subject: [PATCH 2/5] refactor: parse cli arguments only once --- src/main.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0d39ee23..35d7aad8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,7 +65,7 @@ pub fn main() -> Result<()> { let args = CliArgs::parse(); configure_logging(args.verbosity); - let ctx = configure_context()?; + let ctx = configure_context(&args)?; match args.command { Command::Recipe(args) => recipe::run(&ctx, args), @@ -133,8 +133,7 @@ fn configure_parser() -> CooklangParser { CooklangParser::new(Extensions::empty(), Converter::default()) } -fn configure_context() -> Result { - let args = CliArgs::parse(); +fn configure_context(args: &CliArgs) -> Result { let base_path = match args.command { Command::Server(ref server_args) => server_args .get_base_path() From 70c780340519d433fc248a2f02a14ab9542cfdc8 Mon Sep 17 00:00:00 2001 From: cr3bs <82143395+cr3bs@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:22:19 +0200 Subject: [PATCH 3/5] refactor: consolidate context(s) in lib --- src/lib.rs | 85 ++++++++++++++++++++++++++------- src/main.rs | 135 +++------------------------------------------------- 2 files changed, 74 insertions(+), 146 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 67d86c36..183b340d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,10 @@ use std::sync::OnceLock; -// Re-export modules for testing -use anyhow::{Context as _, Result}; +use anyhow::{bail, Context as _, Result}; +use args::{CliArgs, Command}; use camino::{Utf8Path, Utf8PathBuf}; use cooklang::{Converter, CooklangParser, Extensions}; +use util::resolve_to_absolute_path; // Commands - make them available as public modules pub mod doctor; @@ -23,7 +24,12 @@ pub mod update; pub mod args; pub mod util; -// Context struct for testing - matches the one in main.rs +const LOCAL_CONFIG_DIR: &str = "config"; +const APP_NAME: &str = "cook"; +const UTF8_PATH_PANIC: &str = "cook only supports UTF-8 paths."; +const AUTO_AISLE: &str = "aisle.conf"; +const AUTO_PANTRY: &str = "pantry.conf"; + pub struct Context { base_path: Utf8PathBuf, parser: OnceLock, @@ -42,21 +48,27 @@ impl Context { } pub fn aisle(&self) -> Option { - let local_config = self.base_path.join("config").join("aisle.conf"); - if local_config.is_file() { - Some(local_config) - } else { - None - } + let auto = self.base_path.join(LOCAL_CONFIG_DIR).join(AUTO_AISLE); + + tracing::trace!("checking auto aisle file: {auto}"); + + auto.is_file().then_some(auto).or_else(|| { + let global = global_file_path(AUTO_AISLE).ok()?; + tracing::trace!("checking global auto aisle file: {global}"); + global.is_file().then_some(global) + }) } pub fn pantry(&self) -> Option { - let local_config = self.base_path.join("config").join("pantry.conf"); - if local_config.is_file() { - Some(local_config) - } else { - None - } + let auto = self.base_path.join(LOCAL_CONFIG_DIR).join(AUTO_PANTRY); + + tracing::trace!("checking auto pantry file: {auto}"); + + auto.is_file().then_some(auto).or_else(|| { + let global = global_file_path(AUTO_PANTRY).ok()?; + tracing::trace!("checking global auto pantry file: {global}"); + global.is_file().then_some(global) + }) } pub fn base_path(&self) -> &Utf8PathBuf { @@ -68,11 +80,48 @@ fn configure_parser() -> CooklangParser { CooklangParser::new(Extensions::empty(), Converter::default()) } -const APP_NAME: &str = "cook"; -const UTF8_PATH_PANIC: &str = "cook only supports UTF-8 paths."; +pub fn configure_context(args: &CliArgs) -> Result { + let base_path = match args.command { + Command::Server(ref server_args) => server_args + .get_base_path() + .unwrap_or_else(|| Utf8PathBuf::from(".")), + Command::ShoppingList(ref shopping_list_args) => shopping_list_args + .get_base_path() + .unwrap_or_else(|| Utf8PathBuf::from(".")), + _ => Utf8PathBuf::from("."), + }; + + let absolute_base_path = resolve_to_absolute_path(&base_path)?; + + if !absolute_base_path.is_dir() { + bail!("Base path is not a directory: {}", absolute_base_path); + } + + Ok(Context { + base_path: absolute_base_path, + parser: OnceLock::new(), + }) +} + +pub fn configure_logging(verbosity: u8) { + let env_filter = match verbosity { + 0 => "warn,cook=warn", // Default: warnings and errors only + 1 => "info,cook=info", // -v: info level + 2 => "debug,cook=debug", // -vv: debug level + _ => "trace,cook=trace", // -vvv or more: trace level + }; + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .without_time() + .with_target(false) + .compact() + .with_writer(std::io::stderr) + .init(); +} /// Resolve a global configuration file path (e.g. `~/.config/cook/{name}`). -pub fn global_file_path(name: &str) -> Result { +fn global_file_path(name: &str) -> Result { let dirs = directories::ProjectDirs::from("", "", APP_NAME) .context("Could not determine home directory path")?; let config = Utf8Path::from_path(dirs.config_dir()).expect(UTF8_PATH_PANIC); diff --git a/src/main.rs b/src/main.rs index 35d7aad8..d5b0ee88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,38 +28,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -use std::sync::OnceLock; - -use crate::util::resolve_to_absolute_path; -use anyhow::{bail, Context as AnyhowContext, Result}; -use args::{CliArgs, Command}; -use camino::{Utf8Path, Utf8PathBuf}; +use anyhow::Result; use clap::Parser; -use cooklang::{Converter, CooklangParser, Extensions}; - -// commands -mod doctor; -mod import; -mod lsp; -mod pantry; -mod recipe; -mod report; -mod search; -mod seed; -mod server; -mod shopping_list; #[cfg(feature = "self-update")] -mod update; - -// other modules -mod args; -mod util; - -const LOCAL_CONFIG_DIR: &str = "config"; -const APP_NAME: &str = "cook"; -const UTF8_PATH_PANIC: &str = "cook only supports UTF-8 paths."; -const AUTO_AISLE: &str = "aisle.conf"; -const AUTO_PANTRY: &str = "pantry.conf"; +use cookcli::update; +use cookcli::{ + args::{CliArgs, Command}, + configure_context, configure_logging, doctor, import, lsp, pantry, recipe, report, search, + seed, server, shopping_list, +}; pub fn main() -> Result<()> { let args = CliArgs::parse(); @@ -82,101 +59,3 @@ pub fn main() -> Result<()> { Command::Update(args) => update::run(args), } } - -pub struct Context { - base_path: Utf8PathBuf, - parser: OnceLock, -} - -impl Context { - pub fn new(base_path: Utf8PathBuf) -> Self { - Self { - base_path: base_path, - parser: OnceLock::new(), - } - } - - pub fn parser(&self) -> &CooklangParser { - self.parser.get_or_init(configure_parser) - } - - pub fn aisle(&self) -> Option { - let auto = self.base_path.join(LOCAL_CONFIG_DIR).join(AUTO_AISLE); - - tracing::trace!("checking auto aisle file: {auto}"); - - auto.is_file().then_some(auto).or_else(|| { - let global = global_file_path(AUTO_AISLE).ok()?; - tracing::trace!("checking global auto aisle file: {global}"); - global.is_file().then_some(global) - }) - } - - pub fn pantry(&self) -> Option { - let auto = self.base_path.join(LOCAL_CONFIG_DIR).join(AUTO_PANTRY); - - tracing::trace!("checking auto pantry file: {auto}"); - - auto.is_file().then_some(auto).or_else(|| { - let global = global_file_path(AUTO_PANTRY).ok()?; - tracing::trace!("checking global auto pantry file: {global}"); - global.is_file().then_some(global) - }) - } - - pub fn base_path(&self) -> &Utf8PathBuf { - &self.base_path - } -} - -fn configure_parser() -> CooklangParser { - CooklangParser::new(Extensions::empty(), Converter::default()) -} - -fn configure_context(args: &CliArgs) -> Result { - let base_path = match args.command { - Command::Server(ref server_args) => server_args - .get_base_path() - .unwrap_or_else(|| Utf8PathBuf::from(".")), - Command::ShoppingList(ref shopping_list_args) => shopping_list_args - .get_base_path() - .unwrap_or_else(|| Utf8PathBuf::from(".")), - _ => Utf8PathBuf::from("."), - }; - - let absolute_base_path = resolve_to_absolute_path(&base_path)?; - - if !absolute_base_path.is_dir() { - bail!("Base path is not a directory: {}", absolute_base_path); - } - - Ok(Context { - base_path: absolute_base_path, - parser: OnceLock::new(), - }) -} - -fn configure_logging(verbosity: u8) { - let env_filter = match verbosity { - 0 => "warn,cook=warn", // Default: warnings and errors only - 1 => "info,cook=info", // -v: info level - 2 => "debug,cook=debug", // -vv: debug level - _ => "trace,cook=trace", // -vvv or more: trace level - }; - - tracing_subscriber::fmt() - .with_env_filter(env_filter) - .without_time() - .with_target(false) - .compact() - .with_writer(std::io::stderr) - .init(); -} - -pub fn global_file_path(name: &str) -> Result { - let dirs = directories::ProjectDirs::from("", "", APP_NAME) - .context("Could not determine home directory path")?; - let config = Utf8Path::from_path(dirs.config_dir()).expect(UTF8_PATH_PANIC); - let path = config.join(name); - Ok(path) -} From 91a1998ef9a8af0de8787117a988c77b1ef43014 Mon Sep 17 00:00:00 2001 From: cr3bs <82143395+cr3bs@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:23:40 +0200 Subject: [PATCH 4/5] feat: make the parser configurable via command line argument --- src/args.rs | 33 ++- src/lib.rs | 48 +++- src/server/handlers/recipes.rs | 2 +- src/server/handlers/shopping_list.rs | 12 +- src/server/mod.rs | 4 + tests/cli_integration_test.rs | 225 ++++++++++++++++++ tests/common/mod.rs | 27 +++ ...snapshot_test__doctor_validate_output.snap | 22 +- .../snapshots/snapshot_test__help_output.snap | 22 +- .../snapshot_test__search_output.snap | 1 + 10 files changed, 379 insertions(+), 17 deletions(-) diff --git a/src/args.rs b/src/args.rs index 91ca10da..54683bb2 100644 --- a/src/args.rs +++ b/src/args.rs @@ -28,12 +28,28 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; #[cfg(feature = "self-update")] use crate::update; use crate::{doctor, import, lsp, pantry, recipe, report, search, seed, server, shopping_list}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] +pub enum ParserExtensions { + AdvancedUnits, + All, + Compat, + ComponentAlias, + InlineQuantities, + IntermediatePreparations, + Modes, + Modifiers, + #[default] + None, + RangeValues, + TimerRequiresTime, +} + #[derive(Parser, Debug)] #[command( author, @@ -46,6 +62,21 @@ pub struct CliArgs { #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] pub verbosity: u8, + /// Configure parser extension + /// + /// `all` and `none` are mutually exclusive AND overwrite all other extensions + /// + /// `compat` enables all extensions except `timer-requires-time` + #[arg( + name = "EXTENSION", + short = 'e', + long = "extension", + value_enum, + global = true, + default_value = "none" + )] + pub extensions: Vec, + #[command(subcommand)] pub command: Command, } diff --git a/src/lib.rs b/src/lib.rs index 183b340d..33194271 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; use anyhow::{bail, Context as _, Result}; -use args::{CliArgs, Command}; +use args::{CliArgs, Command, ParserExtensions}; use camino::{Utf8Path, Utf8PathBuf}; use cooklang::{Converter, CooklangParser, Extensions}; use util::resolve_to_absolute_path; @@ -44,7 +44,7 @@ impl Context { } pub fn parser(&self) -> &CooklangParser { - self.parser.get_or_init(configure_parser) + self.parser.get().expect("parser was not configured") } pub fn aisle(&self) -> Option { @@ -76,8 +76,41 @@ impl Context { } } -fn configure_parser() -> CooklangParser { - CooklangParser::new(Extensions::empty(), Converter::default()) +fn configure_parser(args: &CliArgs) -> CooklangParser { + if args + .extensions + .iter() + .any(|e| matches!(e, ParserExtensions::None)) + { + return CooklangParser::new(Extensions::empty(), Converter::default()); + } + if args + .extensions + .iter() + .any(|e| matches!(e, ParserExtensions::All)) + { + return CooklangParser::new(Extensions::all(), Converter::default()); + } + let mut extensions = Extensions::empty(); + + for ext in &args.extensions { + match ext { + ParserExtensions::Compat => extensions |= Extensions::COMPAT, + ParserExtensions::Modifiers => extensions |= Extensions::COMPONENT_MODIFIERS, + ParserExtensions::ComponentAlias => extensions |= Extensions::COMPONENT_ALIAS, + ParserExtensions::AdvancedUnits => extensions |= Extensions::ADVANCED_UNITS, + ParserExtensions::Modes => extensions |= Extensions::MODES, + ParserExtensions::InlineQuantities => extensions |= Extensions::INLINE_QUANTITIES, + ParserExtensions::RangeValues => extensions |= Extensions::RANGE_VALUES, + ParserExtensions::TimerRequiresTime => extensions |= Extensions::TIMER_REQUIRES_TIME, + ParserExtensions::IntermediatePreparations => { + extensions |= Extensions::INTERMEDIATE_PREPARATIONS; + } + ParserExtensions::All | ParserExtensions::None => {} + } + } + + CooklangParser::new(extensions, Converter::default()) } pub fn configure_context(args: &CliArgs) -> Result { @@ -97,9 +130,14 @@ pub fn configure_context(args: &CliArgs) -> Result { bail!("Base path is not a directory: {}", absolute_base_path); } + let parser = OnceLock::new(); + parser + .set(configure_parser(args)) + .expect("failed to set parser"); + Ok(Context { base_path: absolute_base_path, - parser: OnceLock::new(), + parser, }) } diff --git a/src/server/handlers/recipes.rs b/src/server/handlers/recipes.rs index 20fdacdd..e004d149 100644 --- a/src/server/handlers/recipes.rs +++ b/src/server/handlers/recipes.rs @@ -1,4 +1,4 @@ -use crate::{server::AppState}; +use crate::server::AppState; use axum::{ extract::{Path, Query, State}, http::StatusCode, diff --git a/src/server/handlers/shopping_list.rs b/src/server/handlers/shopping_list.rs index ad64b485..664e3df3 100644 --- a/src/server/handlers/shopping_list.rs +++ b/src/server/handlers/shopping_list.rs @@ -1,7 +1,11 @@ -use crate::{Context, server::{ - AppState, shopping_list_store::{ShoppingListApiItem, ShoppingListStore, recipe_display_name} -}}; -use crate::util::{extract_ingredients}; +use crate::util::extract_ingredients; +use crate::{ + server::{ + shopping_list_store::{recipe_display_name, ShoppingListApiItem, ShoppingListStore}, + AppState, + }, + Context, +}; use anyhow::Context as _; use axum::{extract::State, http::StatusCode, Json}; use camino::Utf8PathBuf; diff --git a/src/server/mod.rs b/src/server/mod.rs index ffed2681..2a121b83 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -274,6 +274,10 @@ fn build_state(ctx: Context, args: ServerArgs) -> Result> { // Create a new Context with the actual base path to properly search for config files let server_ctx = Context::new(absolute_path.clone()); + server_ctx + .parser + .set(ctx.parser().clone()) + .expect("failed to set parser"); let aisle_path = server_ctx.aisle(); let pantry_path = server_ctx.pantry(); diff --git a/tests/cli_integration_test.rs b/tests/cli_integration_test.rs index 21340183..afddd327 100644 --- a/tests/cli_integration_test.rs +++ b/tests/cli_integration_test.rs @@ -248,3 +248,228 @@ fn test_cli_recipe_from_subdirectory() { .success() .stdout(predicate::str::contains("Pancakes")); } + +#[test] +fn test_cli_recipe_extension_default() { + let temp_dir = common::setup_test_recipes().unwrap(); + + // Default is no extensions, so inline references should not be resolved + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .assert() + .success() + .stdout(predicate::str::contains("&salt")); +} + +#[test] +fn test_cli_recipe_extension_no_modifiers() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .arg("--extension") + .arg("none") + .assert() + .success() + .stdout(predicate::str::contains("&salt")); +} + +#[test] +fn test_cli_recipe_extension_all() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("all") + .assert() + .success() + .stdout(predicate::str::contains("&salt").not()); +} + +#[test] +fn test_cli_recipe_extension_multiple() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .arg("--extension") + .arg("component-alias") + .arg("--extension") + .arg("intermediate-preparations") + .assert() + .success() + .stdout( + predicate::str::contains("white wine") + .not() + .and(predicate::str::contains("@&(1)dough").not()), + ); +} + +#[test] +fn test_cli_recipe_extension_compat() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("compat") + .assert() + .success() + .stdout(predicate::str::contains("&salt").not()); +} + +#[test] +fn test_cli_recipe_extension_modifiers() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("modifiers") + .assert() + .success() + .stdout(predicate::str::contains(r#""relation":{"relation":{"type":"reference","references_to":0},"reference_target":"ingredient"}"#)); +} + +#[test] +fn test_cli_recipe_extension_component_alias() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .arg("--extension") + .arg("component-alias") + .assert() + .success() + .stdout(predicate::str::contains("white wine").not()); +} + +#[test] +fn test_cli_recipe_extension_range_values() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("range-values") + .assert() + .success() + .stdout(predicate::str::contains( + r#""value":{"type":"range","value":{"start":{"type":"regular","value":200.0}"#, + )); +} + +#[test] +fn test_cli_recipe_extension_intermediate_preparations() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .arg("--extension") + .arg("intermediate-preparations") + .assert() + .success() + .stdout(predicate::str::contains("@&(1)dough").not()); +} + +#[test] +fn test_cli_recipe_extension_advanced_units() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("advanced-units") + .assert() + .success() + .stdout(predicate::str::contains(r#"unit":"g""#)); +} + +#[test] +fn test_cli_recipe_extension_inline_quantities() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("-f") + .arg("json") + .arg("extensions.cook") + .arg("--extension") + .arg("inline-quantities") + .assert() + .success() + .stdout(predicate::str::contains( + r#"{"type":"inlineQuantity","index":0}"#, + )); +} + +#[test] +fn test_cli_recipe_extension_modes() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("extensions.cook") + .arg("--extension") + .arg("modes") + .assert() + .success() + .stdout(predicate::str::contains("sugar eggs vanilla").not()); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a29a676e..31ca9716 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -76,6 +76,33 @@ Cook on a #griddle for ~{3%minutes} per side. "#, )?; + let _ = fs::write( + recipes_dir.join("extensions.cook"), + r#"--- +title: Recipe which require extensions +>> [mode]: components +@sugar +@eggs +@vanilla +>> [mode]: steps +--- + +Mix all ingredients together. + +Add @salt{200%g} and @&salt{100%g}. + +Add @white wine|wine{}. + +Mix @flour{100-200%g} with @water{200-300%ml}. + +Let the @&(~1)dough{} rest for ~{1%hour}. + +Let the dough rest at 25 F in the refrigerator. + +Add @milk{1 L} and @onion{200 g}. +"#, + ); + // Create config directory let config_dir = recipes_dir.join("config"); fs::create_dir(&config_dir)?; diff --git a/tests/snapshots/snapshot_test__doctor_validate_output.snap b/tests/snapshots/snapshot_test__doctor_validate_output.snap index c55bef47..ae708675 100644 --- a/tests/snapshots/snapshot_test__doctor_validate_output.snap +++ b/tests/snapshots/snapshot_test__doctor_validate_output.snap @@ -2,6 +2,24 @@ source: tests/snapshot_test.rs expression: sorted_output --- +📄 extensions.cook +Warning: Invalid YAML frontmatter syntax: did not find expected comment or line break at line 2 column 2, while scanning a block scalar at line 2 column 1 + ╭─[extensions.cook] + │ +3 │ >> [mode]: components + ┆ ─ +──╯ +Help: The frontmatter will be ignored. Fix the YAML syntax to use metadata. +Warning: Invalid single word name, the component will be ignored + ╭─[extensions.cook] + │ +18 │ Let the @&(~1)dough{} rest for ~{1%hour}. + ┆ ┬ + ┆ │ + ┆ ╰──────────────────────────────── expected single word name here +───╯ +Help: Add `{}` at the end of the name to use it, or change the name + 📄 with_errors.cook ❌ Missing reference: ./nonexistent @@ -19,6 +37,6 @@ title: Recipe with Errors === Recipe References === === Validation Summary === -Total recipes scanned: 5 +Total recipes scanned: 6 ❌ 1 error(s) found in 0 recipe(s) -⚠️ 1 warning(s) found in 1 recipe(s) +⚠️ 3 warning(s) found in 2 recipe(s) diff --git a/tests/snapshots/snapshot_test__help_output.snap b/tests/snapshots/snapshot_test__help_output.snap index ef61c388..9055bd4d 100644 --- a/tests/snapshots/snapshot_test__help_output.snap +++ b/tests/snapshots/snapshot_test__help_output.snap @@ -1,6 +1,5 @@ --- source: tests/snapshot_test.rs -assertion_line: 311 expression: stdout --- A command-line interface for managing and working with Cooklang recipes @@ -22,8 +21,23 @@ Commands: help Print this message or the help of the given subcommand(s) Options: - -v, --verbose... Increase verbosity (-v for info, -vv for debug, -vvv for trace) - -h, --help Print help - -V, --version Print version + -v, --verbose... + Increase verbosity (-v for info, -vv for debug, -vvv for trace) + + -e, --extension + Configure parser extension + + `all` and `none` are mutually exclusive AND overwrite all other extensions + + `compat` enables all extensions except `timer-requires-time` + + [default: none] + [possible values: advanced-units, all, compat, component-alias, inline-quantities, intermediate-preparations, modes, modifiers, none, range-values, timer-requires-time] + + -h, --help + Print help (see a summary with '-h') + + -V, --version + Print version Docs: https://cooklang.org/cli/ diff --git a/tests/snapshots/snapshot_test__search_output.snap b/tests/snapshots/snapshot_test__search_output.snap index ec2a7f1c..7342c8c3 100644 --- a/tests/snapshots/snapshot_test__search_output.snap +++ b/tests/snapshots/snapshot_test__search_output.snap @@ -3,3 +3,4 @@ source: tests/snapshot_test.rs expression: sorted --- "Breakfast/pancakes.cook" +"extensions.cook" From a4d5957dd0861d4c196ee1c17e6606fe9c29e238 Mon Sep 17 00:00:00 2001 From: cr3bs <82143395+cr3bs@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:24:45 +0200 Subject: [PATCH 5/5] fix: sort children to get deterministic output order --- src/doctor.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/doctor.rs b/src/doctor.rs index 08001d31..484bd793 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -253,8 +253,11 @@ fn run_pantry(ctx: &Context, args: PantryArgs) -> Result<()> { } } - // Recursively check children - for subtree in tree.children.values() { + // Recursively check children (sorted for deterministic output) + let mut keys: Vec<_> = tree.children.keys().collect(); + keys.sort(); + for key in keys { + let subtree = tree.children.get(key).unwrap(); process_recipes( ctx, subtree, @@ -373,8 +376,11 @@ fn run_aisle(ctx: &Context, args: AisleArgs) -> Result<()> { } } - // Recursively check children - for subtree in tree.children.values() { + // Recursively check children (sorted for deterministic output) + let mut keys: Vec<_> = tree.children.keys().collect(); + keys.sort(); + for key in keys { + let subtree = tree.children.get(key).unwrap(); process_recipes(ctx, subtree, all_ingredients, recipe_count); } } @@ -523,8 +529,11 @@ fn run_validate(ctx: &Context, args: ValidateArgs) -> Result<()> { } } - // Recursively check children - for subtree in tree.children.values() { + // Recursively check children (sorted for deterministic output) + let mut keys: Vec<_> = tree.children.keys().collect(); + keys.sort(); + for key in keys { + let subtree = tree.children.get(key).unwrap(); validate_recipes(ctx, subtree, base_path, stats, recipe_refs); } }