use crate::brain::tools::dynamic::tool::{
CoerceAction, DynamicTool, DynamicToolDef, ExecutorType, ParamDef,
};
use crate::brain::tools::{Tool, ToolExecutionContext};
use serde_json::json;
fn param(name: &str, empty: CoerceAction, null: CoerceAction) -> ParamDef {
ParamDef {
name: name.to_string(),
param_type: "array".to_string(),
description: String::new(),
required: false,
default: None,
coerce_empty_to: empty,
coerce_null_to: null,
}
}
fn shell_def(command: &str, params: Vec<ParamDef>) -> DynamicToolDef {
DynamicToolDef {
name: "test_tool".to_string(),
description: "test".to_string(),
executor: ExecutorType::Shell,
enabled: true,
requires_approval: false,
method: None,
url: None,
headers: Default::default(),
timeout_secs: 5,
command: Some(command.to_string()),
params,
}
}
#[test]
fn template_section_renders_when_key_present() {
let params = json!({ "ids": "1,2,3" });
let out = DynamicToolDef::render_template("cmd {{#ids}}--ids {{ids}}{{/ids}}", ¶ms);
assert_eq!(out, "cmd --ids 1,2,3");
}
#[test]
fn template_section_drops_when_key_absent() {
let params = json!({});
let out = DynamicToolDef::render_template("cmd {{#ids}}--ids {{ids}}{{/ids}}", ¶ms);
assert_eq!(out, "cmd ");
}
#[test]
fn template_multiple_sections_independent() {
let params = json!({ "a": "1" });
let out = DynamicToolDef::render_template("{{#a}}A={{a}}{{/a}} {{#b}}B={{b}}{{/b}}", ¶ms);
assert_eq!(out, "A=1 ");
}
#[test]
fn coerce_empty_omit_drops_key() {
let def = shell_def(
"true {{#ids}}--ids {{ids}}{{/ids}}",
vec![param("ids", CoerceAction::Omit, CoerceAction::Keep)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "ids": [] }), &ctx))
.expect("execute");
assert!(
result.success,
"shell omit should leave a clean command. error: {:?}",
result.error
);
}
#[test]
fn coerce_empty_null_replaces_value() {
let def = shell_def(
"true {{#ids}}--ids {{ids}}{{/ids}}",
vec![param("ids", CoerceAction::Null, CoerceAction::Keep)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let _ = rt.block_on(tool.execute(json!({ "ids": [] }), &ctx));
}
#[test]
fn coerce_empty_error_returns_error() {
let def = shell_def(
"true {{ids}}",
vec![param("ids", CoerceAction::Error, CoerceAction::Keep)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "ids": [] }), &ctx))
.expect("execute");
assert!(!result.success, "Error action must reject before exec");
let err = result.error.unwrap_or_default();
assert!(
err.contains("'ids'") && err.contains("empty"),
"error should explain which param + shape, got: {err}"
);
}
#[test]
fn coerce_null_error_returns_error() {
let def = shell_def(
"true {{ids}}",
vec![param("ids", CoerceAction::Keep, CoerceAction::Error)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "ids": null }), &ctx))
.expect("execute");
assert!(!result.success);
let err = result.error.unwrap_or_default();
assert!(err.contains("'ids'") && err.contains("null"), "got: {err}");
}
#[test]
fn coerce_keep_is_default_and_passes_through() {
let def = shell_def(
"true {{ids}}",
vec![param("ids", CoerceAction::Keep, CoerceAction::Keep)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "ids": [] }), &ctx))
.expect("execute");
assert!(result.success);
}
#[test]
fn coerce_non_empty_value_untouched() {
let def = shell_def(
"echo {{ids}}",
vec![param("ids", CoerceAction::Omit, CoerceAction::Error)],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "ids": [1, 2, 3] }), &ctx))
.expect("execute");
assert!(
result.success,
"non-empty value should bypass both rules. err: {:?}",
result.error
);
assert!(result.output.contains('1'), "echo'd: {}", result.output);
}
#[test]
fn coerce_empty_string_treated_as_empty() {
let def = shell_def(
"true {{label}}",
vec![ParamDef {
name: "label".into(),
param_type: "string".into(),
description: String::new(),
required: false,
default: None,
coerce_empty_to: CoerceAction::Error,
coerce_null_to: CoerceAction::Keep,
}],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "label": "" }), &ctx))
.expect("execute");
assert!(!result.success);
assert!(result.error.unwrap_or_default().contains("empty"));
}
#[test]
fn coerce_empty_object_treated_as_empty() {
let def = shell_def(
"true {{cfg}}",
vec![ParamDef {
name: "cfg".into(),
param_type: "object".into(),
description: String::new(),
required: false,
default: None,
coerce_empty_to: CoerceAction::Omit,
coerce_null_to: CoerceAction::Keep,
}],
);
let tool = DynamicTool::new(def);
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt
.block_on(tool.execute(json!({ "cfg": {} }), &ctx))
.expect("execute");
assert!(result.success);
}