Skip to main content

chant/
spec_template.rs

1//! Spec template system for creating specs from reusable templates.
2//!
3//! Templates are markdown files with YAML frontmatter containing variable definitions.
4//! They can be stored in `.chant/templates/` (project) or `~/.config/chant/templates/` (global).
5//!
6//! # Template Format
7//!
8//! ```markdown
9//! ---
10//! name: add-feature
11//! description: Add a new feature with tests
12//! variables:
13//!   - name: feature_name
14//!     description: Name of the feature
15//!     required: true
16//!   - name: module
17//!     description: Target module
18//!     default: core
19//! type: code
20//! labels:
21//!   - feature
22//! ---
23//!
24//! # Add {{feature_name}} feature
25//!
26//! ## Problem
27//!
28//! The {{module}} module needs {{feature_name}} functionality.
29//! ```
30
31use anyhow::{Context, Result};
32use regex::Regex;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::fs;
36use std::path::{Path, PathBuf};
37
38/// Directory name for templates within .chant/
39pub const PROJECT_TEMPLATES_DIR: &str = ".chant/templates";
40
41/// Directory name for global templates
42pub const GLOBAL_TEMPLATES_DIR: &str = "templates";
43
44/// A variable definition within a template
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct TemplateVariable {
47    /// Name of the variable (used in {{name}} placeholders)
48    pub name: String,
49    /// Description of what this variable is for
50    #[serde(default)]
51    pub description: String,
52    /// Whether this variable must be provided (no default)
53    #[serde(default)]
54    pub required: bool,
55    /// Default value if not provided
56    #[serde(default)]
57    pub default: Option<String>,
58}
59
60/// Template frontmatter containing metadata and variable definitions
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TemplateFrontmatter {
63    /// Template name (identifier)
64    pub name: String,
65    /// Human-readable description
66    #[serde(default)]
67    pub description: String,
68    /// Variable definitions
69    #[serde(default)]
70    pub variables: Vec<TemplateVariable>,
71    /// Default spec type to use
72    #[serde(default)]
73    pub r#type: Option<String>,
74    /// Default labels to apply
75    #[serde(default)]
76    pub labels: Option<Vec<String>>,
77    /// Default target files
78    #[serde(default)]
79    pub target_files: Option<Vec<String>>,
80    /// Default context files
81    #[serde(default)]
82    pub context: Option<Vec<String>>,
83    /// Default prompt to use
84    #[serde(default)]
85    pub prompt: Option<String>,
86}
87
88/// A spec template with its metadata and content
89#[derive(Debug, Clone)]
90pub struct SpecTemplate {
91    /// Template name
92    pub name: String,
93    /// Parsed frontmatter
94    pub frontmatter: TemplateFrontmatter,
95    /// Template body (with {{variable}} placeholders)
96    pub body: String,
97    /// Source location (project or global)
98    pub source: TemplateSource,
99    /// File path where template was loaded from
100    pub path: PathBuf,
101}
102
103/// Where a template was loaded from
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum TemplateSource {
106    /// From project's .chant/templates/
107    Project,
108    /// From ~/.config/chant/templates/
109    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    /// Parse a template from file content.
123    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    /// Load a template from a file path.
146    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    /// Get list of required variables that don't have defaults
153    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    /// Check if all required variables are provided
162    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    /// Substitute variables in a string using {{variable}} syntax
178    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: &regex::Captures| {
182            let var_name = &caps[1];
183
184            // First check provided variables
185            if let Some(value) = variables.get(var_name) {
186                return value.clone();
187            }
188
189            // Then check for defaults in template definition
190            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            // Keep the placeholder if no value found
202            caps[0].to_string()
203        })
204        .to_string()
205    }
206
207    /// Generate spec content from this template with the given variables
208    pub fn render(&self, variables: &HashMap<String, String>) -> Result<String> {
209        // Validate required variables are present
210        self.validate_variables(variables)?;
211
212        // Build frontmatter for the spec
213        let mut fm_lines = vec!["---".to_string()];
214
215        // Type (from template or default to 'code')
216        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        // Labels
221        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        // Target files
232        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        // Context
243        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        // Prompt
254        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        // Substitute variables in body
264        let body = self.substitute(&self.body, variables);
265
266        Ok(format!("{}{}", frontmatter, body))
267    }
268}
269
270/// Split content into frontmatter and body.
271/// Returns (Some(frontmatter), body) if frontmatter exists, otherwise (None, full_content).
272fn 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    // Find the closing ---
280    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; // "---" + frontmatter + "\n---"
284        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
295/// Get the path to the project templates directory
296pub fn project_templates_dir() -> PathBuf {
297    PathBuf::from(PROJECT_TEMPLATES_DIR)
298}
299
300/// Get the path to the global templates directory
301pub fn global_templates_dir() -> Option<PathBuf> {
302    dirs::config_dir().map(|p| p.join("chant").join(GLOBAL_TEMPLATES_DIR))
303}
304
305/// Load all templates from a directory
306fn 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
330/// Load all available templates (project templates override global ones with the same name)
331pub fn load_all_templates() -> Vec<SpecTemplate> {
332    let mut templates_by_name: HashMap<String, SpecTemplate> = HashMap::new();
333
334    // First load global templates
335    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    // Then load project templates (overriding global ones with same name)
342    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
352/// Find a template by name (project templates take precedence)
353pub fn find_template(name: &str) -> Result<SpecTemplate> {
354    // Check project templates first
355    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    // Check global templates
362    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
382/// Parse a list of "key=value" strings into a HashMap
383pub 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        // Missing required variable
496        let vars = HashMap::new();
497        assert!(template.validate_variables(&vars).is_err());
498
499        // With required variable
500        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        // req1 is required with no default
586        // req2 has required: true but also has a default, so it shouldn't count
587        // opt1 is not required
588        assert_eq!(required.len(), 1);
589        assert_eq!(required[0].name, "req1");
590    }
591}