diff --git a/.gitignore b/.gitignore index b236bf3..7644c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ target/ releases node_modules/ static/css/output.css +.vscode diff --git a/src/recipe/mod.rs b/src/recipe/mod.rs index 444ee80..ce2d809 100644 --- a/src/recipe/mod.rs +++ b/src/recipe/mod.rs @@ -34,7 +34,9 @@ use clap::{Args, Subcommand}; use crate::Context; -mod read; +pub mod read; + +pub use read::CLIExtensions; #[derive(Debug, Args)] #[command(args_conflicts_with_subcommands = true)] diff --git a/src/recipe/read.rs b/src/recipe/read.rs index 29e1a60..1b8c112 100644 --- a/src/recipe/read.rs +++ b/src/recipe/read.rs @@ -35,7 +35,10 @@ use std::io::Read; use camino::Utf8PathBuf; use crate::{ - util::{split_recipe_name_and_scaling_factor, write_to_output, PARSER}, + util::{ + build_extensions_from_cli, parse_recipe_from_entry_with_parser, + split_recipe_name_and_scaling_factor, write_to_output, + }, Context, }; use cooklang_find::RecipeEntry; @@ -71,6 +74,13 @@ pub struct ReadArgs { /// Has no effect on human, cooklang, or markdown formats. #[arg(long)] pretty: bool, + + /// Extensions to enable + /// + /// Can be specified multiple times to enable multiple extensions. + /// If not specified, no extensions are enabled. + #[arg(short, long, value_enum, num_args(0..), default_values_t = vec![CLIExtensions::None])] + extension: Vec, } #[derive(Debug, Clone, Copy, ValueEnum)] @@ -85,9 +95,28 @@ enum OutputFormat { Markdown, } +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CLIExtensions { + All, + None, + Compat, + ComponentModifiers, + ComponentAlias, + AdvancedUnits, + Modes, + InlineQuantities, + RangeValues, + TimerRequiresTime, + IntermediatePreparations, +} + pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { let mut scale = args.input.scale; + // Build extensions from CLI args + let extensions = build_extensions_from_cli(&args.extension); + let parser = cooklang::CooklangParser::new(extensions, cooklang::Converter::default()); + let (recipe, title) = if let Some(query) = args.input.recipe { let (name, scaling_factor) = split_recipe_name_and_scaling_factor(query.as_str()) .map(|(name, scaling_factor)| { @@ -109,7 +138,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 = parse_recipe_from_entry_with_parser(&recipe_entry, scale, &parser)?; (recipe, recipe_entry.name().clone().unwrap_or(String::new())) } else { // Read from stdin and create a RecipeEntry @@ -123,7 +152,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 = parse_recipe_from_entry_with_parser(&recipe_entry, scale, &parser)?; (recipe, recipe_entry.name().clone().unwrap_or(String::new())) }; @@ -145,7 +174,7 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { &recipe, &title, scale, - PARSER.converter(), + parser.converter(), writer, )?, OutputFormat::Json => { @@ -163,7 +192,7 @@ pub fn run(ctx: &Context, args: ReadArgs) -> Result<()> { &recipe, &title, scale, - PARSER.converter(), + parser.converter(), writer, )?, } diff --git a/src/util/mod.rs b/src/util/mod.rs index 786107c..e76cc36 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -48,10 +48,63 @@ use tracing::warn; pub const RECIPE_SCALING_DELIMITER: char = ':'; pub static PARSER: Lazy = Lazy::new(|| { - // Use no extensions but with default converter for basic unit support + // Default: all extensions enabled for legacy use CooklangParser::new(Extensions::empty(), Converter::default()) }); +/// Build Extensions from CLI args (CLIExtensions) +use crate::recipe::CLIExtensions; +pub fn build_extensions_from_cli(exts: &[CLIExtensions]) -> Extensions { + if exts.iter().any(|e| matches!(e, CLIExtensions::All)) { + return Extensions::all(); + } + if exts.iter().any(|e| matches!(e, CLIExtensions::None)) { + return Extensions::empty(); + } + let mut extensions = Extensions::empty(); + for ext in exts { + match ext { + CLIExtensions::Compat => extensions |= Extensions::COMPAT, + CLIExtensions::ComponentModifiers => extensions |= Extensions::COMPONENT_MODIFIERS, + CLIExtensions::ComponentAlias => extensions |= Extensions::COMPONENT_ALIAS, + CLIExtensions::AdvancedUnits => extensions |= Extensions::ADVANCED_UNITS, + CLIExtensions::Modes => extensions |= Extensions::MODES, + CLIExtensions::InlineQuantities => extensions |= Extensions::INLINE_QUANTITIES, + CLIExtensions::RangeValues => extensions |= Extensions::RANGE_VALUES, + CLIExtensions::TimerRequiresTime => extensions |= Extensions::TIMER_REQUIRES_TIME, + CLIExtensions::IntermediatePreparations => { + extensions |= Extensions::INTERMEDIATE_PREPARATIONS + } + CLIExtensions::All | CLIExtensions::None => {} // handled above + } + } + extensions +} + +/// Parse a Recipe from a RecipeEntry with the given scaling factor and parser +pub fn parse_recipe_from_entry_with_parser( + entry: &RecipeEntry, + scaling_factor: f64, + parser: &CooklangParser, +) -> Result> { + let content = entry.content().context("Failed to read recipe content")?; + let parsed = parser.parse(&content); + + // Log any warnings + if parsed.report().has_warnings() { + let recipe_name = entry.name().as_deref().unwrap_or("unknown"); + for warning in parsed.report().warnings() { + warn!("Recipe '{}': {}", recipe_name, warning); + } + } + + let (mut recipe, _warnings) = parsed.into_result().context("Failed to parse recipe")?; + + // Scale the recipe + recipe.scale(scaling_factor, parser.converter()); + Ok(Arc::new(recipe)) +} + /// Parse a Recipe from a RecipeEntry with the given scaling factor pub fn parse_recipe_from_entry(entry: &RecipeEntry, scaling_factor: f64) -> Result> { let content = entry.content().context("Failed to read recipe content")?; diff --git a/tests/cli_integration_test.rs b/tests/cli_integration_test.rs index f161dfc..ba06187 100644 --- a/tests/cli_integration_test.rs +++ b/tests/cli_integration_test.rs @@ -246,3 +246,22 @@ fn test_cli_recipe_from_subdirectory() { .success() .stdout(predicate::str::contains("Pancakes")); } + +#[test] +fn test_cli_recipe_with_extensions() { + let temp_dir = common::setup_test_recipes().unwrap(); + + Command::cargo_bin("cook") + .unwrap() + .current_dir(temp_dir.path()) + .arg("recipe") + .arg("read") + .arg("aliases.cook") + .arg("-f") + .arg("json") + .arg("--extension") + .arg("component-alias") + .assert() + .success() + .stdout(predicate::str::contains("table salt")); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7c3e423..0c03705 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -47,6 +47,20 @@ Add @garlic{2%cloves} and cook for ~{2%minutes}. "#, )?; + // Create a recipe with component aliases + fs::write( + recipes_dir.join("aliases.cook"), + r#"--- +title: Simple Recipe with Aliases +servings: 2 +--- + +Boil @water{2%cups} for ~{5%minutes}. +Add @table salt|salt{1%tsp} and @pasta{200%g}. +Cook in a #pot for another ~{10%minutes}. +"#, + )?; + // Create a recipe with errors for doctor testing fs::write( recipes_dir.join("with_errors.cook"), diff --git a/tests/snapshots/snapshot_test__doctor_validate_output.snap b/tests/snapshots/snapshot_test__doctor_validate_output.snap index 9dcb227..764e35f 100644 --- a/tests/snapshots/snapshot_test__doctor_validate_output.snap +++ b/tests/snapshots/snapshot_test__doctor_validate_output.snap @@ -10,6 +10,6 @@ expression: sorted_output ❌ Missing reference: nonexistent === 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)