Applied server side call to parse and save recipe (#5022)

This commit is contained in:
Lifei Zhou
2025-10-09 15:45:46 +11:00
committed by GitHub
parent fc7836d649
commit 1396d315e5
42 changed files with 868 additions and 2126 deletions
+8 -96
View File
@@ -1,11 +1,12 @@
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
use crate::recipe::template_recipe::render_recipe_content_with_params;
use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
@@ -34,7 +35,11 @@ where
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 recipe_parameters = validate_recipe_template_from_content(
&recipe_file_content,
Some(recipe_dir_str.to_string()),
)?
.parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(&params, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
@@ -48,18 +53,6 @@ where
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)>,
@@ -94,87 +87,6 @@ where
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 empty_params = vec![];
let params = parameters.as_ref().unwrap_or(&empty_params);
let file_params_with_defaults: Vec<String> = params
.iter()
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
.map(|p| p.key.clone())
.collect();
if !file_params_with_defaults.is_empty() {
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
}
let optional_params_without_default_values: Vec<String> = params
.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>>,
@@ -303,6 +303,27 @@ fn test_build_recipe_from_template_success_without_parameters() {
assert!(recipe.parameters.is_none());
}
#[test]
fn test_build_recipe_from_template_missing_prompt_and_instructions() {
let instructions_and_parameters = "";
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);
match err {
RecipeError::TemplateRendering { source } => {
let err_str = source.to_string();
assert!(
err_str.contains("Recipe must specify at least one of `instructions` or `prompt`.")
);
}
_ => panic!("Expected TemplateRendering error"),
}
}
#[test]
fn test_template_inheritance() {
let parent_content = r#"
+28 -32
View File
@@ -119,8 +119,16 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
if path.is_file() {
if let Some(extension) = path.extension() {
if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) {
if let Ok(recipe) = Recipe::from_file_path(&path) {
recipes.push((path.clone(), recipe));
match Recipe::from_file_path(&path) {
Ok(recipe) => recipes.push((path.clone(), recipe)),
Err(e) => {
let error_message = format!(
"Failed to load recipe from file {}: {}",
path.display(),
e
);
tracing::error!("{}", error_message);
}
}
}
}
@@ -130,7 +138,7 @@ fn scan_directory_for_recipes(dir: &Path) -> Result<Vec<(PathBuf, Recipe)>> {
Ok(recipes)
}
fn generate_recipe_filename(title: &str) -> String {
fn generate_recipe_filename(title: &str, recipe_library_dir: &Path) -> PathBuf {
let base_name = title
.to_lowercase()
.chars()
@@ -145,41 +153,29 @@ fn generate_recipe_filename(title: &str) -> String {
} else {
base_name
};
format!("{}.yaml", filename)
let mut candidate = recipe_library_dir.join(format!("{}.yaml", filename));
if !candidate.exists() {
return candidate;
}
let mut counter = 1;
loop {
candidate = recipe_library_dir.join(format!("{}-{}.yaml", filename, counter));
if !candidate.exists() {
return candidate;
}
counter += 1;
}
}
pub fn save_recipe_to_file(
recipe: Recipe,
is_global: Option<bool>,
file_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let is_global_value = is_global.unwrap_or(true);
let default_file_path =
get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title));
pub fn save_recipe_to_file(recipe: Recipe, file_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let recipe_library_dir = get_recipe_library_dir(true);
let file_path_value = match file_path {
Some(path) => path,
None => {
if default_file_path.exists() {
return Err(anyhow::anyhow!(
"Recipe file already exists at: {:?}",
default_file_path
));
}
default_file_path
}
None => generate_recipe_filename(&recipe.title, &recipe_library_dir),
};
let all_recipes = list_local_recipes()?;
for (existing_path, existing_recipe) in &all_recipes {
if existing_recipe.title == recipe.title && existing_path != &file_path_value {
return Err(anyhow::anyhow!(
"Recipe with title '{}' already exists",
recipe.title
));
}
}
let yaml_content = serde_yaml::to_string(&recipe)?;
fs::write(&file_path_value, yaml_content)?;
+19 -28
View File
@@ -15,7 +15,9 @@ use utoipa::ToSchema;
pub mod build_recipe;
pub mod local_recipes;
pub mod read_recipe_file_content;
mod recipe_extension_adapter;
pub mod template_recipe;
pub mod validate_recipe;
pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir";
pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"];
@@ -42,7 +44,11 @@ pub struct Recipe {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>, // the prompt to start the session with
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(
skip_serializing_if = "Option::is_none",
default,
deserialize_with = "recipe_extension_adapter::deserialize_recipe_extensions"
)]
pub extensions: Option<Vec<ExtensionConfig>>, // a list of extensions to enable
#[serde(skip_serializing_if = "Option::is_none")]
@@ -253,34 +259,19 @@ impl Recipe {
}
pub fn from_content(content: &str) -> Result<Self> {
// Parse using YAML parser (since JSON is a subset of YAML, this handles both)
let mut value: serde_yaml::Value = serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe content as YAML/JSON: {}", e))?;
// Handle nested legacy recipe format
if let Some(nested_recipe) = value.get("recipe") {
value = nested_recipe.clone();
}
if let Some(extensions) = value
.get_mut("extensions")
.and_then(|v| v.as_sequence_mut())
{
for ext in extensions.iter_mut() {
if let Some(obj) = ext.as_mapping_mut() {
if let Some(desc) = obj.get("description") {
if desc.is_null() || desc.as_str().is_some_and(|s| s.is_empty()) {
if let Some(name) = obj.get("name").and_then(|n| n.as_str()) {
obj.insert("description".into(), name.into());
}
}
}
let recipe: Recipe = match serde_yaml::from_str::<serde_yaml::Value>(content) {
Ok(yaml_value) => {
if let Some(nested_recipe) = yaml_value.get("recipe") {
serde_yaml::from_value(nested_recipe.clone())
.map_err(|e| anyhow::anyhow!("Failed to parse nested recipe: {}", e))?
} else {
serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?
}
}
}
let recipe: Recipe = serde_yaml::from_value(value)
.map_err(|e| anyhow::anyhow!("Failed to deserialize recipe: {}", e))?;
Err(_) => serde_yaml::from_str(content)
.map_err(|e| anyhow::anyhow!("Failed to parse recipe: {}", e))?,
};
if let Some(ref retry_config) = recipe.retry {
if let Err(validation_error) = retry_config.validate() {
@@ -778,7 +769,7 @@ isGlobal: true"#;
} = &extensions[0]
{
assert_eq!(name, "test_extension");
assert_eq!(description, "test_extension");
assert_eq!(description, "");
} else {
panic!("Expected Stdio extension");
}
@@ -0,0 +1,283 @@
use crate::agents::extension::{Envs, ExtensionConfig};
use rmcp::model::Tool;
use serde::de::Deserializer;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Deserialize)]
#[serde(tag = "type")]
enum RecipeExtensionConfigInternal {
#[serde(rename = "sse")]
Sse {
name: String,
#[serde(default)]
description: Option<String>,
uri: String,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "stdio")]
Stdio {
name: String,
#[serde(default)]
description: Option<String>,
cmd: String,
args: Vec<String>,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "builtin")]
Builtin {
name: String,
#[serde(default)]
description: Option<String>,
display_name: Option<String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "platform")]
Platform {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "streamable_http")]
StreamableHttp {
name: String,
#[serde(default)]
description: Option<String>,
uri: String,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
#[serde(default)]
headers: HashMap<String, String>,
timeout: Option<u64>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "frontend")]
Frontend {
name: String,
#[serde(default)]
description: Option<String>,
tools: Vec<Tool>,
instructions: Option<String>,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
#[serde(rename = "inline_python")]
InlinePython {
name: String,
#[serde(default)]
description: Option<String>,
code: String,
timeout: Option<u64>,
#[serde(default)]
dependencies: Option<Vec<String>>,
#[serde(default)]
available_tools: Vec<String>,
},
}
macro_rules! map_recipe_extensions {
($value:expr; $( $variant:ident { $( $field:ident ),* $(,)? } ),+ $(,)?) => {{
match $value {
$(
RecipeExtensionConfigInternal::$variant {
name,
description,
$( $field ),*
} => ExtensionConfig::$variant {
name,
description: description.unwrap_or_default(),
$( $field ),*
},
)+
}
}};
}
impl From<RecipeExtensionConfigInternal> for ExtensionConfig {
fn from(internal_variant: RecipeExtensionConfigInternal) -> Self {
map_recipe_extensions!(
internal_variant;
Sse {
uri,
envs,
env_keys,
timeout,
bundled,
available_tools
},
Stdio {
cmd,
args,
envs,
env_keys,
timeout,
bundled,
available_tools
},
Builtin {
display_name,
timeout,
bundled,
available_tools
},
Platform {
bundled,
available_tools
},
StreamableHttp {
uri,
envs,
env_keys,
headers,
timeout,
bundled,
available_tools
},
Frontend {
tools,
instructions,
bundled,
available_tools
},
InlinePython {
code,
timeout,
dependencies,
available_tools
}
)
}
}
pub fn deserialize_recipe_extensions<'de, D>(
deserializer: D,
) -> Result<Option<Vec<ExtensionConfig>>, D::Error>
where
D: Deserializer<'de>,
{
let remotes = Option::<Vec<RecipeExtensionConfigInternal>>::deserialize(deserializer)?;
Ok(remotes.map(|items| {
items
.into_iter()
.map(ExtensionConfig::from)
.collect::<Vec<_>>()
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Wrapper {
#[serde(deserialize_with = "deserialize_recipe_extensions")]
extensions: Option<Vec<ExtensionConfig>>,
}
#[test]
fn builtin_extension_defaults_description() {
let wrapper: Wrapper = serde_json::from_value(json!({
"extensions": [{
"type": "builtin",
"name": "test-builtin",
"display_name": "Test Builtin",
"timeout": 120,
"bundled": true,
"available_tools": ["tool_a", "tool_b"],
}]
}))
.expect("failed to deserialize extensions");
let extensions = wrapper.extensions.expect("expected extensions");
assert_eq!(extensions.len(), 1);
match &extensions[0] {
ExtensionConfig::Builtin {
name,
description,
display_name,
timeout,
bundled,
available_tools,
} => {
assert_eq!(name, "test-builtin");
assert_eq!(description, "");
assert_eq!(display_name.as_deref(), Some("Test Builtin"));
assert_eq!(*timeout, Some(120));
assert_eq!(*bundled, Some(true));
assert_eq!(
available_tools,
&vec!["tool_a".to_string(), "tool_b".to_string()]
);
}
other => panic!("unexpected extension variant: {:?}", other),
}
}
#[test]
fn builtin_extension_null_description_defaults_to_empty() {
let wrapper: Wrapper = serde_json::from_value(json!({
"extensions": [{
"type": "builtin",
"name": "null-description-builtin",
"description": null,
}]
}))
.expect("failed to deserialize extensions with null description");
let extensions = wrapper.extensions.expect("expected extensions");
assert_eq!(extensions.len(), 1);
match &extensions[0] {
ExtensionConfig::Builtin {
name,
description,
display_name,
timeout,
bundled,
available_tools,
} => {
assert_eq!(name, "null-description-builtin");
assert_eq!(description, "");
assert!(display_name.is_none());
assert!(timeout.is_none());
assert!(bundled.is_none());
assert!(available_tools.is_empty());
}
other => panic!("unexpected extension variant: {:?}", other),
}
}
}
+20 -17
View File
@@ -98,7 +98,7 @@ pub fn render_recipe_content_with_params(
let env = add_template_in_env(
&content_with_safe_variables,
params.get(BUILT_IN_RECIPE_DIR_PARAM).unwrap().clone(),
params.get(BUILT_IN_RECIPE_DIR_PARAM).cloned(),
UndefinedBehavior::Strict,
)?;
let template = env.get_template(CURRENT_TEMPLATE_NAME).unwrap();
@@ -110,23 +110,26 @@ pub fn render_recipe_content_with_params(
fn add_template_in_env(
content: &str,
recipe_dir: String,
recipe_dir: Option<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)),
}
});
if let Some(recipe_dir) = recipe_dir {
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)
@@ -134,7 +137,7 @@ fn add_template_in_env(
fn get_env_with_template_variables(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
undefined_behavior: UndefinedBehavior,
) -> Result<(Environment<'_>, HashSet<String>)> {
let env = add_template_in_env(content, recipe_dir, undefined_behavior)?;
@@ -149,7 +152,7 @@ fn get_env_with_template_variables(
pub fn parse_recipe_content(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
) -> Result<(Recipe, HashSet<String>)> {
// Pre-process template variables to handle invalid variable names
let preprocessed_content = preprocess_template_variables(content)?;
@@ -171,7 +174,7 @@ pub fn parse_recipe_content(
// render the recipe for validation, deeplink and explain, etc.
pub fn render_recipe_for_preview(
content: &str,
recipe_dir: String,
recipe_dir: Option<String>,
params: &HashMap<String, String>,
) -> Result<Recipe> {
// Pre-process template variables to handle invalid variable names
+156
View File
@@ -0,0 +1,156 @@
use crate::recipe::read_recipe_file_content::RecipeFile;
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_for_preview};
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
use std::collections::{HashMap, HashSet};
pub fn validate_recipe_parameters(
recipe_file_content: &str,
recipe_dir_str: Option<String>,
) -> Result<Option<Vec<RecipeParameter>>> {
let (recipe_template, template_variables) =
parse_recipe_content(recipe_file_content, recipe_dir_str)?;
let recipe_parameters = recipe_template.parameters;
validate_optional_parameters(&recipe_parameters)?;
validate_parameters_in_template(&recipe_parameters, &template_variables)?;
Ok(recipe_parameters)
}
fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
match jsonschema::validator_for(schema) {
Ok(_) => Ok(()),
Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)),
}
}
pub fn validate_recipe_template_from_file(recipe_file: &RecipeFile) -> Result<Recipe> {
let recipe_dir = recipe_file
.parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?
.to_string();
validate_recipe_template_from_content(&recipe_file.content, Some(recipe_dir))
}
pub fn validate_recipe_template_from_content(
recipe_content: &str,
recipe_dir: Option<String>,
) -> Result<Recipe> {
validate_recipe_parameters(recipe_content, recipe_dir.clone())?;
let recipe = render_recipe_for_preview(recipe_content, recipe_dir, &HashMap::new())?;
validate_prompt_or_instructions(&recipe)?;
if let Some(response) = &recipe.response {
if let Some(json_schema) = &response.json_schema {
validate_json_schema(json_schema)?;
}
}
Ok(recipe)
}
fn validate_prompt_or_instructions(recipe: &Recipe) -> Result<()> {
let has_instructions = recipe
.instructions
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let has_prompt = recipe
.prompt
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if has_instructions || has_prompt {
return Ok(());
}
Err(anyhow::anyhow!(
"Recipe must specify at least one of `instructions` or `prompt`."
))
}
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 empty_params = vec![];
let params = parameters.as_ref().unwrap_or(&empty_params);
let file_params_with_defaults: Vec<String> = params
.iter()
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
.map(|p| p.key.clone())
.collect();
if !file_params_with_defaults.is_empty() {
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
}
let optional_params_without_default_values: Vec<String> = params
.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(", ")))
}
}