rustant_tools/
template.rs1use async_trait::async_trait;
4use handlebars::Handlebars;
5use rustant_core::error::ToolError;
6use rustant_core::types::{RiskLevel, ToolOutput};
7use serde_json::{Value, json};
8use std::path::PathBuf;
9
10use crate::registry::Tool;
11
12pub struct TemplateTool {
13 workspace: PathBuf,
14}
15
16impl TemplateTool {
17 pub fn new(workspace: PathBuf) -> Self {
18 Self { workspace }
19 }
20}
21
22#[async_trait]
23impl Tool for TemplateTool {
24 fn name(&self) -> &str {
25 "template"
26 }
27 fn description(&self) -> &str {
28 "Render Handlebars templates with variables. Actions: render, list_templates."
29 }
30 fn parameters_schema(&self) -> Value {
31 json!({
32 "type": "object",
33 "properties": {
34 "action": {
35 "type": "string",
36 "enum": ["render", "list_templates"],
37 "description": "Action to perform"
38 },
39 "template": { "type": "string", "description": "Template string or file path" },
40 "variables": { "type": "object", "description": "Template variables as key-value pairs" },
41 "output_path": { "type": "string", "description": "Optional file path to write output" }
42 },
43 "required": ["action"]
44 })
45 }
46 fn risk_level(&self) -> RiskLevel {
47 RiskLevel::ReadOnly
48 }
49
50 async fn execute(&self, args: Value) -> Result<ToolOutput, ToolError> {
51 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
52
53 match action {
54 "render" => {
55 let template_str = args.get("template").and_then(|v| v.as_str()).unwrap_or("");
56 if template_str.is_empty() {
57 return Ok(ToolOutput::text(
58 "Please provide a template string or file path.",
59 ));
60 }
61
62 let template_content =
64 if template_str.ends_with(".hbs") || template_str.ends_with(".handlebars") {
65 let path = self.workspace.join(template_str);
66 std::fs::read_to_string(&path).map_err(|e| ToolError::ExecutionFailed {
67 name: "template".into(),
68 message: format!("Failed to read template file: {}", e),
69 })?
70 } else {
71 template_str.to_string()
72 };
73
74 let variables = args.get("variables").cloned().unwrap_or(json!({}));
75
76 let mut handlebars = Handlebars::new();
77 handlebars.set_strict_mode(false);
78 let rendered = handlebars
79 .render_template(&template_content, &variables)
80 .map_err(|e| ToolError::ExecutionFailed {
81 name: "template".into(),
82 message: format!("Template render error: {}", e),
83 })?;
84
85 if let Some(output_path) = args.get("output_path").and_then(|v| v.as_str()) {
87 let path = self.workspace.join(output_path);
88 if let Some(parent) = path.parent() {
89 std::fs::create_dir_all(parent).ok();
90 }
91 std::fs::write(&path, &rendered).map_err(|e| ToolError::ExecutionFailed {
92 name: "template".into(),
93 message: format!("Failed to write output: {}", e),
94 })?;
95 return Ok(ToolOutput::text(format!(
96 "Rendered template written to {}.",
97 output_path
98 )));
99 }
100
101 Ok(ToolOutput::text(rendered))
102 }
103 "list_templates" => {
104 let templates_dir = self.workspace.join(".rustant").join("templates");
105 if !templates_dir.exists() {
106 return Ok(ToolOutput::text(
107 "No templates directory found. Create .rustant/templates/ with .hbs files.",
108 ));
109 }
110 let mut templates = Vec::new();
111 if let Ok(entries) = std::fs::read_dir(&templates_dir) {
112 for entry in entries.flatten() {
113 let name = entry.file_name().to_string_lossy().to_string();
114 if name.ends_with(".hbs") || name.ends_with(".handlebars") {
115 templates.push(name);
116 }
117 }
118 }
119 if templates.is_empty() {
120 Ok(ToolOutput::text(
121 "No template files found in .rustant/templates/.",
122 ))
123 } else {
124 Ok(ToolOutput::text(format!(
125 "Templates ({}):\n{}",
126 templates.len(),
127 templates
128 .iter()
129 .map(|t| format!(" {}", t))
130 .collect::<Vec<_>>()
131 .join("\n")
132 )))
133 }
134 }
135 _ => Ok(ToolOutput::text(format!(
136 "Unknown action: {}. Use: render, list_templates",
137 action
138 ))),
139 }
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use tempfile::TempDir;
147
148 #[tokio::test]
149 async fn test_template_render() {
150 let dir = TempDir::new().unwrap();
151 let workspace = dir.path().canonicalize().unwrap();
152 let tool = TemplateTool::new(workspace);
153
154 let result = tool
155 .execute(json!({
156 "action": "render",
157 "template": "Hello, {{name}}! You have {{count}} messages.",
158 "variables": {"name": "Alice", "count": 5}
159 }))
160 .await
161 .unwrap();
162 assert!(result.content.contains("Hello, Alice!"));
163 assert!(result.content.contains("5 messages"));
164 }
165
166 #[tokio::test]
167 async fn test_template_render_missing_vars() {
168 let dir = TempDir::new().unwrap();
169 let workspace = dir.path().canonicalize().unwrap();
170 let tool = TemplateTool::new(workspace);
171
172 let result = tool
173 .execute(json!({
174 "action": "render",
175 "template": "Hello, {{name}}!",
176 "variables": {}
177 }))
178 .await
179 .unwrap();
180 assert!(result.content.contains("Hello, !"));
182 }
183
184 #[tokio::test]
185 async fn test_template_list_empty() {
186 let dir = TempDir::new().unwrap();
187 let workspace = dir.path().canonicalize().unwrap();
188 let tool = TemplateTool::new(workspace);
189
190 let result = tool
191 .execute(json!({"action": "list_templates"}))
192 .await
193 .unwrap();
194 assert!(result.content.contains("No templates"));
195 }
196
197 #[tokio::test]
198 async fn test_template_schema() {
199 let dir = TempDir::new().unwrap();
200 let tool = TemplateTool::new(dir.path().to_path_buf());
201 assert_eq!(tool.name(), "template");
202 assert_eq!(tool.risk_level(), RiskLevel::ReadOnly);
203 }
204}