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 mut render_ctx = context.clone();
let ctx_value = serde_json::to_value(context).unwrap_or(serde_json::Value::Null);
render_ctx
.entry("ctx".to_string())
.or_insert_with(|| ctx_value.clone());
render_ctx
.entry("workload".to_string())
.or_insert_with(|| ctx_value);
let tool_command = self.build_tool_from_definition(&step.tool, &render_ctx)?;
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 mut render_ctx = iter_context.clone();
let ctx_value = serde_json::to_value(&iter_context).unwrap_or(serde_json::Value::Null);
render_ctx
.entry("ctx".to_string())
.or_insert_with(|| ctx_value.clone());
render_ctx
.entry("workload".to_string())
.or_insert_with(|| ctx_value);
let mut tool_command = self.build_tool_from_definition(&step.tool, &render_ctx)?;
if let Some(serde_json::Value::Object(cfg)) = tool_command.config.as_mut() {
let args_entry = cfg
.entry("args".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(args) = args_entry {
args.insert(iterator.item_var.clone(), iterator.item.clone());
args.insert("_index".to_string(), serde_json::json!(iterator.index));
args.insert("_total".to_string(), serde_json::json!(iterator.total));
}
}
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_deferring(
&cfg,
context,
&["_prev", "_results"],
)?)
} 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,
set_vars: None,
r#loop: None,
tool: ToolDefinition::Single(Box::new(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,
set_vars: None,
r#loop: None,
tool: ToolDefinition::Single(Box::new(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.as_ref().unwrap();
assert_eq!(iter.index, 2);
assert_eq!(iter.total, 5);
let tool_cfg = command.tool.config.as_ref().expect("tool config present");
let args = tool_cfg.get("args").expect("args injected");
assert_eq!(args.get("item"), Some(&serde_json::json!("test_value")));
assert_eq!(args.get("_index"), Some(&serde_json::json!(2)));
assert_eq!(args.get("_total"), Some(&serde_json::json!(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,
set_vars: None,
r#loop: None,
tool: ToolDefinition::Pipeline(vec![
crate::playbook::types::PipelineItem::Nested(fetch_task),
crate::playbook::types::PipelineItem::Nested(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());
}
fn make_http_step_with_url(url: &str) -> Step {
Step {
step: "test_step".to_string(),
desc: None,
spec: None,
when: None,
args: None,
vars: None,
set_vars: None,
r#loop: None,
tool: ToolDefinition::Single(Box::new(ToolSpec {
kind: ToolKind::Http,
auth: None,
libs: None,
args: None,
code: None,
url: Some(url.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,
}
}
#[test]
fn test_build_command_exposes_ctx_namespace() {
let builder = CommandBuilder::new();
let step = make_http_step_with_url("https://example.com/{{ ctx.foo }}");
let mut context = HashMap::new();
context.insert("foo".to_string(), serde_json::json!(42));
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/42"),
"{{ ctx.foo }} should resolve to 42 via the ctx namespace shim"
);
let persisted = command.context.unwrap();
assert!(
!persisted.contains_key("ctx"),
"persisted context must not carry ctx shim"
);
}
#[test]
fn test_build_command_exposes_workload_namespace() {
let builder = CommandBuilder::new();
let step = make_http_step_with_url("https://example.com/{{ workload.foo }}");
let mut context = HashMap::new();
context.insert("foo".to_string(), serde_json::json!(42));
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/42"),
"{{ workload.foo }} should resolve to 42 via the workload namespace shim"
);
}
#[test]
fn test_build_command_preserves_existing_workload() {
let builder = CommandBuilder::new();
let step = make_http_step_with_url("https://example.com/{{ workload.session_token }}");
let mut context = HashMap::new();
context.insert(
"workload".to_string(),
serde_json::json!({ "session_token": "abc123" }),
);
context.insert("foo".to_string(), serde_json::json!(99));
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/abc123"),
"existing workload.session_token must survive — shim must not clobber"
);
}
#[test]
fn test_build_command_preserves_flat_top_level_keys() {
let builder = CommandBuilder::new();
let step = make_http_step_with_url("https://{{ host }}/{{ ctx.path }}");
let mut context = HashMap::new();
context.insert("host".to_string(), serde_json::json!("example.com"));
context.insert("path".to_string(), serde_json::json!("api/v1"));
let command = builder
.build_command(1, 2, 3, 4, &step, &context, None)
.unwrap();
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/api/v1"),
"flat top-level key {{ host }} and ctx-namespaced {{ ctx.path }} must both resolve"
);
}
#[test]
fn test_build_iteration_command_ctx_includes_iterator_var() {
let builder = CommandBuilder::new();
let step = make_http_step_with_url("https://example.com/{{ ctx.num }}");
let context = HashMap::new();
let iterator = IteratorMetadata {
parent_execution_id: 100,
iterator_step: "loop_step".to_string(),
index: 0,
total: 3,
item: serde_json::json!(42),
item_var: "num".to_string(),
};
let command = builder
.build_iteration_command(1, 2, 3, 4, &step, &context, iterator)
.unwrap();
let config = command.tool.config.unwrap();
assert_eq!(
config.get("url").and_then(|v| v.as_str()),
Some("https://example.com/42"),
"{{ ctx.num }} must resolve to 42 via the ctx shim in an iteration command"
);
let persisted = command.context.unwrap();
assert!(
!persisted.contains_key("ctx"),
"persisted iter_context must not carry ctx shim"
);
}
}