use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::error::AppResult;
use crate::playbook::types::{Step, ToolCall, ToolDefinition, ToolSpec};
use crate::template::TemplateRenderer;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
pub command_id: i64,
pub execution_id: i64,
pub catalog_id: i64,
pub parent_event_id: i64,
pub step_name: String,
pub tool: ToolCommand,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iterator: Option<IteratorMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCommand {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IteratorMetadata {
pub parent_execution_id: i64,
pub iterator_step: String,
pub index: usize,
pub total: usize,
pub item: serde_json::Value,
pub item_var: String,
}
pub struct CommandBuilder {
renderer: TemplateRenderer,
}
impl Default for CommandBuilder {
fn default() -> Self {
Self::new()
}
}
impl CommandBuilder {
pub fn new() -> Self {
Self {
renderer: TemplateRenderer::new(),
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_command(
&self,
command_id: i64,
execution_id: i64,
catalog_id: i64,
parent_event_id: i64,
step: &Step,
context: &HashMap<String, serde_json::Value>,
metadata: Option<&serde_json::Value>,
) -> AppResult<Command> {
let tool_command = self.build_tool_from_definition(&step.tool, context)?;
Ok(Command {
command_id,
execution_id,
catalog_id,
parent_event_id,
step_name: step.step.clone(),
tool: tool_command,
context: Some(context.clone()),
metadata: metadata.cloned(),
iterator: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn build_iteration_command(
&self,
command_id: i64,
execution_id: i64,
catalog_id: i64,
parent_event_id: i64,
step: &Step,
context: &HashMap<String, serde_json::Value>,
iterator: IteratorMetadata,
) -> AppResult<Command> {
let mut iter_context = context.clone();
iter_context.insert(iterator.item_var.clone(), iterator.item.clone());
iter_context.insert("_index".to_string(), serde_json::json!(iterator.index));
iter_context.insert("_total".to_string(), serde_json::json!(iterator.total));
let tool_command = self.build_tool_from_definition(&step.tool, &iter_context)?;
Ok(Command {
command_id,
execution_id,
catalog_id,
parent_event_id,
step_name: step.step.clone(),
tool: tool_command,
context: Some(iter_context),
metadata: None,
iterator: Some(iterator),
})
}
fn build_tool_from_definition(
&self,
tool: &ToolDefinition,
context: &HashMap<String, serde_json::Value>,
) -> AppResult<ToolCommand> {
match tool {
ToolDefinition::Single(spec) => self.build_tool_command(spec, context),
ToolDefinition::Pipeline(tasks) => {
let pipeline_config = serde_json::to_value(tasks).ok();
let config = if let Some(cfg) = pipeline_config {
Some(self.renderer.render_value(&cfg, context)?)
} else {
None
};
Ok(ToolCommand {
kind: "task_sequence".to_string(),
config,
timeout: None,
})
}
}
}
fn build_tool_command(
&self,
tool: &ToolSpec,
context: &HashMap<String, serde_json::Value>,
) -> AppResult<ToolCommand> {
let kind = tool.kind.to_string();
let tool_call = ToolCall::from_spec(tool);
let config_value = serde_json::to_value(&tool_call.config).ok();
let config = if let Some(cfg) = config_value {
Some(self.renderer.render_value(&cfg, context)?)
} else {
None
};
Ok(ToolCommand {
kind,
config,
timeout: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn build_playbook_call(
&self,
command_id: i64,
execution_id: i64,
catalog_id: i64,
parent_event_id: i64,
step_name: &str,
playbook_path: &str,
playbook_version: Option<&str>,
args: Option<&serde_json::Value>,
context: &HashMap<String, serde_json::Value>,
) -> Command {
let config = serde_json::json!({
"path": playbook_path,
"version": playbook_version.unwrap_or("latest"),
"args": args.cloned().unwrap_or(serde_json::Value::Null),
});
Command {
command_id,
execution_id,
catalog_id,
parent_event_id,
step_name: step_name.to_string(),
tool: ToolCommand {
kind: "playbook".to_string(),
config: Some(config),
timeout: None,
},
context: Some(context.clone()),
metadata: None,
iterator: None,
}
}
pub fn build_noop_command(
&self,
command_id: i64,
execution_id: i64,
catalog_id: i64,
parent_event_id: i64,
step_name: &str,
context: &HashMap<String, serde_json::Value>,
) -> Command {
Command {
command_id,
execution_id,
catalog_id,
parent_event_id,
step_name: step_name.to_string(),
tool: ToolCommand {
kind: "noop".to_string(),
config: None,
timeout: None,
},
context: Some(context.clone()),
metadata: None,
iterator: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::playbook::types::ToolKind;
#[test]
fn test_command_serialization() {
let mut context = HashMap::new();
context.insert("key".to_string(), serde_json::json!("value"));
let command = Command {
command_id: 12345,
execution_id: 67890,
catalog_id: 11111,
parent_event_id: 22222,
step_name: "test_step".to_string(),
tool: ToolCommand {
kind: "http".to_string(),
config: Some(serde_json::json!({
"url": "https://example.com",
"method": "GET"
})),
timeout: Some(30),
},
context: Some(context),
metadata: None,
iterator: None,
};
let json = serde_json::to_string(&command).unwrap();
assert!(json.contains("test_step"));
assert!(json.contains("http"));
assert!(json.contains("example.com"));
}
#[test]
fn test_build_command() {
let builder = CommandBuilder::new();
let step = Step {
step: "test_step".to_string(),
desc: None,
spec: None,
when: None,
args: None,
vars: None,
r#loop: None,
tool: ToolDefinition::Single(ToolSpec {
kind: ToolKind::Http,
auth: None,
libs: None,
args: None,
code: None,
url: Some("https://{{ host }}/api".to_string()),
method: Some("GET".to_string()),
query: None,
command: None,
connection: None,
params: None,
headers: None,
eval: None,
output_select: None,
extra: HashMap::new(),
}),
next: None,
};
let mut context = HashMap::new();
context.insert("host".to_string(), serde_json::json!("example.com"));
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
assert_eq!(command.step_name, "test_step");
assert_eq!(command.tool.kind, "http");
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/api")
);
}
#[test]
fn test_build_iteration_command() {
let builder = CommandBuilder::new();
let step = Step {
step: "process_item".to_string(),
desc: None,
spec: None,
when: None,
args: None,
vars: None,
r#loop: None,
tool: ToolDefinition::Single(ToolSpec {
kind: ToolKind::Python,
auth: None,
libs: None,
args: None,
code: Some("return {'item': '{{ item }}'}".to_string()),
url: None,
method: None,
query: None,
command: None,
connection: None,
params: None,
headers: None,
eval: None,
output_select: None,
extra: HashMap::new(),
}),
next: None,
};
let context = HashMap::new();
let iterator = IteratorMetadata {
parent_execution_id: 100,
iterator_step: "loop_step".to_string(),
index: 2,
total: 5,
item: serde_json::json!("test_value"),
item_var: "item".to_string(),
};
let command = builder
.build_iteration_command(1, 2, 3, 4, &step, &context, iterator)
.unwrap();
assert!(command.iterator.is_some());
let iter = command.iterator.unwrap();
assert_eq!(iter.index, 2);
assert_eq!(iter.total, 5);
}
#[test]
fn test_build_noop_command() {
let builder = CommandBuilder::new();
let mut context = HashMap::new();
context.insert("key".to_string(), serde_json::json!("value"));
let command = builder.build_noop_command(1, 2, 3, 4, "noop_step", &context);
assert_eq!(command.tool.kind, "noop");
assert!(command.tool.config.is_none());
}
#[test]
fn test_build_pipeline_command() {
let builder = CommandBuilder::new();
let mut fetch_task = HashMap::new();
fetch_task.insert(
"fetch".to_string(),
ToolSpec {
kind: ToolKind::Http,
auth: None,
libs: None,
args: None,
code: None,
url: Some("https://api.example.com".to_string()),
method: Some("GET".to_string()),
query: None,
command: None,
connection: None,
params: None,
headers: None,
eval: None,
output_select: None,
extra: HashMap::new(),
},
);
let mut transform_task = HashMap::new();
transform_task.insert(
"transform".to_string(),
ToolSpec {
kind: ToolKind::Python,
auth: None,
libs: None,
args: None,
code: Some("result = {'processed': True}".to_string()),
url: None,
method: None,
query: None,
command: None,
connection: None,
params: None,
headers: None,
eval: None,
output_select: None,
extra: HashMap::new(),
},
);
let step = Step {
step: "pipeline_step".to_string(),
desc: None,
spec: None,
when: None,
args: None,
vars: None,
r#loop: None,
tool: ToolDefinition::Pipeline(vec![fetch_task, transform_task]),
next: None,
};
let context = HashMap::new();
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
assert_eq!(command.step_name, "pipeline_step");
assert_eq!(command.tool.kind, "task_sequence");
assert!(command.tool.config.is_some());
}
}