feat: created sub recipe tools (#2982)

This commit is contained in:
Lifei Zhou
2025-06-25 09:29:26 +10:00
committed by GitHub
parent 566796f767
commit 9e6247d9ed
18 changed files with 1329 additions and 426 deletions
+30 -7
View File
@@ -10,13 +10,14 @@ use futures_util::stream;
use futures_util::stream::StreamExt;
use mcp_core::protocol::JsonRpcMessage;
use crate::agents::sub_recipe_manager::SubRecipeManager;
use crate::config::{Config, ExtensionConfigManager, PermissionManager};
use crate::message::Message;
use crate::permission::permission_judge::check_tool_permissions;
use crate::permission::PermissionConfirmation;
use crate::providers::base::Provider;
use crate::providers::errors::ProviderError;
use crate::recipe::{Author, Recipe, Settings};
use crate::recipe::{Author, Recipe, Settings, SubRecipe};
use crate::scheduler_trait::SchedulerTrait;
use crate::tool_monitor::{ToolCall, ToolMonitor};
use regex::Regex;
@@ -52,6 +53,7 @@ use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DEC
pub struct Agent {
pub(super) provider: Mutex<Option<Arc<dyn Provider>>>,
pub(super) extension_manager: Mutex<ExtensionManager>,
pub(super) sub_recipe_manager: Mutex<SubRecipeManager>,
pub(super) frontend_tools: Mutex<HashMap<String, FrontendTool>>,
pub(super) frontend_instructions: Mutex<Option<String>>,
pub(super) prompt_manager: Mutex<PromptManager>,
@@ -80,6 +82,7 @@ impl Agent {
Self {
provider: Mutex::new(None),
extension_manager: Mutex::new(ExtensionManager::new()),
sub_recipe_manager: Mutex::new(SubRecipeManager::new()),
frontend_tools: Mutex::new(HashMap::new()),
frontend_instructions: Mutex::new(None),
prompt_manager: Mutex::new(PromptManager::new()),
@@ -193,6 +196,11 @@ impl Agent {
Ok(tools)
}
pub async fn add_sub_recipes(&self, sub_recipes: Vec<SubRecipe>) {
let mut sub_recipe_manager = self.sub_recipe_manager.lock().await;
sub_recipe_manager.add_sub_recipe_tools(sub_recipes);
}
/// Dispatch a single tool call to the appropriate client
#[instrument(skip(self, tool_call, request_id), fields(input, output))]
pub async fn dispatch_tool_call(
@@ -242,7 +250,13 @@ impl Agent {
}
let extension_manager = self.extension_manager.lock().await;
let result: ToolCallResult = if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
let sub_recipe_manager = self.sub_recipe_manager.lock().await;
let result: ToolCallResult = if sub_recipe_manager.is_sub_recipe_tool(&tool_call.name) {
sub_recipe_manager
.dispatch_sub_recipe_tool_call(&tool_call.name, tool_call.arguments.clone())
.await
} else if tool_call.name == PLATFORM_READ_RESOURCE_TOOL_NAME {
// Check if the tool is read_resource and handle it separately
ToolCallResult::from(
extension_manager
@@ -465,17 +479,26 @@ impl Agent {
if extension_name.is_none() || extension_name.as_deref() == Some("platform") {
// Add platform tools
prefixed_tools.push(platform_tools::search_available_extensions_tool());
prefixed_tools.push(platform_tools::manage_extensions_tool());
prefixed_tools.push(platform_tools::manage_schedule_tool());
prefixed_tools.extend([
platform_tools::search_available_extensions_tool(),
platform_tools::manage_extensions_tool(),
platform_tools::manage_schedule_tool(),
]);
// Add resource tools if supported
if extension_manager.supports_resources() {
prefixed_tools.push(platform_tools::read_resource_tool());
prefixed_tools.push(platform_tools::list_resources_tool());
prefixed_tools.extend([
platform_tools::read_resource_tool(),
platform_tools::list_resources_tool(),
]);
}
}
if extension_name.is_none() {
let sub_recipe_manager = self.sub_recipe_manager.lock().await;
prefixed_tools.extend(sub_recipe_manager.sub_recipe_tools.values().cloned());
}
prefixed_tools
}
+2 -1
View File
@@ -5,11 +5,12 @@ pub mod extension_manager;
mod large_response_handler;
pub mod platform_tools;
pub mod prompt_manager;
mod recipe_tools;
mod reply_parts;
mod router_tool_selector;
mod router_tools;
mod schedule_tool;
pub mod sub_recipe_manager;
mod tool_execution;
mod tool_router_index_manager;
pub(crate) mod tool_vectordb;
@@ -0,0 +1 @@
pub mod sub_recipe_tools;
@@ -0,0 +1,167 @@
use std::{collections::HashMap, fs};
use anyhow::Result;
use mcp_core::tool::{Tool, ToolAnnotations};
use serde_json::{json, Map, Value};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use crate::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement, SubRecipe};
pub const SUB_RECIPE_TOOL_NAME_PREFIX: &str = "subrecipe__run_";
pub fn create_sub_recipe_tool(sub_recipe: &SubRecipe) -> Tool {
let input_schema = get_input_schema(sub_recipe).unwrap();
Tool::new(
format!("{}_{}", SUB_RECIPE_TOOL_NAME_PREFIX, sub_recipe.name),
"Run a sub recipe.
Use this tool when you need to run a sub-recipe.
The sub recipe will be run with the provided parameters
and return the output of the sub recipe."
.to_string(),
input_schema,
Some(ToolAnnotations {
title: Some(format!("run sub recipe {}", sub_recipe.name)),
read_only_hint: false,
destructive_hint: true,
idempotent_hint: false,
open_world_hint: true,
}),
)
}
fn get_sub_recipe_parameter_definition(
sub_recipe: &SubRecipe,
) -> Result<Option<Vec<RecipeParameter>>> {
let content = fs::read_to_string(sub_recipe.path.clone())
.map_err(|e| anyhow::anyhow!("Failed to read recipe file {}: {}", sub_recipe.path, e))?;
let recipe = Recipe::from_content(&content)?;
Ok(recipe.parameters)
}
fn get_input_schema(sub_recipe: &SubRecipe) -> Result<Value> {
let mut sub_recipe_params_map = HashMap::<String, String>::new();
if let Some(params_with_value) = &sub_recipe.values {
for (param_name, param_value) in params_with_value {
sub_recipe_params_map.insert(param_name.clone(), param_value.clone());
}
}
let parameter_definition = get_sub_recipe_parameter_definition(sub_recipe)?;
if let Some(parameters) = parameter_definition {
let mut properties = Map::new();
let mut required = Vec::new();
for param in parameters {
if sub_recipe_params_map.contains_key(&param.key) {
continue;
}
properties.insert(
param.key.clone(),
json!({
"type": param.input_type.to_string(),
"description": param.description.clone(),
}),
);
if !matches!(param.requirement, RecipeParameterRequirement::Optional) {
required.push(param.key);
}
}
Ok(json!({
"type": "object",
"properties": properties,
"required": required
}))
} else {
Ok(json!({
"type": "object",
"properties": {}
}))
}
}
fn prepare_command_params(
sub_recipe: &SubRecipe,
params_from_tool_call: Value,
) -> Result<HashMap<String, String>> {
let mut sub_recipe_params = HashMap::<String, String>::new();
if let Some(params_with_value) = &sub_recipe.values {
for (param_name, param_value) in params_with_value {
sub_recipe_params.insert(param_name.clone(), param_value.clone());
}
}
if let Some(params_map) = params_from_tool_call.as_object() {
for (key, value) in params_map {
sub_recipe_params.insert(
key.to_string(),
value.as_str().unwrap_or(&value.to_string()).to_string(),
);
}
}
Ok(sub_recipe_params)
}
pub async fn run_sub_recipe(sub_recipe: &SubRecipe, params: Value) -> Result<String> {
let command_params = prepare_command_params(sub_recipe, params)?;
let mut command = Command::new("goose");
command.arg("run").arg("--recipe").arg(&sub_recipe.path);
for (key, value) in command_params {
command.arg("--params").arg(format!("{}={}", key, value));
}
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn: {}", e))?;
let stdout = child.stdout.take().expect("Failed to capture stdout");
let stderr = child.stderr.take().expect("Failed to capture stderr");
let mut stdout_reader = BufReader::new(stdout).lines();
let mut stderr_reader = BufReader::new(stderr).lines();
let stdout_sub_recipe_name = sub_recipe.name.clone();
let stderr_sub_recipe_name = sub_recipe.name.clone();
// Spawn background tasks to read from stdout and stderr
let stdout_task = tokio::spawn(async move {
let mut buffer = String::new();
while let Ok(Some(line)) = stdout_reader.next_line().await {
println!("[sub-recipe {}] {}", stdout_sub_recipe_name, line);
buffer.push_str(&line);
buffer.push('\n');
}
buffer
});
let stderr_task = tokio::spawn(async move {
let mut buffer = String::new();
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!(
"[stderr for sub-recipe {}] {}",
stderr_sub_recipe_name, line
);
buffer.push_str(&line);
buffer.push('\n');
}
buffer
});
let status = child
.wait()
.await
.map_err(|e| anyhow::anyhow!("Failed to wait for process: {}", e))?;
let stdout_output = stdout_task.await.unwrap();
let stderr_output = stderr_task.await.unwrap();
if status.success() {
Ok(stdout_output)
} else {
Err(anyhow::anyhow!("Command failed:\n{}", stderr_output))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,155 @@
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::recipe::SubRecipe;
fn setup_sub_recipe() -> SubRecipe {
let sub_recipe = SubRecipe {
name: "test_sub_recipe".to_string(),
path: "test_sub_recipe.yaml".to_string(),
values: Some(HashMap::from([("key1".to_string(), "value1".to_string())])),
};
sub_recipe
}
mod prepare_command_params_tests {
use std::collections::HashMap;
use crate::{
agents::recipe_tools::sub_recipe_tools::{
prepare_command_params, tests::tests::setup_sub_recipe,
},
recipe::SubRecipe,
};
#[test]
fn test_prepare_command_params_basic() {
let mut params = HashMap::new();
params.insert("key2".to_string(), "value2".to_string());
let sub_recipe = setup_sub_recipe();
let params_value = serde_json::to_value(params).unwrap();
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result.get("key1"), Some(&"value1".to_string()));
assert_eq!(result.get("key2"), Some(&"value2".to_string()));
}
#[test]
fn test_prepare_command_params_empty() {
let sub_recipe = SubRecipe {
name: "test_sub_recipe".to_string(),
path: "test_sub_recipe.yaml".to_string(),
values: None,
};
let params: HashMap<String, String> = HashMap::new();
let params_value = serde_json::to_value(params).unwrap();
let result = prepare_command_params(&sub_recipe, params_value).unwrap();
assert_eq!(result.len(), 0);
}
}
mod get_input_schema_tests {
use crate::{
agents::recipe_tools::sub_recipe_tools::{
get_input_schema, tests::tests::setup_sub_recipe,
},
recipe::SubRecipe,
};
#[test]
fn test_get_input_schema_with_parameters() {
let sub_recipe = setup_sub_recipe();
let sub_recipe_file_content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Test prompt",
"parameters": [
{
"key": "key1",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
},
{
"key": "key2",
"input_type": "number",
"requirement": "optional",
"description": "An optional parameter"
}
]
}"#;
let temp_dir = tempfile::tempdir().unwrap();
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
let mut sub_recipe = sub_recipe;
sub_recipe.path = temp_file.to_string_lossy().to_string();
let result = get_input_schema(&sub_recipe).unwrap();
// Verify the schema structure
assert_eq!(result["type"], "object");
assert!(result["properties"].is_object());
let properties = result["properties"].as_object().unwrap();
assert_eq!(properties.len(), 1);
let key2_prop = &properties["key2"];
assert_eq!(key2_prop["type"], "number");
assert_eq!(key2_prop["description"], "An optional parameter");
let required = result["required"].as_array().unwrap();
assert_eq!(required.len(), 0);
}
#[test]
fn test_get_input_schema_no_parameters_values() {
let sub_recipe = SubRecipe {
name: "test_sub_recipe".to_string(),
path: "test_sub_recipe.yaml".to_string(),
values: None,
};
let sub_recipe_file_content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Test prompt",
"parameters": [
{
"key": "key1",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
]
}"#;
let temp_dir = tempfile::tempdir().unwrap();
let temp_file = temp_dir.path().join("test_sub_recipe.yaml");
std::fs::write(&temp_file, sub_recipe_file_content).unwrap();
let mut sub_recipe = sub_recipe;
sub_recipe.path = temp_file.to_string_lossy().to_string();
let result = get_input_schema(&sub_recipe).unwrap();
assert_eq!(result["type"], "object");
assert!(result["properties"].is_object());
let properties = result["properties"].as_object().unwrap();
assert_eq!(properties.len(), 1);
let key1_prop = &properties["key1"];
assert_eq!(key1_prop["type"], "string");
assert_eq!(key1_prop["description"], "A test parameter");
assert_eq!(result["required"].as_array().unwrap().len(), 1);
assert_eq!(result["required"][0], "key1");
}
}
}
@@ -0,0 +1,89 @@
use mcp_core::{Content, Tool, ToolError};
use serde_json::Value;
use std::collections::HashMap;
use crate::{
agents::{
recipe_tools::sub_recipe_tools::{
create_sub_recipe_tool, run_sub_recipe, SUB_RECIPE_TOOL_NAME_PREFIX,
},
tool_execution::ToolCallResult,
},
recipe::SubRecipe,
};
#[derive(Debug, Clone)]
pub struct SubRecipeManager {
pub sub_recipe_tools: HashMap<String, Tool>,
pub sub_recipes: HashMap<String, SubRecipe>,
}
impl Default for SubRecipeManager {
fn default() -> Self {
Self::new()
}
}
impl SubRecipeManager {
pub fn new() -> Self {
Self {
sub_recipe_tools: HashMap::new(),
sub_recipes: HashMap::new(),
}
}
pub fn add_sub_recipe_tools(&mut self, sub_recipes_to_add: Vec<SubRecipe>) {
for sub_recipe in sub_recipes_to_add {
let sub_recipe_key = format!(
"{}_{}",
SUB_RECIPE_TOOL_NAME_PREFIX,
sub_recipe.name.clone()
);
let tool = create_sub_recipe_tool(&sub_recipe);
self.sub_recipe_tools.insert(sub_recipe_key.clone(), tool);
self.sub_recipes.insert(sub_recipe_key.clone(), sub_recipe);
}
}
pub fn is_sub_recipe_tool(&self, tool_name: &str) -> bool {
self.sub_recipe_tools.contains_key(tool_name)
}
pub async fn dispatch_sub_recipe_tool_call(
&self,
tool_name: &str,
params: Value,
) -> ToolCallResult {
let result = self.call_sub_recipe_tool(tool_name, params).await;
match result {
Ok(call_result) => ToolCallResult::from(Ok(call_result)),
Err(e) => ToolCallResult::from(Err(ToolError::ExecutionError(e.to_string()))),
}
}
async fn call_sub_recipe_tool(
&self,
tool_name: &str,
params: Value,
) -> Result<Vec<Content>, ToolError> {
let sub_recipe = self.sub_recipes.get(tool_name).ok_or_else(|| {
let sub_recipe_name = tool_name
.strip_prefix(SUB_RECIPE_TOOL_NAME_PREFIX)
.and_then(|s| s.strip_prefix("_"))
.ok_or_else(|| {
ToolError::InvalidParameters(format!(
"Invalid sub-recipe tool name format: {}",
tool_name
))
})
.unwrap();
ToolError::InvalidParameters(format!("Sub-recipe '{}' not found", sub_recipe_name))
})?;
let output = run_sub_recipe(sub_recipe, params).await.map_err(|e| {
ToolError::ExecutionError(format!("Sub-recipe execution failed: {}", e))
})?;
Ok(vec![Content::text(output)])
}
}
+256
View File
@@ -1,6 +1,10 @@
use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt;
use crate::agents::extension::ExtensionConfig;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
fn default_version() -> String {
@@ -89,6 +93,9 @@ pub struct Recipe {
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<Vec<RecipeParameter>>, // any additional parameters for the recipe
#[serde(skip_serializing_if = "Option::is_none")]
pub sub_recipes: Option<Vec<SubRecipe>>, // sub-recipes for the recipe
}
#[derive(Serialize, Deserialize, Debug)]
@@ -112,6 +119,39 @@ pub struct Settings {
pub temperature: Option<f32>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SubRecipe {
pub name: String,
pub path: String,
#[serde(default, deserialize_with = "deserialize_value_map_as_string")]
pub values: Option<HashMap<String, String>>,
}
fn deserialize_value_map_as_string<'de, D>(
deserializer: D,
) -> Result<Option<HashMap<String, String>>, D::Error>
where
D: Deserializer<'de>,
{
// First, try to deserialize a map of values
let opt_raw: Option<HashMap<String, Value>> = Option::deserialize(deserializer)?;
match opt_raw {
Some(raw_map) => {
let mut result = HashMap::new();
for (k, v) in raw_map {
let s = match v {
Value::String(s) => s,
_ => serde_json::to_string(&v).map_err(serde::de::Error::custom)?,
};
result.insert(k, s);
}
Ok(Some(result))
}
None => Ok(None),
}
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RecipeParameterRequirement {
@@ -176,6 +216,7 @@ pub struct RecipeBuilder {
activities: Option<Vec<String>>,
author: Option<Author>,
parameters: Option<Vec<RecipeParameter>>,
sub_recipes: Option<Vec<SubRecipe>>,
}
impl Recipe {
@@ -206,6 +247,18 @@ impl Recipe {
activities: None,
author: None,
parameters: None,
sub_recipes: None,
}
}
pub fn from_content(content: &str) -> Result<Self> {
if serde_json::from_str::<serde_json::Value>(content).is_ok() {
Ok(serde_json::from_str(content)?)
} else if serde_yaml::from_str::<serde_yaml::Value>(content).is_ok() {
Ok(serde_yaml::from_str(content)?)
} else {
Err(anyhow::anyhow!(
"Unsupported format. Expected JSON or YAML."
))
}
}
}
@@ -274,6 +327,10 @@ impl RecipeBuilder {
self.parameters = Some(parameters);
self
}
pub fn sub_recipes(mut self, sub_recipes: Vec<SubRecipe>) -> Self {
self.sub_recipes = Some(sub_recipes);
self
}
/// Builds the Recipe instance
///
@@ -298,6 +355,205 @@ impl RecipeBuilder {
activities: self.activities,
author: self.author,
parameters: self.parameters,
sub_recipes: self.sub_recipes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_content_with_json() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"prompt": "Test prompt",
"instructions": "Test instructions",
"extensions": [
{
"type": "stdio",
"name": "test_extension",
"cmd": "test_cmd",
"args": ["arg1", "arg2"],
"timeout": 300,
"description": "Test extension"
}
],
"parameters": [
{
"key": "test_param",
"input_type": "string",
"requirement": "required",
"description": "A test parameter"
}
],
"sub_recipes": [
{
"name": "test_sub_recipe",
"path": "test_sub_recipe.yaml",
"values": {
"sub_recipe_param": "sub_recipe_value"
}
}
]
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert_eq!(recipe.version, "1.0.0");
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
assert!(recipe.parameters.is_some());
let parameters = recipe.parameters.unwrap();
assert_eq!(parameters.len(), 1);
assert_eq!(parameters[0].key, "test_param");
assert!(matches!(
parameters[0].input_type,
RecipeParameterInputType::String
));
assert!(matches!(
parameters[0].requirement,
RecipeParameterRequirement::Required
));
assert!(recipe.sub_recipes.is_some());
let sub_recipes = recipe.sub_recipes.unwrap();
assert_eq!(sub_recipes.len(), 1);
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
assert_eq!(
sub_recipes[0].values,
Some(HashMap::from([(
"sub_recipe_param".to_string(),
"sub_recipe_value".to_string()
)]))
);
}
#[test]
fn test_from_content_with_yaml() {
let content = r#"version: 1.0.0
title: Test Recipe
description: A test recipe
prompt: Test prompt
instructions: Test instructions
extensions:
- type: stdio
name: test_extension
cmd: test_cmd
args: [arg1, arg2]
timeout: 300
description: Test extension
parameters:
- key: test_param
input_type: string
requirement: required
description: A test parameter
sub_recipes:
- name: test_sub_recipe
path: test_sub_recipe.yaml
values:
sub_recipe_param: sub_recipe_value"#;
let recipe = Recipe::from_content(content).unwrap();
assert_eq!(recipe.version, "1.0.0");
assert_eq!(recipe.title, "Test Recipe");
assert_eq!(recipe.description, "A test recipe");
assert_eq!(recipe.instructions, Some("Test instructions".to_string()));
assert_eq!(recipe.prompt, Some("Test prompt".to_string()));
assert!(recipe.extensions.is_some());
let extensions = recipe.extensions.unwrap();
assert_eq!(extensions.len(), 1);
assert!(recipe.parameters.is_some());
let parameters = recipe.parameters.unwrap();
assert_eq!(parameters.len(), 1);
assert_eq!(parameters[0].key, "test_param");
assert!(matches!(
parameters[0].input_type,
RecipeParameterInputType::String
));
assert!(matches!(
parameters[0].requirement,
RecipeParameterRequirement::Required
));
assert!(recipe.sub_recipes.is_some());
let sub_recipes = recipe.sub_recipes.unwrap();
assert_eq!(sub_recipes.len(), 1);
assert_eq!(sub_recipes[0].name, "test_sub_recipe");
assert_eq!(sub_recipes[0].path, "test_sub_recipe.yaml");
assert_eq!(
sub_recipes[0].values,
Some(HashMap::from([(
"sub_recipe_param".to_string(),
"sub_recipe_value".to_string()
)]))
);
}
#[test]
fn test_from_content_invalid_json() {
let content = "{ invalid json }";
let result = Recipe::from_content(content);
assert!(result.is_err());
}
#[test]
fn test_from_content_missing_required_fields() {
let content = r#"{
"version": "1.0.0",
"description": "A test recipe"
}"#;
let result = Recipe::from_content(content);
assert!(result.is_err());
}
#[test]
fn test_from_content_with_author() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"author": {
"contact": "test@example.com"
}
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert!(recipe.author.is_some());
let author = recipe.author.unwrap();
assert_eq!(author.contact, Some("test@example.com".to_string()));
}
#[test]
fn test_from_content_with_activities() {
let content = r#"{
"version": "1.0.0",
"title": "Test Recipe",
"description": "A test recipe",
"instructions": "Test instructions",
"activities": ["activity1", "activity2"]
}"#;
let recipe = Recipe::from_content(content).unwrap();
assert!(recipe.activities.is_some());
let activities = recipe.activities.unwrap();
assert_eq!(activities, vec!["activity1", "activity2"]);
}
}
+1
View File
@@ -1374,6 +1374,7 @@ mod tests {
author: None,
parameters: None,
settings: None,
sub_recipes: None,
};
let mut recipe_file = File::create(&recipe_filename)?;
writeln!(