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
+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)
}
+299
View File
@@ -0,0 +1,299 @@
use std::{
collections::{HashMap, HashSet},
path::Path,
};
use crate::recipe::{Recipe, BUILT_IN_RECIPE_DIR_PARAM};
use anyhow::Result;
use minijinja::{Environment, UndefinedBehavior};
use regex::Regex;
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);
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()
}
// 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
.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 {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Lenient);
let test_template = format!(
"{open}{content}{close}",
open = OPEN_BRACE,
content = var,
close = CLOSE_BRACE
);
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 = OPEN_BRACE,
content = var,
close = CLOSE_BRACE
);
let replacement = format!(
"{{% raw %}}{open}{content}{close}{{% endraw %}}",
open = OPEN_BRACE,
close = CLOSE_BRACE,
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)
}
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::recipe::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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).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, &params).unwrap();
assert!(result.contains("prompt: ''"));
assert!(!result.contains(r#"prompt: "\"\"""#)); // Should not contain escaped quotes
assert!(result.contains(r#"name: "Simple Recipe""#));
}
}
}