Skip to main content

rustant_tools/
template.rs

1//! Template engine tool — render Handlebars templates.
2
3use 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                // Check if it's a file path
63                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                // Optionally write to file
86                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        // With strict_mode=false, missing vars render as empty
181        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}