chore: move recipe loading to goose (#3424)

This commit is contained in:
Lifei Zhou
2025-07-17 14:38:20 +10:00
committed by GitHub
parent 21b79ad240
commit 9351027d1b
15 changed files with 749 additions and 597 deletions
+6 -8
View File
@@ -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)?
+3 -3
View File
@@ -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(),
-1
View File
@@ -3,4 +3,3 @@ pub mod github_recipe;
pub mod print_recipe;
pub mod recipe;
pub mod search_recipe;
pub mod template_recipe;
+16 -3
View File
@@ -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!();
}
+106 -190
View File
@@ -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(&params, 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, &params_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(&params, recipe_parameters, recipe_dir_str, false)?;
let (params_for_template, missing_params) = apply_values_to_parameters(
&params,
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(),
&params_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(&param_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(&param.key) {
match (&param.default, &param.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"
);
}
}
}
+5 -45
View File
@@ -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 -1
View File
@@ -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"] }
+189
View File
@@ -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<String> },
#[error("Template rendering failed: {source}")]
TemplateRendering { source: anyhow::Error },
#[error("Recipe parsing failed: {source}")]
RecipeParsing { source: anyhow::Error },
}
pub fn render_recipe_template<F>(
recipe_file: RecipeFile,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<(String, Vec<String>)>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
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(&params, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
let rendered_content = if missing_params.is_empty() {
render_recipe_content_with_params(&recipe_file_content, &params_for_template)?
} else {
String::new()
};
Ok((rendered_content, missing_params))
}
pub 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 build_recipe_from_template<F>(
recipe_file: RecipeFile,
params: Vec<(String, String)>,
user_prompt_fn: Option<F>,
) -> Result<Recipe, RecipeError>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
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<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(&param_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(", ")))
}
}
pub fn apply_values_to_parameters<F>(
user_params: &[(String, String)],
recipe_parameters: Option<Vec<RecipeParameter>>,
recipe_parent_dir: &str,
user_prompt_fn: Option<F>,
) -> Result<(HashMap<String, String>, Vec<String>)>
where
F: Fn(&str, &str) -> Result<String, anyhow::Error>,
{
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(&param.key) {
match (&param.default, &param.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()(&param.key, &param.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;
@@ -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<fn(&str, &str) -> Result<String, anyhow::Error>> = 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"
);
}
}
+6
View File
@@ -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()
}
@@ -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<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,
})
}
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)
}
@@ -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<String> {
let all_template_variables = extract_template_variables(content);
@@ -27,6 +27,7 @@ fn extract_template_variables(content: &str) -> Vec<String> {
.collect()
}
// filter out variables that are not only alphanumeric and underscores
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
@@ -40,15 +41,14 @@ fn filter_unparseable_variables(template_variables: &[String]) -> Result<Vec<Str
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 = "{{",
open = OPEN_BRACE,
content = var,
close = "}}"
close = CLOSE_BRACE
);
if env.template_from_str(&test_template).is_err() {
vars_to_convert.push(var.clone());
@@ -67,14 +67,14 @@ fn replace_unparseable_vars_with_raw(
for var in unparsable_template_variables {
let pattern = format!(
"{open}{content}{close}",
open = "{{",
open = OPEN_BRACE,
content = var,
close = "}}"
close = CLOSE_BRACE
);
let replacement = format!(
"{{% raw %}}{open}{content}{close}{{% endraw %}}",
open = "{{",
close = "}}",
open = OPEN_BRACE,
close = CLOSE_BRACE,
content = var
);
result = result.replace(&pattern, &replacement);
@@ -108,19 +108,6 @@ pub fn render_recipe_content_with_params(
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,
@@ -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() {