1use anyhow::{Context, Result};
32use regex::Regex;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::fs;
36use std::path::{Path, PathBuf};
37
38pub const PROJECT_TEMPLATES_DIR: &str = ".chant/templates";
40
41pub const GLOBAL_TEMPLATES_DIR: &str = "templates";
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TemplateVariable {
47 pub name: String,
49 #[serde(default)]
51 pub description: String,
52 #[serde(default)]
54 pub required: bool,
55 #[serde(default)]
57 pub default: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TemplateFrontmatter {
63 pub name: String,
65 #[serde(default)]
67 pub description: String,
68 #[serde(default)]
70 pub variables: Vec<TemplateVariable>,
71 #[serde(default)]
73 pub r#type: Option<String>,
74 #[serde(default)]
76 pub labels: Option<Vec<String>>,
77 #[serde(default)]
79 pub target_files: Option<Vec<String>>,
80 #[serde(default)]
82 pub context: Option<Vec<String>>,
83 #[serde(default)]
85 pub prompt: Option<String>,
86}
87
88#[derive(Debug, Clone)]
90pub struct SpecTemplate {
91 pub name: String,
93 pub frontmatter: TemplateFrontmatter,
95 pub body: String,
97 pub source: TemplateSource,
99 pub path: PathBuf,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum TemplateSource {
106 Project,
108 Global,
110}
111
112impl std::fmt::Display for TemplateSource {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 TemplateSource::Project => write!(f, "project"),
116 TemplateSource::Global => write!(f, "global"),
117 }
118 }
119}
120
121impl SpecTemplate {
122 pub fn parse(content: &str, path: &Path, source: TemplateSource) -> Result<Self> {
124 let (frontmatter_str, body) = split_frontmatter(content);
125
126 let frontmatter: TemplateFrontmatter = if let Some(fm) = frontmatter_str {
127 serde_yaml::from_str(&fm).context("Failed to parse template frontmatter")?
128 } else {
129 anyhow::bail!("Template must have YAML frontmatter with 'name' field");
130 };
131
132 if frontmatter.name.is_empty() {
133 anyhow::bail!("Template 'name' field is required and cannot be empty");
134 }
135
136 Ok(Self {
137 name: frontmatter.name.clone(),
138 frontmatter,
139 body: body.to_string(),
140 source,
141 path: path.to_path_buf(),
142 })
143 }
144
145 pub fn load(path: &Path, source: TemplateSource) -> Result<Self> {
147 let content = fs::read_to_string(path)
148 .with_context(|| format!("Failed to read template from {}", path.display()))?;
149 Self::parse(&content, path, source)
150 }
151
152 pub fn required_variables(&self) -> Vec<&TemplateVariable> {
154 self.frontmatter
155 .variables
156 .iter()
157 .filter(|v| v.required && v.default.is_none())
158 .collect()
159 }
160
161 pub fn validate_variables(&self, provided: &HashMap<String, String>) -> Result<()> {
163 let missing: Vec<_> = self
164 .required_variables()
165 .iter()
166 .filter(|v| !provided.contains_key(&v.name))
167 .map(|v| v.name.as_str())
168 .collect();
169
170 if !missing.is_empty() {
171 anyhow::bail!("Missing required variable(s): {}", missing.join(", "));
172 }
173
174 Ok(())
175 }
176
177 pub fn substitute(&self, text: &str, variables: &HashMap<String, String>) -> String {
179 let re = Regex::new(r"\{\{(\w+)\}\}").unwrap();
180
181 re.replace_all(text, |caps: ®ex::Captures| {
182 let var_name = &caps[1];
183
184 if let Some(value) = variables.get(var_name) {
186 return value.clone();
187 }
188
189 if let Some(var_def) = self
191 .frontmatter
192 .variables
193 .iter()
194 .find(|v| v.name == var_name)
195 {
196 if let Some(ref default) = var_def.default {
197 return default.clone();
198 }
199 }
200
201 caps[0].to_string()
203 })
204 .to_string()
205 }
206
207 pub fn render(&self, variables: &HashMap<String, String>) -> Result<String> {
209 self.validate_variables(variables)?;
211
212 let mut fm_lines = vec!["---".to_string()];
214
215 let spec_type = self.frontmatter.r#type.as_deref().unwrap_or("code");
217 fm_lines.push(format!("type: {}", spec_type));
218 fm_lines.push("status: pending".to_string());
219
220 if let Some(ref labels) = self.frontmatter.labels {
222 if !labels.is_empty() {
223 fm_lines.push("labels:".to_string());
224 for label in labels {
225 let substituted = self.substitute(label, variables);
226 fm_lines.push(format!(" - {}", substituted));
227 }
228 }
229 }
230
231 if let Some(ref target_files) = self.frontmatter.target_files {
233 if !target_files.is_empty() {
234 fm_lines.push("target_files:".to_string());
235 for file in target_files {
236 let substituted = self.substitute(file, variables);
237 fm_lines.push(format!(" - {}", substituted));
238 }
239 }
240 }
241
242 if let Some(ref context) = self.frontmatter.context {
244 if !context.is_empty() {
245 fm_lines.push("context:".to_string());
246 for ctx in context {
247 let substituted = self.substitute(ctx, variables);
248 fm_lines.push(format!(" - {}", substituted));
249 }
250 }
251 }
252
253 if let Some(ref prompt) = self.frontmatter.prompt {
255 fm_lines.push(format!("prompt: {}", prompt));
256 }
257
258 fm_lines.push("---".to_string());
259 fm_lines.push(String::new());
260
261 let frontmatter = fm_lines.join("\n");
262
263 let body = self.substitute(&self.body, variables);
265
266 Ok(format!("{}{}", frontmatter, body))
267 }
268}
269
270fn split_frontmatter(content: &str) -> (Option<String>, &str) {
273 let content = content.trim_start();
274
275 if !content.starts_with("---") {
276 return (None, content);
277 }
278
279 let after_first = &content[3..];
281 if let Some(end_pos) = after_first.find("\n---") {
282 let frontmatter = after_first[..end_pos].trim();
283 let body_start = 3 + end_pos + 4; let body = if body_start < content.len() {
285 content[body_start..].trim_start_matches('\n')
286 } else {
287 ""
288 };
289 (Some(frontmatter.to_string()), body)
290 } else {
291 (None, content)
292 }
293}
294
295pub fn project_templates_dir() -> PathBuf {
297 PathBuf::from(PROJECT_TEMPLATES_DIR)
298}
299
300pub fn global_templates_dir() -> Option<PathBuf> {
302 dirs::config_dir().map(|p| p.join("chant").join(GLOBAL_TEMPLATES_DIR))
303}
304
305fn load_templates_from_dir(dir: &Path, source: TemplateSource) -> Vec<SpecTemplate> {
307 let mut templates = Vec::new();
308
309 if !dir.exists() {
310 return templates;
311 }
312
313 if let Ok(entries) = fs::read_dir(dir) {
314 for entry in entries.flatten() {
315 let path = entry.path();
316 if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
317 match SpecTemplate::load(&path, source.clone()) {
318 Ok(template) => templates.push(template),
319 Err(e) => {
320 eprintln!("Warning: Failed to load template {}: {}", path.display(), e);
321 }
322 }
323 }
324 }
325 }
326
327 templates
328}
329
330pub fn load_all_templates() -> Vec<SpecTemplate> {
332 let mut templates_by_name: HashMap<String, SpecTemplate> = HashMap::new();
333
334 if let Some(global_dir) = global_templates_dir() {
336 for template in load_templates_from_dir(&global_dir, TemplateSource::Global) {
337 templates_by_name.insert(template.name.clone(), template);
338 }
339 }
340
341 let project_dir = project_templates_dir();
343 for template in load_templates_from_dir(&project_dir, TemplateSource::Project) {
344 templates_by_name.insert(template.name.clone(), template);
345 }
346
347 let mut templates: Vec<_> = templates_by_name.into_values().collect();
348 templates.sort_by(|a, b| a.name.cmp(&b.name));
349 templates
350}
351
352pub fn find_template(name: &str) -> Result<SpecTemplate> {
354 let project_dir = project_templates_dir();
356 let project_path = project_dir.join(format!("{}.md", name));
357 if project_path.exists() {
358 return SpecTemplate::load(&project_path, TemplateSource::Project);
359 }
360
361 if let Some(global_dir) = global_templates_dir() {
363 let global_path = global_dir.join(format!("{}.md", name));
364 if global_path.exists() {
365 return SpecTemplate::load(&global_path, TemplateSource::Global);
366 }
367 }
368
369 anyhow::bail!(
370 "Template '{}' not found.\n\
371 Searched in:\n \
372 - {}\n \
373 - {}",
374 name,
375 project_path.display(),
376 global_templates_dir()
377 .map(|p| p.join(format!("{}.md", name)).display().to_string())
378 .unwrap_or_else(|| "~/.config/chant/templates/".to_string())
379 );
380}
381
382pub fn parse_var_args(var_args: &[String]) -> Result<HashMap<String, String>> {
384 let mut vars = HashMap::new();
385
386 for arg in var_args {
387 let parts: Vec<&str> = arg.splitn(2, '=').collect();
388 if parts.len() != 2 {
389 anyhow::bail!("Invalid variable format '{}'. Expected 'key=value'.", arg);
390 }
391 vars.insert(parts[0].to_string(), parts[1].to_string());
392 }
393
394 Ok(vars)
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_split_frontmatter() {
403 let content = "---\nname: test\n---\n\n# Body\n";
404 let (fm, body) = split_frontmatter(content);
405 assert!(fm.is_some());
406 assert_eq!(fm.unwrap(), "name: test");
407 assert_eq!(body, "# Body\n");
408 }
409
410 #[test]
411 fn test_split_frontmatter_no_frontmatter() {
412 let content = "# Just body\n";
413 let (fm, body) = split_frontmatter(content);
414 assert!(fm.is_none());
415 assert_eq!(body, "# Just body\n");
416 }
417
418 #[test]
419 fn test_parse_template() {
420 let content = r#"---
421name: test-template
422description: A test template
423variables:
424 - name: feature
425 description: Feature name
426 required: true
427 - name: module
428 description: Module name
429 default: core
430type: code
431labels:
432 - feature
433---
434
435# Add {{feature}} to {{module}}
436
437## Problem
438
439Need to add {{feature}}.
440"#;
441
442 let template = SpecTemplate::parse(content, Path::new("test.md"), TemplateSource::Project)
443 .expect("Should parse");
444 assert_eq!(template.name, "test-template");
445 assert_eq!(template.frontmatter.description, "A test template");
446 assert_eq!(template.frontmatter.variables.len(), 2);
447 assert!(template.frontmatter.variables[0].required);
448 assert_eq!(
449 template.frontmatter.variables[1].default,
450 Some("core".to_string())
451 );
452 }
453
454 #[test]
455 fn test_substitute_variables() {
456 let content = r#"---
457name: test
458variables:
459 - name: x
460 required: true
461 - name: y
462 default: default_y
463---
464
465Text with {{x}} and {{y}}.
466"#;
467
468 let template = SpecTemplate::parse(content, Path::new("test.md"), TemplateSource::Project)
469 .expect("Should parse");
470
471 let mut vars = HashMap::new();
472 vars.insert("x".to_string(), "value_x".to_string());
473
474 let result = template.substitute("{{x}} and {{y}}", &vars);
475 assert_eq!(result, "value_x and default_y");
476 }
477
478 #[test]
479 fn test_validate_variables() {
480 let content = r#"---
481name: test
482variables:
483 - name: required_var
484 required: true
485 - name: optional_var
486 default: optional
487---
488
489Body
490"#;
491
492 let template = SpecTemplate::parse(content, Path::new("test.md"), TemplateSource::Project)
493 .expect("Should parse");
494
495 let vars = HashMap::new();
497 assert!(template.validate_variables(&vars).is_err());
498
499 let mut vars = HashMap::new();
501 vars.insert("required_var".to_string(), "value".to_string());
502 assert!(template.validate_variables(&vars).is_ok());
503 }
504
505 #[test]
506 fn test_render_template() {
507 let content = r#"---
508name: feature
509description: Add a feature
510variables:
511 - name: feature_name
512 required: true
513 - name: module
514 default: core
515type: code
516labels:
517 - feature
518 - "{{module}}"
519---
520
521# Add {{feature_name}}
522
523Implement {{feature_name}} in {{module}}.
524"#;
525
526 let template = SpecTemplate::parse(content, Path::new("test.md"), TemplateSource::Project)
527 .expect("Should parse");
528
529 let mut vars = HashMap::new();
530 vars.insert("feature_name".to_string(), "logging".to_string());
531
532 let rendered = template.render(&vars).expect("Should render");
533
534 assert!(rendered.contains("type: code"));
535 assert!(rendered.contains("status: pending"));
536 assert!(rendered.contains("# Add logging"));
537 assert!(rendered.contains("Implement logging in core."));
538 assert!(rendered.contains("labels:"));
539 assert!(rendered.contains(" - feature"));
540 assert!(rendered.contains(" - core"));
541 }
542
543 #[test]
544 fn test_parse_var_args() {
545 let args = vec!["key1=value1".to_string(), "key2=value2".to_string()];
546 let vars = parse_var_args(&args).expect("Should parse");
547 assert_eq!(vars.get("key1"), Some(&"value1".to_string()));
548 assert_eq!(vars.get("key2"), Some(&"value2".to_string()));
549 }
550
551 #[test]
552 fn test_parse_var_args_with_equals_in_value() {
553 let args = vec!["key=value=with=equals".to_string()];
554 let vars = parse_var_args(&args).expect("Should parse");
555 assert_eq!(vars.get("key"), Some(&"value=with=equals".to_string()));
556 }
557
558 #[test]
559 fn test_parse_var_args_invalid() {
560 let args = vec!["no_equals_sign".to_string()];
561 assert!(parse_var_args(&args).is_err());
562 }
563
564 #[test]
565 fn test_required_variables() {
566 let content = r#"---
567name: test
568variables:
569 - name: req1
570 required: true
571 - name: req2
572 required: true
573 default: has_default
574 - name: opt1
575 required: false
576---
577
578Body
579"#;
580
581 let template = SpecTemplate::parse(content, Path::new("test.md"), TemplateSource::Project)
582 .expect("Should parse");
583
584 let required = template.required_variables();
585 assert_eq!(required.len(), 1);
589 assert_eq!(required[0].name, "req1");
590 }
591}