use async_trait::async_trait;
use minijinja::Environment;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::generate_schema;
use ai_agents_core::{
Tool, ToolCallClassification, ToolOperationKind, ToolResult, ToolSafetyMetadata,
};
pub struct TemplateTool {
env: Environment<'static>,
}
impl TemplateTool {
pub fn new() -> Self {
let mut env = Environment::new();
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
Self { env }
}
fn render_template(&self, template: &str, data: &Value) -> Result<String, String> {
let tmpl = self
.env
.template_from_str(template)
.map_err(|e| format!("Template parse error: {}", e))?;
tmpl.render(data)
.map_err(|e| format!("Render error: {}", e))
}
}
impl Default for TemplateTool {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize, JsonSchema)]
struct TemplateInput {
operation: String,
#[serde(default)]
template: Option<String>,
#[serde(default)]
path: Option<String>,
data: Value,
}
#[derive(Debug, Serialize, Deserialize)]
struct RenderOutput {
rendered: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct RenderFileOutput {
rendered: String,
template_path: String,
}
#[async_trait]
impl Tool for TemplateTool {
fn id(&self) -> &str {
"template"
}
fn name(&self) -> &str {
"Template Renderer"
}
fn description(&self) -> &str {
"Render Jinja2-style templates with data. Operations: render (inline template), render_file (from file). Supports variables ({{ var }}), filters ({{ name|upper }}), conditionals ({% if %}), and loops ({% for %})."
}
fn input_schema(&self) -> Value {
generate_schema::<TemplateInput>()
}
fn safety_metadata(&self) -> ToolSafetyMetadata {
ToolSafetyMetadata::read_only(ToolOperationKind::Read)
}
fn classify_call(&self, args: &Value) -> ToolCallClassification {
let operation = args
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or_default();
if operation.eq_ignore_ascii_case("render_file") {
ToolCallClassification::from_metadata(&ToolSafetyMetadata::read_only(
ToolOperationKind::Read,
))
} else {
ToolCallClassification::from_metadata(&ToolSafetyMetadata::compute())
}
}
async fn execute(&self, args: Value, _ctx: ai_agents_core::ToolExecutionContext) -> ToolResult {
let input: TemplateInput = match serde_json::from_value(args) {
Ok(input) => input,
Err(e) => return ToolResult::error(format!("Invalid input: {}", e)),
};
match input.operation.to_lowercase().as_str() {
"render" => self.handle_render(&input),
"render_file" => self.handle_render_file(&input),
_ => ToolResult::error(format!(
"Unknown operation: {}. Valid: render, render_file",
input.operation
)),
}
}
}
impl TemplateTool {
fn handle_render(&self, input: &TemplateInput) -> ToolResult {
let template = match &input.template {
Some(t) => t,
None => return ToolResult::error("'template' is required for render operation"),
};
match self.render_template(template, &input.data) {
Ok(rendered) => {
let output = RenderOutput { rendered };
self.to_result(&output)
}
Err(e) => ToolResult::error(e),
}
}
fn handle_render_file(&self, input: &TemplateInput) -> ToolResult {
let path = match &input.path {
Some(p) => p,
None => return ToolResult::error("'path' is required for render_file operation"),
};
let template = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => return ToolResult::error(format!("File read error: {}", e)),
};
match self.render_template(&template, &input.data) {
Ok(rendered) => {
let output = RenderFileOutput {
rendered,
template_path: path.clone(),
};
self.to_result(&output)
}
Err(e) => ToolResult::error(e),
}
}
fn to_result<T: Serialize>(&self, output: &T) -> ToolResult {
match serde_json::to_string(output) {
Ok(json) => ToolResult::ok(json),
Err(e) => ToolResult::error(format!("Serialization error: {}", e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_render_simple() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"template": "Hello {{ name }}!",
"data": {"name": "World"}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(result.success);
let output: RenderOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "Hello World!");
}
#[tokio::test]
async fn test_render_with_filter() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"template": "Hello {{ name|upper }}!",
"data": {"name": "world"}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(result.success);
let output: RenderOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "Hello WORLD!");
}
#[tokio::test]
async fn test_render_with_loop() {
let tool = TemplateTool::new();
let result = tool
.execute(serde_json::json!({
"operation": "render",
"template": "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}",
"data": {"items": ["a", "b", "c"]}
}), ai_agents_core::ToolExecutionContext::test("test"))
.await;
assert!(result.success);
let output: RenderOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "a, b, c");
}
#[tokio::test]
async fn test_render_with_conditional() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"template": "{% if show %}visible{% else %}hidden{% endif %}",
"data": {"show": true}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(result.success);
let output: RenderOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "visible");
}
#[tokio::test]
async fn test_render_nested_data() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"template": "Order #{{ order.id }}: {{ order.items|length }} items",
"data": {
"order": {
"id": "12345",
"items": ["item1", "item2", "item3"]
}
}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(result.success);
let output: RenderOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "Order #12345: 3 items");
}
#[tokio::test]
async fn test_render_file() {
let dir = tempdir().unwrap();
let file_path = dir.path().join("template.txt");
std::fs::write(&file_path, "Hello {{ name }}!").unwrap();
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render_file",
"path": file_path.to_str().unwrap(),
"data": {"name": "File"}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(result.success);
let output: RenderFileOutput = serde_json::from_str(&result.output).unwrap();
assert_eq!(output.rendered, "Hello File!");
}
#[tokio::test]
async fn test_render_missing_template() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"data": {}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(!result.success);
}
#[tokio::test]
async fn test_render_invalid_template() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "render",
"template": "{{ unclosed",
"data": {}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(!result.success);
}
#[tokio::test]
async fn test_invalid_operation() {
let tool = TemplateTool::new();
let result = tool
.execute(
serde_json::json!({
"operation": "invalid",
"data": {}
}),
ai_agents_core::ToolExecutionContext::test("test"),
)
.await;
assert!(!result.success);
}
}