From 9351027d1b3170d3b098d9f20e2533f2b8213428 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 17 Jul 2025 14:38:20 +1000 Subject: [PATCH] chore: move recipe loading to goose (#3424) --- crates/goose-cli/src/cli.rs | 14 +- crates/goose-cli/src/commands/recipe.rs | 6 +- .../goose-cli/src/recipes/extract_from_cli.rs | 7 +- crates/goose-cli/src/recipes/github_recipe.rs | 8 +- crates/goose-cli/src/recipes/mod.rs | 1 - crates/goose-cli/src/recipes/print_recipe.rs | 19 +- crates/goose-cli/src/recipes/recipe.rs | 296 ++++++--------- crates/goose-cli/src/recipes/recipe/tests.rs | 315 ---------------- crates/goose-cli/src/recipes/search_recipe.rs | 50 +-- crates/goose/Cargo.toml | 2 +- crates/goose/src/recipe/build_recipe/mod.rs | 189 ++++++++++ crates/goose/src/recipe/build_recipe/tests.rs | 352 ++++++++++++++++++ crates/goose/src/recipe/mod.rs | 6 + .../src/recipe/read_recipe_file_content.rs | 46 +++ .../src/recipe}/template_recipe.rs | 35 +- 15 files changed, 749 insertions(+), 597 deletions(-) delete mode 100644 crates/goose-cli/src/recipes/recipe/tests.rs create mode 100644 crates/goose/src/recipe/build_recipe/mod.rs create mode 100644 crates/goose/src/recipe/build_recipe/tests.rs create mode 100644 crates/goose/src/recipe/read_recipe_file_content.rs rename crates/{goose-cli/src/recipes => goose/src/recipe}/template_recipe.rs (92%) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3a8eb17f..2b16521d 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -18,7 +18,7 @@ use crate::commands::schedule::{ use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::logging::setup_logging; use crate::recipes::extract_from_cli::extract_recipe_info_from_cli; -use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_content_as_template}; +use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml}; use crate::session; use crate::session::{build_session, SessionBuilderConfig, SessionSettings}; use goose_bench::bench_config::BenchRunConfig; @@ -881,16 +881,14 @@ pub async fn cli() -> Result<()> { ), (_, _, Some(recipe_name)) => { if explain { - explain_recipe_with_parameters(&recipe_name, params)?; + explain_recipe(&recipe_name, params)?; return Ok(()); } if render_recipe { - let recipe = load_recipe_content_as_template(&recipe_name, params) - .unwrap_or_else(|err| { - eprintln!("{}: {}", console::style("Error").red().bold(), err); - std::process::exit(1); - }); - println!("{}", recipe); + if let Err(err) = render_recipe_as_yaml(&recipe_name, params) { + eprintln!("{}: {}", console::style("Error").red().bold(), err); + std::process::exit(1); + } return Ok(()); } extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes)? diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 1b15a912..5e37f0c8 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -4,7 +4,7 @@ use console::style; use serde_json; use crate::recipes::github_recipe::RecipeSource; -use crate::recipes::recipe::load_recipe; +use crate::recipes::recipe::load_recipe_for_validation; use crate::recipes::search_recipe::list_available_recipes; /// Validates a recipe file @@ -18,7 +18,7 @@ use crate::recipes::search_recipe::list_available_recipes; /// Result indicating success or failure pub fn handle_validate(recipe_name: &str) -> Result<()> { // Load and validate the recipe file - match load_recipe(recipe_name) { + match load_recipe_for_validation(recipe_name) { Ok(_) => { println!("{} recipe file is valid", style("✓").green().bold()); Ok(()) @@ -41,7 +41,7 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { /// Result indicating success or failure pub fn handle_deeplink(recipe_name: &str) -> Result { // Load the recipe file first to validate it - match load_recipe(recipe_name) { + match load_recipe_for_validation(recipe_name) { Ok(recipe) => { let mut full_url = String::new(); if let Ok(recipe_json) = serde_json::to_string(&recipe) { diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index 56113b75..dff70fca 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -3,8 +3,10 @@ use std::path::PathBuf; use anyhow::{anyhow, Result}; use goose::recipe::{Response, SubRecipe}; +use crate::recipes::print_recipe::print_recipe_info; +use crate::recipes::recipe::load_recipe; use crate::recipes::search_recipe::retrieve_recipe_file; -use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings}; +use crate::{cli::InputConfig, session::SessionSettings}; #[allow(clippy::type_complexity)] pub fn extract_recipe_info_from_cli( @@ -17,10 +19,11 @@ pub fn extract_recipe_info_from_cli( Option>, Option, )> { - let recipe = load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| { + let recipe = load_recipe(&recipe_name, params.clone()).unwrap_or_else(|err| { eprintln!("{}: {}", console::style("Error").red().bold(), err); std::process::exit(1); }); + print_recipe_info(&recipe, params); let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default(); if !additional_sub_recipes.is_empty() { for sub_recipe_name in additional_sub_recipes { diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs index 0fcbbe9e..e7f92855 100644 --- a/crates/goose-cli/src/recipes/github_recipe.rs +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -1,9 +1,10 @@ use anyhow::{anyhow, Result}; use console::style; +use goose::recipe::template_recipe::parse_recipe_content; use serde::{Deserialize, Serialize}; use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; -use crate::recipes::search_recipe::RecipeFile; +use goose::recipe::read_recipe_file_content::RecipeFile; use std::env; use std::fs; use std::path::Path; @@ -330,10 +331,7 @@ fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) -> .map_err(|e| anyhow!("Failed to convert content to string: {}", e))?; // Parse the recipe content - let (recipe, _) = crate::recipes::template_recipe::parse_recipe_content( - &content, - format!("{}/{}", repo, dir_name), - )?; + let (recipe, _) = parse_recipe_content(&content, format!("{}/{}", repo, dir_name))?; return Ok(RecipeInfo { name: dir_name.to_string(), diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index a1ceef61..8dd270e2 100644 --- a/crates/goose-cli/src/recipes/mod.rs +++ b/crates/goose-cli/src/recipes/mod.rs @@ -3,4 +3,3 @@ pub mod github_recipe; pub mod print_recipe; pub mod recipe; pub mod search_recipe; -pub mod template_recipe; diff --git a/crates/goose-cli/src/recipes/print_recipe.rs b/crates/goose-cli/src/recipes/print_recipe.rs index bc43842f..be4a2339 100644 --- a/crates/goose-cli/src/recipes/print_recipe.rs +++ b/crates/goose-cli/src/recipes/print_recipe.rs @@ -1,9 +1,7 @@ use std::collections::HashMap; use console::style; -use goose::recipe::Recipe; - -use crate::recipes::recipe::BUILT_IN_RECIPE_DIR_PARAM; +use goose::recipe::{Recipe, BUILT_IN_RECIPE_DIR_PARAM}; pub fn print_recipe_explanation(recipe: &Recipe) { println!( @@ -81,3 +79,18 @@ pub fn missing_parameters_command_line(missing_params: Vec) -> String { .collect::>() .join(" ") } + +pub fn print_recipe_info(recipe: &Recipe, params: Vec<(String, String)>) { + println!( + "{} {}", + style("Loading recipe:").green().bold(), + style(&recipe.title).green() + ); + println!("{} {}", style("Description:").bold(), &recipe.description); + + if !params.is_empty() { + println!("{}", style("Parameters used to load this recipe:").bold()); + print_parameters_with_values(params.into_iter().collect()); + } + println!(); +} diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 49b0e6bf..0596f72a 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -1,89 +1,69 @@ use crate::recipes::print_recipe::{ - missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation, + missing_parameters_command_line, print_recipe_explanation, print_required_parameters_for_template, }; -use crate::recipes::search_recipe::{retrieve_recipe_file, RecipeFile}; -use crate::recipes::template_recipe::{ - parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview, -}; +use crate::recipes::search_recipe::retrieve_recipe_file; use anyhow::Result; -use console::style; -use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; -use std::collections::{HashMap, HashSet}; +use goose::recipe::build_recipe::{ + apply_values_to_parameters, build_recipe_from_template, validate_recipe_parameters, RecipeError, +}; +use goose::recipe::read_recipe_file_content::RecipeFile; +use goose::recipe::template_recipe::render_recipe_for_preview; +use goose::recipe::Recipe; +use std::collections::HashMap; -pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; -pub fn load_recipe_content_as_template( - recipe_name: &str, - params: Vec<(String, String)>, -) -> Result { - let RecipeFile { - content: recipe_file_content, - parent_dir: recipe_parent_dir, - .. - } = retrieve_recipe_file(recipe_name)?; - let recipe_dir_str = recipe_parent_dir +fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result { + |key: &str, description: &str| -> Result { + let input_value = + cliclack::input(format!("Please enter {} ({})", key, description)).interact()?; + Ok(input_value) + } +} + +fn load_recipe_file_with_dir(recipe_name: &str) -> Result<(RecipeFile, String)> { + let recipe_file = retrieve_recipe_file(recipe_name)?; + let recipe_dir_str = recipe_file + .parent_dir .to_str() - .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; - let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; + .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))? + .to_string(); + Ok((recipe_file, recipe_dir_str)) +} - let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, true)?; - - if !missing_params.is_empty() { - return Err(anyhow::anyhow!( +pub fn load_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result { + let recipe_file = retrieve_recipe_file(recipe_name)?; + match build_recipe_from_template(recipe_file, params, Some(create_user_prompt_callback())) { + Ok(recipe) => Ok(recipe), + Err(RecipeError::MissingParams { parameters }) => Err(anyhow::anyhow!( "Please provide the following parameters in the command line: {}", - missing_parameters_command_line(missing_params) - )); + missing_parameters_command_line(parameters) + )), + Err(e) => Err(anyhow::anyhow!(e.to_string())), } - render_recipe_content_with_params(&recipe_file_content, ¶ms_for_template) } -fn validate_recipe_parameters( - recipe_file_content: &str, - recipe_dir_str: &str, -) -> Result>> { - let (raw_recipe, template_variables) = - parse_recipe_content(recipe_file_content, recipe_dir_str.to_string())?; - let recipe_parameters = raw_recipe.parameters; - validate_optional_parameters(&recipe_parameters)?; - validate_parameters_in_template(&recipe_parameters, &template_variables)?; - Ok(recipe_parameters) -} - -pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result { - let rendered_content = load_recipe_content_as_template(recipe_name, params.clone())?; - let recipe = Recipe::from_content(&rendered_content)?; - - // Display information about the loaded recipe - println!( - "{} {}", - style("Loading recipe:").green().bold(), - style(&recipe.title).green() - ); - println!("{} {}", style("Description:").bold(), &recipe.description); - - if !params.is_empty() { - println!("{}", style("Parameters used to load this recipe:").bold()); - print_parameters_with_values(params.into_iter().collect()); +pub fn render_recipe_as_yaml(recipe_name: &str, params: Vec<(String, String)>) -> Result<()> { + let recipe = load_recipe(recipe_name, params)?; + match serde_yaml::to_string(&recipe) { + Ok(yaml_content) => { + println!("{}", yaml_content); + Ok(()) + } + Err(_) => { + eprintln!("Failed to serialize recipe to YAML"); + std::process::exit(1); + } } - println!(); - Ok(recipe) } -pub fn load_recipe(recipe_name: &str) -> Result { - let RecipeFile { - content: recipe_file_content, - parent_dir: recipe_parent_dir, - .. - } = retrieve_recipe_file(recipe_name)?; - let recipe_dir_str = recipe_parent_dir - .to_str() - .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; - validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; +pub fn load_recipe_for_validation(recipe_name: &str) -> Result { + let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?; + let recipe_file_content = &recipe_file.content; + validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?; let recipe = render_recipe_for_preview( - &recipe_file_content, + recipe_file_content, recipe_dir_str.to_string(), &HashMap::new(), )?; @@ -97,24 +77,19 @@ pub fn load_recipe(recipe_name: &str) -> Result { Ok(recipe) } -pub fn explain_recipe_with_parameters( - recipe_name: &str, - params: Vec<(String, String)>, -) -> Result<()> { - let RecipeFile { - content: recipe_file_content, - parent_dir: recipe_parent_dir, - .. - } = retrieve_recipe_file(recipe_name)?; - let recipe_dir_str = recipe_parent_dir - .to_str() - .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; - let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; +pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result<()> { + let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?; + let recipe_file_content = &recipe_file.content; + let recipe_parameters = validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?; - let (params_for_template, missing_params) = - apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, false)?; + let (params_for_template, missing_params) = apply_values_to_parameters( + ¶ms, + recipe_parameters, + &recipe_dir_str, + None:: Result>, + )?; let recipe = render_recipe_for_preview( - &recipe_file_content, + recipe_file_content, recipe_dir_str.to_string(), ¶ms_for_template, )?; @@ -124,110 +99,6 @@ pub fn explain_recipe_with_parameters( Ok(()) } -fn validate_parameters_in_template( - recipe_parameters: &Option>, - template_variables: &HashSet, -) -> Result<()> { - let mut template_variables = template_variables.clone(); - template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM); - - let param_keys: HashSet = recipe_parameters - .as_ref() - .unwrap_or(&vec![]) - .iter() - .map(|p| p.key.clone()) - .collect(); - - let missing_keys = template_variables - .difference(¶m_keys) - .collect::>(); - - let extra_keys = param_keys - .difference(&template_variables) - .collect::>(); - - if missing_keys.is_empty() && extra_keys.is_empty() { - return Ok(()); - } - - let mut message = String::new(); - - if !missing_keys.is_empty() { - message.push_str(&format!( - "Missing definitions for parameters in the recipe file: {}.", - missing_keys - .iter() - .map(|s| s.to_string()) - .collect::>() - .join(", ") - )); - } - - if !extra_keys.is_empty() { - message.push_str(&format!( - "\nUnnecessary parameter definitions: {}.", - extra_keys - .iter() - .map(|s| s.to_string()) - .collect::>() - .join(", ") - )); - } - Err(anyhow::anyhow!("{}", message.trim_end())) -} - -fn validate_optional_parameters(parameters: &Option>) -> Result<()> { - let optional_params_without_default_values: Vec = parameters - .as_ref() - .unwrap_or(&vec![]) - .iter() - .filter(|p| { - matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none() - }) - .map(|p| p.key.clone()) - .collect(); - - if optional_params_without_default_values.is_empty() { - Ok(()) - } else { - Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", "))) - } -} - -fn apply_values_to_parameters( - user_params: &[(String, String)], - recipe_parameters: Option>, - recipe_parent_dir: &str, - enable_user_prompt: bool, -) -> Result<(HashMap, Vec)> { - let mut param_map: HashMap = user_params.iter().cloned().collect(); - param_map.insert( - BUILT_IN_RECIPE_DIR_PARAM.to_string(), - recipe_parent_dir.to_string(), - ); - let mut missing_params: Vec = Vec::new(); - for param in recipe_parameters.unwrap_or_default() { - if !param_map.contains_key(¶m.key) { - match (¶m.default, ¶m.requirement) { - (Some(default), _) => param_map.insert(param.key.clone(), default.clone()), - (None, RecipeParameterRequirement::UserPrompt) if enable_user_prompt => { - let input_value = cliclack::input(format!( - "Please enter {} ({})", - param.key, param.description - )) - .interact()?; - param_map.insert(param.key.clone(), input_value) - } - _ => { - missing_params.push(param.key.clone()); - None - } - }; - } - } - Ok((param_map, missing_params)) -} - fn validate_json_schema(schema: &serde_json::Value) -> Result<()> { match jsonschema::validator_for(schema) { Ok(_) => Ok(()), @@ -236,4 +107,49 @@ fn validate_json_schema(schema: &serde_json::Value) -> Result<()> { } #[cfg(test)] -mod tests; +mod tests { + use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; + + use crate::recipes::recipe::load_recipe; + + mod load_recipe { + use super::*; + #[test] + fn test_load_recipe_success() { + let recipe_content = r#"{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + "instructions": "Test instructions with {{ my_name }}", + "parameters": [ + { + "key": "my_name", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ] + }"#; + let temp_dir = tempfile::tempdir().unwrap(); + let recipe_path = temp_dir.path().join("test_recipe.json"); + std::fs::write(&recipe_path, recipe_content).unwrap(); + + let params = vec![("my_name".to_string(), "value".to_string())]; + let recipe = load_recipe(recipe_path.to_str().unwrap(), params).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions with value"); + // Verify parameters match recipe definition + assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1); + let param = &recipe.parameters.as_ref().unwrap()[0]; + assert_eq!(param.key, "my_name"); + assert!(matches!(param.input_type, RecipeParameterInputType::String)); + assert!(matches!( + param.requirement, + RecipeParameterRequirement::Required + )); + assert_eq!(param.description, "A test parameter"); + } + } +} diff --git a/crates/goose-cli/src/recipes/recipe/tests.rs b/crates/goose-cli/src/recipes/recipe/tests.rs deleted file mode 100644 index 4b27601f..00000000 --- a/crates/goose-cli/src/recipes/recipe/tests.rs +++ /dev/null @@ -1,315 +0,0 @@ -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; - use tempfile::TempDir; - - use crate::recipes::recipe::load_recipe_as_template; - - fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, PathBuf) { - let recipe_content = format!( - r#"{{ - "version": "1.0.0", - "title": "Test Recipe", - "description": "A test recipe", - {} - }}"#, - instructions_and_parameters - ); - let temp_dir = tempfile::tempdir().unwrap(); - let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.json"); - - std::fs::write(&recipe_path, recipe_content).unwrap(); - (temp_dir, recipe_path) - } - - mod load_recipe_as_template_tests { - use super::*; - #[test] - fn test_load_recipe_as_template_success() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ my_name }}", - "parameters": [ - { - "key": "my_name", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions with value"); - // Verify parameters match recipe definition - assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1); - let param = &recipe.parameters.as_ref().unwrap()[0]; - assert_eq!(param.key, "my_name"); - assert!(matches!(param.input_type, RecipeParameterInputType::String)); - assert!(matches!( - param.requirement, - RecipeParameterRequirement::Required - )); - assert_eq!(param.description, "A test parameter"); - } - - #[test] - fn test_load_recipe_as_template_success_variable_in_prompt() { - let instructions_and_parameters = r#" - "instructions": "Test instructions", - "prompt": "My prompt {{ my_name }}", - "parameters": [ - { - "key": "my_name", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions"); - assert_eq!(recipe.prompt.unwrap(), "My prompt value"); - let param = &recipe.parameters.as_ref().unwrap()[0]; - assert_eq!(param.key, "my_name"); - assert!(matches!(param.input_type, RecipeParameterInputType::String)); - assert!(matches!( - param.requirement, - RecipeParameterRequirement::Required - )); - assert_eq!(param.description, "A test parameter"); - } - - #[test] - fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}", - "parameters": [ - { - "key": "wrong_param_key", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = - load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - println!("{}", err.to_string()); - assert!(err - .to_string() - .contains("Unnecessary parameter definitions: wrong_param_key.")); - assert!(err - .to_string() - .contains("Missing definitions for parameters in the recipe file:")); - assert!(err.to_string().contains("expected_param1")); - assert!(err.to_string().contains("expected_param2")); - } - - #[test] - fn test_load_recipe_as_template_with_default_values_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}", - "parameters": [ - { - "key": "param_with_default", - "input_type": "string", - "requirement": "optional", - "default": "my_default_value", - "description": "A test parameter" - }, - { - "key": "param_without_default", - "input_type": "string", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let params = vec![("param_without_default".to_string(), "value1".to_string())]; - - let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); - - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!( - recipe.instructions.unwrap(), - "Test instructions with my_default_value value1" - ); - } - - #[test] - fn test_load_recipe_as_template_optional_parameters_with_empty_default_values_in_recipe_file( - ) { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ optional_param }}", - "parameters": [ - { - "key": "optional_param", - "input_type": "string", - "requirement": "optional", - "description": "A test parameter", - "default": "", - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let recipe = - load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); - assert_eq!(recipe.title, "Test Recipe"); - assert_eq!(recipe.description, "A test recipe"); - assert_eq!(recipe.instructions.unwrap(), "Test instructions with "); - } - - #[test] - fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() - { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ optional_param }}", - "parameters": [ - { - "key": "optional_param", - "input_type": "string", - "requirement": "optional", - "description": "A test parameter" - } - ]"#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = - load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - println!("{}", err.to_string()); - assert!(err.to_string().to_lowercase().contains("missing")); - } - - #[test] - fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() { - let instructions_and_parameters = r#" - "instructions": "Test instructions with {{ param }}", - "parameters": [ - { - "key": "param", - "input_type": "some_invalid_type", - "requirement": "required", - "description": "A test parameter" - } - ]"#; - let params = vec![("param".to_string(), "value".to_string())]; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params); - assert!(load_recipe_result.is_err()); - let err = load_recipe_result.unwrap_err(); - let err_msg = err.to_string(); - eprint!("Error: {}", err_msg); - assert!(err_msg.contains("unknown variant `some_invalid_type`")); - } - - #[test] - fn test_load_recipe_as_template_success_without_parameters() { - let instructions_and_parameters = r#" - "instructions": "Test instructions" - "#; - let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - - let recipe = - load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); - assert_eq!(recipe.instructions.unwrap(), "Test instructions"); - assert!(recipe.parameters.is_none()); - } - - #[test] - fn test_template_inheritance() { - let temp_dir = tempfile::tempdir().unwrap(); - let temp_path = temp_dir.path(); - let parent_content = r#" - version: 1.0.0 - title: Parent - description: Parent recipe - prompt: | - show me the news for day: {{ date }} - {% block prompt -%} - What is the capital of France? - {%- endblock %} - {% if is_enabled %} - Feature is enabled. - {% else %} - Feature is disabled. - {% endif %} - parameters: - - key: date - input_type: string - requirement: required - description: date specified by the user - - key: is_enabled - input_type: boolean - requirement: required - description: whether the feature is enabled - "#; - - let parent_path = temp_path.join("parent.yaml"); - std::fs::write(&parent_path, parent_content).unwrap(); - let child_content = r#" - {% extends "parent.yaml" -%} - {% block prompt -%} - What is the capital of Germany? - {%- endblock %} - "#; - let child_path = temp_path.join("child.yaml"); - std::fs::write(&child_path, child_content).unwrap(); - - let params = vec![ - ("date".to_string(), "today".to_string()), - ("is_enabled".to_string(), "true".to_string()), - ]; - let parent_result = - load_recipe_as_template(parent_path.to_str().unwrap(), params.clone()); - assert!(parent_result.is_ok()); - let parent_recipe = parent_result.unwrap(); - assert_eq!(parent_recipe.description, "Parent recipe"); - assert_eq!( - parent_recipe.prompt.unwrap(), - "show me the news for day: today\nWhat is the capital of France?\n\n Feature is enabled.\n" - ); - assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 2); - assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date"); - assert_eq!( - parent_recipe.parameters.as_ref().unwrap()[1].key, - "is_enabled" - ); - - let child_result = load_recipe_as_template(child_path.to_str().unwrap(), params); - assert!(child_result.is_ok()); - let child_recipe = child_result.unwrap(); - assert_eq!(child_recipe.title, "Parent"); - assert_eq!(child_recipe.description, "Parent recipe"); - assert_eq!( - child_recipe.prompt.unwrap().trim(), - "show me the news for day: today\nWhat is the capital of Germany?\n\n Feature is enabled." - ); - assert_eq!(child_recipe.parameters.as_ref().unwrap().len(), 2); - assert_eq!(child_recipe.parameters.as_ref().unwrap()[0].key, "date"); - assert_eq!( - child_recipe.parameters.as_ref().unwrap()[1].key, - "is_enabled" - ); - } - } -} diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs index 6b998227..8854e0dc 100644 --- a/crates/goose-cli/src/recipes/search_recipe.rs +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -1,7 +1,10 @@ use anyhow::{anyhow, Result}; use goose::config::Config; +use goose::recipe::read_recipe_file_content::{read_recipe_file, RecipeFile}; +use goose::recipe::template_recipe::parse_recipe_content; +use std::env; +use std::fs; use std::path::{Path, PathBuf}; -use std::{env, fs}; use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; @@ -12,12 +15,6 @@ use super::github_recipe::{ const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; -pub struct RecipeFile { - pub content: String, - pub parent_dir: PathBuf, - pub file_path: PathBuf, -} - pub fn retrieve_recipe_file(recipe_name: &str) -> Result { if RECIPE_FILE_EXTENSIONS .iter() @@ -103,43 +100,6 @@ fn configured_github_recipe_repo() -> Option { } } -fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf { - if let Some(path_str) = path.to_str() { - if let Some(stripped) = path_str.strip_prefix("~/") { - if let Some(home_dir) = dirs::home_dir() { - return home_dir.join(stripped); - } - } - } - PathBuf::from(path) -} - -fn read_recipe_file>(recipe_path: P) -> Result { - let raw_path = recipe_path.as_ref(); - let path = convert_path_with_tilde_expansion(raw_path); - - let content = fs::read_to_string(&path) - .map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?; - let canonical = path.canonicalize().map_err(|e| { - anyhow!( - "Failed to resolve absolute path for {}: {}", - path.display(), - e - ) - })?; - - let parent_dir = canonical - .parent() - .ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))? - .to_path_buf(); - - Ok(RecipeFile { - content, - parent_dir, - file_path: canonical, - }) -} - /// Lists all available recipes from local paths and GitHub repositories pub fn list_available_recipes() -> Result> { let mut recipes = Vec::new(); @@ -214,7 +174,7 @@ fn create_local_recipe_info(path: &Path) -> Result { .unwrap_or_else(|| Path::new(".")) .to_string_lossy() .to_string(); - let (recipe, _) = crate::recipes::template_recipe::parse_recipe_content(&content, recipe_dir)?; + let (recipe, _) = parse_recipe_content(&content, recipe_dir)?; let name = path .file_stem() diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index 87618570..d3b63c91 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -43,7 +43,7 @@ uuid = { version = "1.0", features = ["v4"] } regex = "1.11.1" async-trait = "0.1" async-stream = "0.3" -minijinja = "2.8.0" +minijinja = { version = "2.10.2", features = ["loader"] } include_dir = "0.7.4" tiktoken-rs = "0.6.0" chrono = { version = "0.4.38", features = ["serde"] } diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs new file mode 100644 index 00000000..d567f1ca --- /dev/null +++ b/crates/goose/src/recipe/build_recipe/mod.rs @@ -0,0 +1,189 @@ +use crate::recipe::read_recipe_file_content::RecipeFile; +use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params}; +use crate::recipe::{ + Recipe, RecipeParameter, RecipeParameterRequirement, BUILT_IN_RECIPE_DIR_PARAM, +}; +use anyhow::Result; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, thiserror::Error)] +pub enum RecipeError { + #[error("Missing required parameters: {parameters:?}")] + MissingParams { parameters: Vec }, + #[error("Template rendering failed: {source}")] + TemplateRendering { source: anyhow::Error }, + #[error("Recipe parsing failed: {source}")] + RecipeParsing { source: anyhow::Error }, +} + +pub fn render_recipe_template( + recipe_file: RecipeFile, + params: Vec<(String, String)>, + user_prompt_fn: Option, +) -> Result<(String, Vec)> +where + F: Fn(&str, &str) -> Result, +{ + let RecipeFile { + content: recipe_file_content, + parent_dir: recipe_parent_dir, + .. + } = recipe_file; + let recipe_dir_str = recipe_parent_dir + .to_str() + .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; + let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?; + + let (params_for_template, missing_params) = + apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, user_prompt_fn)?; + + let rendered_content = if missing_params.is_empty() { + render_recipe_content_with_params(&recipe_file_content, ¶ms_for_template)? + } else { + String::new() + }; + + Ok((rendered_content, missing_params)) +} + +pub fn validate_recipe_parameters( + recipe_file_content: &str, + recipe_dir_str: &str, +) -> Result>> { + let (raw_recipe, template_variables) = + parse_recipe_content(recipe_file_content, recipe_dir_str.to_string())?; + let recipe_parameters = raw_recipe.parameters; + validate_optional_parameters(&recipe_parameters)?; + validate_parameters_in_template(&recipe_parameters, &template_variables)?; + Ok(recipe_parameters) +} + +pub fn build_recipe_from_template( + recipe_file: RecipeFile, + params: Vec<(String, String)>, + user_prompt_fn: Option, +) -> Result +where + F: Fn(&str, &str) -> Result, +{ + let (rendered_content, missing_params) = + render_recipe_template(recipe_file, params.clone(), user_prompt_fn) + .map_err(|source| RecipeError::TemplateRendering { source })?; + + if !missing_params.is_empty() { + return Err(RecipeError::MissingParams { + parameters: missing_params, + }); + } + + let recipe = Recipe::from_content(&rendered_content) + .map_err(|source| RecipeError::RecipeParsing { source })?; + Ok(recipe) +} + +fn validate_parameters_in_template( + recipe_parameters: &Option>, + template_variables: &HashSet, +) -> Result<()> { + let mut template_variables = template_variables.clone(); + template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM); + + let param_keys: HashSet = recipe_parameters + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|p| p.key.clone()) + .collect(); + + let missing_keys = template_variables + .difference(¶m_keys) + .collect::>(); + + let extra_keys = param_keys + .difference(&template_variables) + .collect::>(); + + if missing_keys.is_empty() && extra_keys.is_empty() { + return Ok(()); + } + + let mut message = String::new(); + + if !missing_keys.is_empty() { + message.push_str(&format!( + "Missing definitions for parameters in the recipe file: {}.", + missing_keys + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(", ") + )); + } + + if !extra_keys.is_empty() { + message.push_str(&format!( + "\nUnnecessary parameter definitions: {}.", + extra_keys + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(", ") + )); + } + Err(anyhow::anyhow!("{}", message.trim_end())) +} + +fn validate_optional_parameters(parameters: &Option>) -> Result<()> { + let optional_params_without_default_values: Vec = parameters + .as_ref() + .unwrap_or(&vec![]) + .iter() + .filter(|p| { + matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none() + }) + .map(|p| p.key.clone()) + .collect(); + + if optional_params_without_default_values.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!("Optional parameters missing default values in the recipe: {}. Please provide defaults.", optional_params_without_default_values.join(", "))) + } +} + +pub fn apply_values_to_parameters( + user_params: &[(String, String)], + recipe_parameters: Option>, + recipe_parent_dir: &str, + user_prompt_fn: Option, +) -> Result<(HashMap, Vec)> +where + F: Fn(&str, &str) -> Result, +{ + let mut param_map: HashMap = user_params.iter().cloned().collect(); + param_map.insert( + BUILT_IN_RECIPE_DIR_PARAM.to_string(), + recipe_parent_dir.to_string(), + ); + let mut missing_params: Vec = Vec::new(); + for param in recipe_parameters.unwrap_or_default() { + if !param_map.contains_key(¶m.key) { + match (¶m.default, ¶m.requirement) { + (Some(default), _) => param_map.insert(param.key.clone(), default.clone()), + (None, RecipeParameterRequirement::UserPrompt) if user_prompt_fn.is_some() => { + let input_value = + user_prompt_fn.as_ref().unwrap()(¶m.key, ¶m.description)?; + param_map.insert(param.key.clone(), input_value) + } + _ => { + missing_params.push(param.key.clone()); + None + } + }; + } + } + Ok((param_map, missing_params)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/goose/src/recipe/build_recipe/tests.rs b/crates/goose/src/recipe/build_recipe/tests.rs new file mode 100644 index 00000000..acd6dbbe --- /dev/null +++ b/crates/goose/src/recipe/build_recipe/tests.rs @@ -0,0 +1,352 @@ +#[cfg(test)] +mod tests { + use crate::recipe::build_recipe::{build_recipe_from_template, RecipeError}; + use crate::recipe::read_recipe_file_content::RecipeFile; + use crate::recipe::{RecipeParameterInputType, RecipeParameterRequirement}; + use tempfile::TempDir; + + const NO_USER_PROMPT: Option Result> = None; + + fn setup_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) { + let recipe_content = format!( + r#"{{ + "version": "1.0.0", + "title": "Test Recipe", + "description": "A test recipe", + {} + }}"#, + instructions_and_parameters + ); + let temp_dir = tempfile::tempdir().unwrap(); + let recipe_path = temp_dir.path().join("test_recipe.json"); + + std::fs::write(&recipe_path, recipe_content).unwrap(); + + let recipe_file = RecipeFile { + content: std::fs::read_to_string(&recipe_path).unwrap(), + parent_dir: temp_dir.path().to_path_buf(), + file_path: recipe_path, + }; + + (temp_dir, recipe_file) + } + + fn setup_yaml_recipe_files( + parent_content: &str, + child_content: &str, + ) -> (TempDir, RecipeFile, RecipeFile) { + let temp_dir = tempfile::tempdir().unwrap(); + let temp_path = temp_dir.path(); + + let parent_path = temp_path.join("parent.yaml"); + std::fs::write(&parent_path, parent_content).unwrap(); + + let child_path = temp_path.join("child.yaml"); + std::fs::write(&child_path, child_content).unwrap(); + + let parent_recipe_file = RecipeFile { + content: std::fs::read_to_string(&parent_path).unwrap(), + parent_dir: temp_path.to_path_buf(), + file_path: parent_path, + }; + + let child_recipe_file = RecipeFile { + content: std::fs::read_to_string(&child_path).unwrap(), + parent_dir: temp_path.to_path_buf(), + file_path: child_path, + }; + + (temp_dir, parent_recipe_file, child_recipe_file) + } + + #[test] + fn test_build_recipe_from_template_success() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ my_name }}", + "parameters": [ + { + "key": "my_name", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let params = vec![("my_name".to_string(), "value".to_string())]; + let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions with value"); + // Verify parameters match recipe definition + assert_eq!(recipe.parameters.as_ref().unwrap().len(), 1); + let param = &recipe.parameters.as_ref().unwrap()[0]; + assert_eq!(param.key, "my_name"); + assert!(matches!(param.input_type, RecipeParameterInputType::String)); + assert!(matches!( + param.requirement, + RecipeParameterRequirement::Required + )); + assert_eq!(param.description, "A test parameter"); + } + + #[test] + fn test_build_recipe_from_template_success_variable_in_prompt() { + let instructions_and_parameters = r#" + "instructions": "Test instructions", + "prompt": "My prompt {{ my_name }}", + "parameters": [ + { + "key": "my_name", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let params = vec![("my_name".to_string(), "value".to_string())]; + let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions"); + assert_eq!(recipe.prompt.unwrap(), "My prompt value"); + let param = &recipe.parameters.as_ref().unwrap()[0]; + assert_eq!(param.key, "my_name"); + assert!(matches!(param.input_type, RecipeParameterInputType::String)); + assert!(matches!( + param.requirement, + RecipeParameterRequirement::Required + )); + assert_eq!(param.description, "A test parameter"); + } + + #[test] + fn test_build_recipe_from_template_wrong_parameters_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}", + "parameters": [ + { + "key": "wrong_param_key", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let build_recipe_result = + build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT); + assert!(build_recipe_result.is_err()); + let err = build_recipe_result.unwrap_err(); + println!("{}", err.to_string()); + + match err { + RecipeError::TemplateRendering { source } => { + let err_str = source.to_string(); + assert!(err_str.contains("Unnecessary parameter definitions: wrong_param_key.")); + assert!(err_str.contains("Missing definitions for parameters in the recipe file:")); + assert!(err_str.contains("expected_param1")); + assert!(err_str.contains("expected_param2")); + } + _ => panic!("Expected TemplateRendering error"), + } + } + + #[test] + fn test_build_recipe_from_template_with_default_values_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}", + "parameters": [ + { + "key": "param_with_default", + "input_type": "string", + "requirement": "optional", + "default": "my_default_value", + "description": "A test parameter" + }, + { + "key": "param_without_default", + "input_type": "string", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + let params = vec![("param_without_default".to_string(), "value1".to_string())]; + + let recipe = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT).unwrap(); + + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!( + recipe.instructions.unwrap(), + "Test instructions with my_default_value value1" + ); + } + + #[test] + fn test_build_recipe_from_template_optional_parameters_with_empty_default_values_in_recipe_file( + ) { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ optional_param }}", + "parameters": [ + { + "key": "optional_param", + "input_type": "string", + "requirement": "optional", + "description": "A test parameter", + "default": "" + } + ]"#; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap(); + assert_eq!(recipe.title, "Test Recipe"); + assert_eq!(recipe.description, "A test recipe"); + assert_eq!(recipe.instructions.unwrap(), "Test instructions with "); + } + + #[test] + fn test_build_recipe_from_template_optional_parameters_without_default_values_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ optional_param }}", + "parameters": [ + { + "key": "optional_param", + "input_type": "string", + "requirement": "optional", + "description": "A test parameter" + } + ]"#; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let build_recipe_result = + build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT); + assert!(build_recipe_result.is_err()); + let err = build_recipe_result.unwrap_err(); + println!("{}", err.to_string()); + match err { + RecipeError::TemplateRendering { source } => { + assert!(source.to_string().to_lowercase().contains("missing")); + } + _ => panic!("Expected TemplateRendering error"), + } + } + + #[test] + fn test_build_recipe_from_template_wrong_input_type_in_recipe_file() { + let instructions_and_parameters = r#" + "instructions": "Test instructions with {{ param }}", + "parameters": [ + { + "key": "param", + "input_type": "some_invalid_type", + "requirement": "required", + "description": "A test parameter" + } + ]"#; + let params = vec![("param".to_string(), "value".to_string())]; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let build_recipe_result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT); + assert!(build_recipe_result.is_err()); + let err = build_recipe_result.unwrap_err(); + match err { + RecipeError::TemplateRendering { source } => { + let err_msg = source.to_string(); + eprint!("Error: {}", err_msg); + assert!(err_msg.contains("unknown variant `some_invalid_type`")); + } + _ => panic!("Expected TemplateRendering error, got: {:?}", err), + } + } + + #[test] + fn test_build_recipe_from_template_success_without_parameters() { + let instructions_and_parameters = r#" + "instructions": "Test instructions" + "#; + let (_temp_dir, recipe_file) = setup_recipe_file(instructions_and_parameters); + + let recipe = build_recipe_from_template(recipe_file, Vec::new(), NO_USER_PROMPT).unwrap(); + assert_eq!(recipe.instructions.unwrap(), "Test instructions"); + assert!(recipe.parameters.is_none()); + } + + #[test] + fn test_template_inheritance() { + let parent_content = r#" + version: 1.0.0 + title: Parent + description: Parent recipe + prompt: | + show me the news for day: {{ date }} + {% block prompt -%} + What is the capital of France? + {%- endblock %} + {% if is_enabled %} + Feature is enabled. + {% else %} + Feature is disabled. + {% endif %} + parameters: + - key: date + input_type: string + requirement: required + description: date specified by the user + - key: is_enabled + input_type: boolean + requirement: required + description: whether the feature is enabled + "#; + + let child_content = r#" + {% extends "parent.yaml" -%} + {% block prompt -%} + What is the capital of Germany? + {%- endblock %} + "#; + + let (_temp_dir, parent_recipe_file, child_recipe_file) = + setup_yaml_recipe_files(parent_content, child_content); + + let params = vec![ + ("date".to_string(), "today".to_string()), + ("is_enabled".to_string(), "true".to_string()), + ]; + + let parent_recipe = + build_recipe_from_template(parent_recipe_file, params.clone(), NO_USER_PROMPT).unwrap(); + assert_eq!(parent_recipe.description, "Parent recipe"); + assert_eq!( + parent_recipe.prompt.unwrap(), + "show me the news for day: today\nWhat is the capital of France?\n\n Feature is enabled.\n" + ); + assert_eq!(parent_recipe.parameters.as_ref().unwrap().len(), 2); + assert_eq!(parent_recipe.parameters.as_ref().unwrap()[0].key, "date"); + assert_eq!( + parent_recipe.parameters.as_ref().unwrap()[1].key, + "is_enabled" + ); + + let child_recipe = + build_recipe_from_template(child_recipe_file, params, NO_USER_PROMPT).unwrap(); + assert_eq!(child_recipe.title, "Parent"); + assert_eq!(child_recipe.description, "Parent recipe"); + assert_eq!( + child_recipe.prompt.unwrap().trim(), + "show me the news for day: today\nWhat is the capital of Germany?\n\n Feature is enabled." + ); + assert_eq!(child_recipe.parameters.as_ref().unwrap().len(), 2); + assert_eq!(child_recipe.parameters.as_ref().unwrap()[0].key, "date"); + assert_eq!( + child_recipe.parameters.as_ref().unwrap()[1].key, + "is_enabled" + ); + } +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 135cdd32..8b0144a9 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -7,6 +7,12 @@ use crate::agents::extension::ExtensionConfig; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; +pub mod build_recipe; +pub mod read_recipe_file_content; +pub mod template_recipe; + +pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; + fn default_version() -> String { "1.0.0".to_string() } diff --git a/crates/goose/src/recipe/read_recipe_file_content.rs b/crates/goose/src/recipe/read_recipe_file_content.rs new file mode 100644 index 00000000..1aeac3bc --- /dev/null +++ b/crates/goose/src/recipe/read_recipe_file_content.rs @@ -0,0 +1,46 @@ +use anyhow::{anyhow, Result}; +use std::fs; +use std::path::{Path, PathBuf}; +pub struct RecipeFile { + pub content: String, + pub parent_dir: PathBuf, + pub file_path: PathBuf, +} + +pub fn read_recipe_file>(recipe_path: P) -> Result { + let raw_path = recipe_path.as_ref(); + let path = convert_path_with_tilde_expansion(raw_path); + + let content = fs::read_to_string(&path) + .map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?; + + let canonical = path.canonicalize().map_err(|e| { + anyhow!( + "Failed to resolve absolute path for {}: {}", + path.display(), + e + ) + })?; + + let parent_dir = canonical + .parent() + .ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))? + .to_path_buf(); + + Ok(RecipeFile { + content, + parent_dir, + file_path: canonical, + }) +} + +fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf { + if let Some(path_str) = path.to_str() { + if let Some(stripped) = path_str.strip_prefix("~/") { + if let Some(home_dir) = dirs::home_dir() { + return home_dir.join(stripped); + } + } + } + PathBuf::from(path) +} diff --git a/crates/goose-cli/src/recipes/template_recipe.rs b/crates/goose/src/recipe/template_recipe.rs similarity index 92% rename from crates/goose-cli/src/recipes/template_recipe.rs rename to crates/goose/src/recipe/template_recipe.rs index aed1fe1d..7396bb99 100644 --- a/crates/goose-cli/src/recipes/template_recipe.rs +++ b/crates/goose/src/recipe/template_recipe.rs @@ -3,14 +3,14 @@ use std::{ path::Path, }; +use crate::recipe::{Recipe, BUILT_IN_RECIPE_DIR_PARAM}; use anyhow::Result; -use goose::recipe::Recipe; use minijinja::{Environment, UndefinedBehavior}; use regex::Regex; -use crate::recipes::recipe::BUILT_IN_RECIPE_DIR_PARAM; - const CURRENT_TEMPLATE_NAME: &str = "current_template"; +const OPEN_BRACE: &str = "{{"; +const CLOSE_BRACE: &str = "}}"; fn preprocess_template_variables(content: &str) -> Result { let all_template_variables = extract_template_variables(content); @@ -27,6 +27,7 @@ fn extract_template_variables(content: &str) -> Vec { .collect() } +// filter out variables that are not only alphanumeric and underscores fn filter_complex_variables(template_variables: &[String]) -> Vec { let valid_var_re = Regex::new(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$").unwrap(); template_variables @@ -40,15 +41,14 @@ fn filter_unparseable_variables(template_variables: &[String]) -> Result, -) -> Result { - let preprocessed_content = preprocess_template_variables(content)?; - - let mut env = minijinja::Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Lenient); - let template = env.template_from_str(&preprocessed_content)?; - let rendered_content = template.render(params)?; - Ok(rendered_content) -} - fn add_template_in_env( content: &str, recipe_dir: String, @@ -218,7 +205,7 @@ mod tests { mod render_content_with_params_tests { use std::collections::HashMap; - use crate::recipes::template_recipe::render_recipe_content_with_params; + use crate::recipe::template_recipe::render_recipe_content_with_params; #[test] fn test_render_content_with_params() {