chore: move recipe loading to goose (#3424)
This commit is contained in:
@@ -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)?
|
||||
|
||||
@@ -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<String> {
|
||||
// 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) {
|
||||
|
||||
@@ -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<Vec<SubRecipe>>,
|
||||
Option<Response>,
|
||||
)> {
|
||||
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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -3,4 +3,3 @@ pub mod github_recipe;
|
||||
pub mod print_recipe;
|
||||
pub mod recipe;
|
||||
pub mod search_recipe;
|
||||
pub mod template_recipe;
|
||||
|
||||
@@ -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>) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.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!();
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
|key: &str, description: &str| -> Result<String> {
|
||||
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<Recipe> {
|
||||
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<Option<Vec<RecipeParameter>>> {
|
||||
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<Recipe> {
|
||||
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<Recipe> {
|
||||
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<Recipe> {
|
||||
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<Recipe> {
|
||||
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::<fn(&str, &str) -> Result<String>>,
|
||||
)?;
|
||||
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<Vec<RecipeParameter>>,
|
||||
template_variables: &HashSet<String>,
|
||||
) -> Result<()> {
|
||||
let mut template_variables = template_variables.clone();
|
||||
template_variables.remove(BUILT_IN_RECIPE_DIR_PARAM);
|
||||
|
||||
let param_keys: HashSet<String> = recipe_parameters
|
||||
.as_ref()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.map(|p| p.key.clone())
|
||||
.collect();
|
||||
|
||||
let missing_keys = template_variables
|
||||
.difference(¶m_keys)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let extra_keys = param_keys
|
||||
.difference(&template_variables)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if !extra_keys.is_empty() {
|
||||
message.push_str(&format!(
|
||||
"\nUnnecessary parameter definitions: {}.",
|
||||
extra_keys
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
Err(anyhow::anyhow!("{}", message.trim_end()))
|
||||
}
|
||||
|
||||
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
|
||||
let optional_params_without_default_values: Vec<String> = 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<Vec<RecipeParameter>>,
|
||||
recipe_parent_dir: &str,
|
||||
enable_user_prompt: bool,
|
||||
) -> Result<(HashMap<String, String>, Vec<String>)> {
|
||||
let mut param_map: HashMap<String, String> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RecipeFile> {
|
||||
if RECIPE_FILE_EXTENSIONS
|
||||
.iter()
|
||||
@@ -103,43 +100,6 @@ fn configured_github_recipe_repo() -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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<P: AsRef<Path>>(recipe_path: P) -> Result<RecipeFile> {
|
||||
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<Vec<RecipeInfo>> {
|
||||
let mut recipes = Vec::new();
|
||||
@@ -214,7 +174,7 @@ fn create_local_recipe_info(path: &Path) -> Result<RecipeInfo> {
|
||||
.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()
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
fn preprocess_template_variables(content: &str) -> Result<String> {
|
||||
let all_template_variables = extract_template_variables(content);
|
||||
let complex_template_variables = filter_complex_variables(&all_template_variables);
|
||||
let unparsable_template_variables = filter_unparseable_variables(&complex_template_variables)?;
|
||||
replace_unparseable_vars_with_raw(content, &unparsable_template_variables)
|
||||
}
|
||||
|
||||
fn extract_template_variables(content: &str) -> Vec<String> {
|
||||
let template_var_re = Regex::new(r"\{\{(.*?)\}\}").unwrap();
|
||||
template_var_re
|
||||
.captures_iter(content)
|
||||
.map(|cap| cap[1].to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_complex_variables(template_variables: &[String]) -> Vec<String> {
|
||||
let valid_var_re = Regex::new(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*$").unwrap();
|
||||
template_variables
|
||||
.iter()
|
||||
.filter(|var| !valid_var_re.is_match(var))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_unparseable_variables(template_variables: &[String]) -> Result<Vec<String>> {
|
||||
let mut vars_to_convert = Vec::new();
|
||||
|
||||
for var in template_variables {
|
||||
// Create individual environment for each validation
|
||||
let mut env = Environment::new();
|
||||
env.set_undefined_behavior(UndefinedBehavior::Lenient);
|
||||
|
||||
let test_template = format!(
|
||||
"{open}{content}{close}",
|
||||
open = "{{",
|
||||
content = var,
|
||||
close = "}}"
|
||||
);
|
||||
if env.template_from_str(&test_template).is_err() {
|
||||
vars_to_convert.push(var.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vars_to_convert)
|
||||
}
|
||||
|
||||
fn replace_unparseable_vars_with_raw(
|
||||
content: &str,
|
||||
unparsable_template_variables: &[String],
|
||||
) -> Result<String> {
|
||||
let mut result = content.to_string();
|
||||
|
||||
for var in unparsable_template_variables {
|
||||
let pattern = format!(
|
||||
"{open}{content}{close}",
|
||||
open = "{{",
|
||||
content = var,
|
||||
close = "}}"
|
||||
);
|
||||
let replacement = format!(
|
||||
"{{% raw %}}{open}{content}{close}{{% endraw %}}",
|
||||
open = "{{",
|
||||
close = "}}",
|
||||
content = var
|
||||
);
|
||||
result = result.replace(&pattern, &replacement);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn render_recipe_content_with_params(
|
||||
content: &str,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
// Pre-process content to replace empty double quotes with single quotes
|
||||
// This prevents MiniJinja from escaping "" to "\"\"" which would break YAML parsing
|
||||
let re = Regex::new(r#":\s*"""#).unwrap();
|
||||
let content_with_empty_quotes_replaced = re.replace_all(content, ": ''");
|
||||
|
||||
// Pre-process template variables to convert invalid variable names to raw content
|
||||
let content_with_safe_variables =
|
||||
preprocess_template_variables(&content_with_empty_quotes_replaced)?;
|
||||
|
||||
let env = add_template_in_env(
|
||||
&content_with_safe_variables,
|
||||
params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(),
|
||||
UndefinedBehavior::Strict,
|
||||
)?;
|
||||
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
|
||||
let rendered_content = template
|
||||
.render(params)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to render the recipe {}", e))?;
|
||||
Ok(rendered_content)
|
||||
}
|
||||
|
||||
pub fn render_recipe_silent_when_variables_are_provided(
|
||||
content: &str,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Result<String> {
|
||||
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,
|
||||
undefined_behavior: UndefinedBehavior,
|
||||
) -> Result<Environment> {
|
||||
let mut env = minijinja::Environment::new();
|
||||
env.set_undefined_behavior(undefined_behavior);
|
||||
env.set_loader(move |name| {
|
||||
let path = Path::new(recipe_dir.as_str()).join(name);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(minijinja::Error::new(
|
||||
minijinja::ErrorKind::InvalidOperation,
|
||||
"could not read template",
|
||||
)
|
||||
.with_source(e)),
|
||||
}
|
||||
});
|
||||
|
||||
env.add_template(CURRENT_TEMPLATE_NAME, content)?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
fn get_env_with_template_variables(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
undefined_behavior: UndefinedBehavior,
|
||||
) -> Result<(Environment, HashSet<String>)> {
|
||||
let env = add_template_in_env(content, recipe_dir, undefined_behavior)?;
|
||||
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
|
||||
let state = template.eval_to_state(())?;
|
||||
let mut template_variables = HashSet::new();
|
||||
for (_, template) in state.env().templates() {
|
||||
template_variables.extend(template.undeclared_variables(true));
|
||||
}
|
||||
Ok((env, template_variables))
|
||||
}
|
||||
|
||||
pub fn parse_recipe_content(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
) -> Result<(Recipe, HashSet<String>)> {
|
||||
// Pre-process template variables to handle invalid variable names
|
||||
let preprocessed_content = preprocess_template_variables(content)?;
|
||||
|
||||
let (env, template_variables) = get_env_with_template_variables(
|
||||
&preprocessed_content,
|
||||
recipe_dir,
|
||||
UndefinedBehavior::Lenient,
|
||||
)?;
|
||||
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
|
||||
let rendered_content = template
|
||||
.render(())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
|
||||
let recipe = Recipe::from_content(&rendered_content)?;
|
||||
// return recipe (without loading any variables) and the variable names that are in the recipe
|
||||
Ok((recipe, template_variables))
|
||||
}
|
||||
|
||||
// render the recipe for validation, deeplink and explain, etc.
|
||||
pub fn render_recipe_for_preview(
|
||||
content: &str,
|
||||
recipe_dir: String,
|
||||
params: &HashMap<String, String>,
|
||||
) -> Result<Recipe> {
|
||||
// Pre-process template variables to handle invalid variable names
|
||||
let preprocessed_content = preprocess_template_variables(content)?;
|
||||
|
||||
let (env, template_variables) = get_env_with_template_variables(
|
||||
&preprocessed_content,
|
||||
recipe_dir,
|
||||
UndefinedBehavior::Lenient,
|
||||
)?;
|
||||
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
|
||||
// if the variables are not provided, the template will be rendered with the variables, otherwise it will keep the variables as is
|
||||
let mut ctx = preserve_vars(&template_variables).clone();
|
||||
ctx.extend(params.clone());
|
||||
let rendered_content = template
|
||||
.render(ctx)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse the recipe {}", e))?;
|
||||
Recipe::from_content(&rendered_content)
|
||||
}
|
||||
|
||||
fn preserve_vars(variables: &HashSet<String>) -> HashMap<String, String> {
|
||||
let mut context = HashMap::<String, String>::new();
|
||||
for template_var in variables {
|
||||
context.insert(template_var.clone(), format!("{{{{ {} }}}}", template_var));
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod render_content_with_params_tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::recipes::template_recipe::render_recipe_content_with_params;
|
||||
|
||||
#[test]
|
||||
fn test_render_content_with_params() {
|
||||
// Test basic parameter substitution
|
||||
let content = "Hello {{ name }}!";
|
||||
let params = HashMap::from([
|
||||
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||
("name".to_string(), "World".to_string()),
|
||||
]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
|
||||
// Test empty parameter substitution
|
||||
let content = "Hello {{ empty }}!";
|
||||
let params = HashMap::from([
|
||||
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||
("empty".to_string(), "".to_string()),
|
||||
]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello !");
|
||||
|
||||
// Test multiple parameters
|
||||
let content = "{{ greeting }} {{ name }}!";
|
||||
let params = HashMap::from([
|
||||
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||
("greeting".to_string(), "Hi".to_string()),
|
||||
("name".to_string(), "Alice".to_string()),
|
||||
]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hi Alice!");
|
||||
|
||||
// Test missing parameter results in error
|
||||
let content = "Hello {{ missing }}!";
|
||||
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||
let err = render_recipe_content_with_params(content, ¶ms).unwrap_err();
|
||||
let error_msg = err.to_string();
|
||||
assert!(error_msg.contains("Failed to render the recipe"));
|
||||
|
||||
// Test invalid template syntax results in error
|
||||
let content = "Hello {{ unclosed";
|
||||
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||
let err = render_recipe_content_with_params(content, ¶ms).unwrap_err();
|
||||
assert!(err.to_string().contains("unexpected end of input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_content_with_spaced_variables() {
|
||||
let content = "Hello {{hf model org}}_{{hf model name}}!";
|
||||
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello {{hf model org}}_{{hf model name}}!");
|
||||
|
||||
let content = "Hello {{hf model org}_{hf model name}}!";
|
||||
let params = HashMap::from([("recipe_dir".to_string(), "some_dir".to_string())]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello {{hf model org}_{hf model name}}!");
|
||||
|
||||
let content = "Hello {{valid_var}}!";
|
||||
let params = HashMap::from([
|
||||
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||
("valid_var".to_string(), "World".to_string()),
|
||||
]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
|
||||
let content = "{{valid_var}} and {{invalid var}}";
|
||||
let params = HashMap::from([
|
||||
("recipe_dir".to_string(), "some_dir".to_string()),
|
||||
("valid_var".to_string(), "Hello".to_string()),
|
||||
]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
assert_eq!(result, "Hello and {{invalid var}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_prompt() {
|
||||
let content = r#"
|
||||
prompt: ""
|
||||
name: "Simple Recipe"
|
||||
description: "A test recipe"
|
||||
"#;
|
||||
let params = HashMap::from([("recipe_dir".to_string(), "test_dir".to_string())]);
|
||||
let result = render_recipe_content_with_params(content, ¶ms).unwrap();
|
||||
|
||||
assert!(result.contains("prompt: ''"));
|
||||
assert!(!result.contains(r#"prompt: "\"\"""#)); // Should not contain escaped quotes
|
||||
|
||||
assert!(result.contains(r#"name: "Simple Recipe""#));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user