1use std::sync::LazyLock;
7
8use handlebars::Handlebars;
9use thiserror::Error;
10
11use crate::types::GenerateSkillResult;
12
13#[derive(Debug, Error)]
15pub enum TemplateError {
16 #[error("template rendering failed: {0}")]
18 RenderFailed(#[from] handlebars::RenderError),
19
20 #[error("template registration failed: {0}")]
22 RegistrationFailed(#[from] handlebars::TemplateError),
23}
24
25const SKILL_GENERATION_TEMPLATE: &str = include_str!("templates/skill-generation.hbs");
27
28const SKILL_MD_TEMPLATE: &str = include_str!("templates/skill-md.hbs");
30
31static HANDLEBARS: LazyLock<Handlebars<'static>> = LazyLock::new(|| {
36 let mut hb = Handlebars::new();
37 hb.register_template_string("skill", SKILL_GENERATION_TEMPLATE)
38 .expect("embedded skill-generation template must be valid Handlebars syntax");
39 hb.register_template_string("skill-md", SKILL_MD_TEMPLATE)
40 .expect("embedded skill-md template must be valid Handlebars syntax");
41 hb
42});
43
44fn yaml_quote(s: &str) -> String {
50 let escaped = s
51 .replace('\\', "\\\\")
52 .replace('"', "\\\"")
53 .replace('\n', "\\n")
54 .replace('\r', "");
55 format!("\"{escaped}\"")
56}
57
58pub fn render_generation_prompt(context: &GenerateSkillResult) -> Result<String, TemplateError> {
84 let rendered = HANDLEBARS.render("skill", context)?;
85 Ok(rendered)
86}
87
88pub fn render_skill_md(context: &GenerateSkillResult) -> Result<String, TemplateError> {
129 let mut value =
132 serde_json::to_value(context).expect("GenerateSkillResult serialization is infallible");
133
134 if let Some(desc) = value
137 .get("server_description")
138 .and_then(|v| v.as_str())
139 .map(yaml_quote)
140 {
141 value["server_description"] = serde_json::Value::String(desc);
142 }
143
144 let rendered = HANDLEBARS.render("skill-md", &value)?;
145 Ok(rendered.replace("\r\n", "\n"))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::types::{SkillCategory, SkillTool, ToolExample};
153
154 fn create_test_context() -> GenerateSkillResult {
155 GenerateSkillResult {
156 server_id: "test".to_string(),
157 skill_name: "test-progressive".to_string(),
158 server_description: Some("Test server".to_string()),
159 categories: vec![SkillCategory {
160 name: "test".to_string(),
161 display_name: "Test".to_string(),
162 tools: vec![SkillTool {
163 name: "test_tool".to_string(),
164 typescript_name: "testTool".to_string(),
165 description: "Test tool description".to_string(),
166 keywords: vec!["test".to_string()],
167 required_params: vec!["param1".to_string()],
168 optional_params: vec![],
169 }],
170 }],
171 tool_count: 1,
172 example_tools: vec![ToolExample {
173 tool_name: "test_tool".to_string(),
174 description: "Test tool".to_string(),
175 cli_command: "node test.ts".to_string(),
176 params_json: "{}".to_string(),
177 }],
178 generation_prompt: "Pre-built prompt".to_string(),
179 output_path: "~/.claude/skills/test/SKILL.md".to_string(),
180 warnings: vec![],
181 }
182 }
183
184 #[test]
185 fn test_render_generation_prompt() {
186 let context = create_test_context();
187 let result = render_generation_prompt(&context);
188
189 match result {
190 Ok(prompt) => {
191 assert!(prompt.contains("test"));
192 assert!(prompt.contains("SKILL.md"));
193 }
194 Err(e) => panic!("Template rendering failed: {e}"),
195 }
196 }
197
198 #[test]
199 fn test_render_skill_md() {
200 let context = create_test_context();
201 let result = render_skill_md(&context);
202
203 match result {
204 Ok(md) => {
205 assert!(md.starts_with("---\n"), "must start with YAML frontmatter");
206 assert!(md.contains("name: test-progressive"));
207 assert!(md.contains("# test-progressive"));
208 assert!(md.contains("~/.claude/servers/test/"));
209 assert!(md.contains("testTool"));
210 }
211 Err(e) => panic!("render_skill_md failed: {e}"),
212 }
213 }
214
215 #[test]
216 fn test_render_skill_md_html_special_chars_not_escaped() {
217 let mut context = create_test_context();
218 context.categories = vec![SkillCategory {
219 name: "test".to_string(),
220 display_name: "Test".to_string(),
221 tools: vec![SkillTool {
222 name: "my_tool".to_string(),
223 typescript_name: "myTool".to_string(),
224 description: "Create & update <items> with \"quotes\"".to_string(),
225 keywords: vec![],
226 required_params: vec![],
227 optional_params: vec![],
228 }],
229 }];
230
231 let md = render_skill_md(&context).unwrap();
232 assert!(md.contains('&'), "& must not be HTML-escaped");
234 assert!(md.contains('<'), "< must not be HTML-escaped");
235 }
236
237 #[test]
238 fn test_render_skill_md_yaml_frontmatter_safe() {
239 let mut context = create_test_context();
241 context.server_description = Some("GitHub: issues & CI\nname: injected".to_string());
242
243 let md = render_skill_md(&context).unwrap();
244
245 let after_open = md.strip_prefix("---\n").expect("must start with ---");
247 let fm_end = after_open.find("\n---").expect("must have closing ---");
248 let frontmatter = &after_open[..fm_end];
249
250 let name_count = frontmatter
252 .lines()
253 .filter(|l| l.starts_with("name:"))
254 .count();
255 assert_eq!(
256 name_count, 1,
257 "YAML key injection detected in: {frontmatter}"
258 );
259
260 let desc_line = frontmatter
262 .lines()
263 .find(|l| l.starts_with("description:"))
264 .expect("description key must be present");
265 assert!(
266 desc_line.contains('"'),
267 "description must be YAML-quoted: {desc_line}"
268 );
269 }
270
271 #[test]
278 fn test_render_skill_md_end_to_end_flattens_injected_markdown_structure() {
279 use crate::build_skill_context;
280 use crate::parser::ParsedToolFile;
281
282 let hostile = ParsedToolFile {
283 name: "evil_tool".to_string(),
284 typescript_name: "evilTool".to_string(),
285 server_id: "test".to_string(),
286 category: Some("test".to_string()),
287 keywords: vec![],
288 description: Some(
289 "safe text\n### Injected Heading\n```\ninjected fenced block\n```\n\
290 - fake list item"
291 .to_string(),
292 ),
293 parameters: vec![],
294 };
295
296 let context = build_skill_context("test", std::slice::from_ref(&hostile), None);
297 let md = render_skill_md(&context).unwrap();
298
299 assert_eq!(
307 md.lines().filter(|l| l.starts_with("###")).count(),
308 1,
309 "tool description must not introduce a new heading line: {md}"
310 );
311 assert_eq!(
312 md.lines()
313 .filter(|l| l.trim_start().starts_with("```"))
314 .count(),
315 8,
316 "tool description must not introduce a new fenced-code-block delimiter line: {md}"
317 );
318 assert!(
319 md.contains("Injected Heading"),
320 "sanitized content must still render, just inert"
321 );
322 }
323
324 #[test]
329 fn test_render_skill_md_end_to_end_flattens_injected_category_heading() {
330 use crate::build_skill_context;
331 use crate::parser::ParsedToolFile;
332
333 let hostile = ParsedToolFile {
334 name: "evil_tool".to_string(),
335 typescript_name: "evilTool".to_string(),
336 server_id: "test".to_string(),
337 category: Some("issues\n### Injected Heading".to_string()),
338 keywords: vec![],
339 description: Some("safe description".to_string()),
340 parameters: vec![],
341 };
342
343 let context = build_skill_context("test", std::slice::from_ref(&hostile), None);
344 let md = render_skill_md(&context).unwrap();
345
346 assert_eq!(
349 md.lines().filter(|l| l.starts_with("###")).count(),
350 1,
351 "hostile category must not introduce a new heading line: {md}"
352 );
353 assert!(
354 md.contains("Injected Heading"),
355 "sanitized content must still render, just inert"
356 );
357 }
358
359 #[test]
360 fn test_yaml_quote() {
361 assert_eq!(yaml_quote("simple"), "\"simple\"");
362 assert_eq!(yaml_quote("GitHub: issues"), "\"GitHub: issues\"");
363 assert_eq!(yaml_quote("line1\nline2"), "\"line1\\nline2\"");
364 assert_eq!(yaml_quote(r#"has "quotes""#), r#""has \"quotes\"""#);
365 assert_eq!(yaml_quote("has \\backslash"), "\"has \\\\backslash\"");
366 }
367}