Skip to main content

metis_core/application/services/
template.rs

1//! Template loading service with fallback chain support.
2//!
3//! This service loads document templates from:
4//! 1. Project-level: `.metis/templates/{type}/content.md`
5//! 2. Global-level: `~/.config/metis/templates/{type}/content.md`
6//! 3. Embedded defaults (compiled into binary)
7
8use std::path::{Path, PathBuf};
9use tera::{Context, Tera};
10
11/// Embedded default templates for each document type
12mod defaults {
13    pub mod vision {
14        pub const CONTENT: &str = include_str!("../../domain/documents/vision/content.md");
15        pub const EXIT_CRITERIA: &str =
16            include_str!("../../domain/documents/vision/acceptance_criteria.md");
17    }
18
19    pub mod strategy {
20        pub const CONTENT: &str = include_str!("../../domain/documents/strategy/content.md");
21        pub const EXIT_CRITERIA: &str =
22            include_str!("../../domain/documents/strategy/acceptance_criteria.md");
23    }
24
25    pub mod initiative {
26        pub const CONTENT: &str = include_str!("../../domain/documents/initiative/content.md");
27        pub const EXIT_CRITERIA: &str =
28            include_str!("../../domain/documents/initiative/acceptance_criteria.md");
29    }
30
31    pub mod task {
32        pub const CONTENT: &str = include_str!("../../domain/documents/task/content.md");
33        pub const EXIT_CRITERIA: &str =
34            include_str!("../../domain/documents/task/acceptance_criteria.md");
35    }
36
37    pub mod adr {
38        pub const CONTENT: &str = include_str!("../../domain/documents/adr/content.md");
39        pub const EXIT_CRITERIA: &str =
40            include_str!("../../domain/documents/adr/acceptance_criteria.md");
41    }
42}
43
44/// Error type for template loading operations
45#[derive(Debug, Clone)]
46pub enum TemplateError {
47    /// Template file could not be read
48    IoError(String),
49    /// Template failed to parse as valid Tera template
50    ParseError(String),
51    /// Template failed validation (render with sample data)
52    ValidationError(String),
53    /// Unknown document type
54    UnknownDocumentType(String),
55}
56
57impl std::fmt::Display for TemplateError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            TemplateError::IoError(msg) => write!(f, "Template IO error: {}", msg),
61            TemplateError::ParseError(msg) => write!(f, "Template parse error: {}", msg),
62            TemplateError::ValidationError(msg) => write!(f, "Template validation error: {}", msg),
63            TemplateError::UnknownDocumentType(t) => write!(f, "Unknown document type: {}", t),
64        }
65    }
66}
67
68impl std::error::Error for TemplateError {}
69
70/// Template types that can be loaded
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TemplateType {
73    Content,
74    ExitCriteria,
75}
76
77impl TemplateType {
78    fn filename(&self) -> &'static str {
79        match self {
80            TemplateType::Content => "content.md",
81            TemplateType::ExitCriteria => "exit_criteria.md",
82        }
83    }
84}
85
86/// Service for loading templates with fallback chain support.
87///
88/// Templates are loaded in this order:
89/// 1. Project-level: `{workspace}/.metis/templates/{type}/{template}.md`
90/// 2. Global-level: `~/.config/metis/templates/{type}/{template}.md`
91/// 3. Embedded defaults
92pub struct TemplateLoader {
93    /// Path to the project workspace (e.g., `/path/to/project/.metis`)
94    project_path: Option<PathBuf>,
95    /// Path to global config (e.g., `~/.config/metis`)
96    global_path: PathBuf,
97}
98
99impl TemplateLoader {
100    /// Create a new TemplateLoader with the given project workspace path.
101    ///
102    /// If `project_path` is None, only global and embedded templates will be used.
103    pub fn new(project_path: Option<PathBuf>) -> Self {
104        let global_path = dirs::config_dir()
105            .unwrap_or_else(|| PathBuf::from("."))
106            .join("metis");
107
108        Self {
109            project_path,
110            global_path,
111        }
112    }
113
114    /// Create a TemplateLoader for a specific workspace directory.
115    pub fn for_workspace<P: AsRef<Path>>(workspace_dir: P) -> Self {
116        Self::new(Some(workspace_dir.as_ref().to_path_buf()))
117    }
118
119    /// Load a content template for the given document type.
120    ///
121    /// Returns the template content string, loading from the first available source
122    /// in the fallback chain.
123    pub fn load_content_template(&self, doc_type: &str) -> Result<String, TemplateError> {
124        self.load_template(doc_type, TemplateType::Content)
125    }
126
127    /// Load an exit criteria template for the given document type.
128    pub fn load_exit_criteria_template(&self, doc_type: &str) -> Result<String, TemplateError> {
129        self.load_template(doc_type, TemplateType::ExitCriteria)
130    }
131
132    /// Load a template with the fallback chain.
133    fn load_template(
134        &self,
135        doc_type: &str,
136        template_type: TemplateType,
137    ) -> Result<String, TemplateError> {
138        // 1. Try project-level template
139        if let Some(ref project_path) = self.project_path {
140            let project_template = project_path
141                .join("templates")
142                .join(doc_type)
143                .join(template_type.filename());
144
145            if project_template.exists() {
146                let content = std::fs::read_to_string(&project_template)
147                    .map_err(|e| TemplateError::IoError(e.to_string()))?;
148
149                // Validate the template before returning
150                self.validate_template(&content, doc_type)?;
151                return Ok(content);
152            }
153        }
154
155        // 2. Try global-level template
156        let global_template = self
157            .global_path
158            .join("templates")
159            .join(doc_type)
160            .join(template_type.filename());
161
162        if global_template.exists() {
163            let content = std::fs::read_to_string(&global_template)
164                .map_err(|e| TemplateError::IoError(e.to_string()))?;
165
166            // Validate the template before returning
167            self.validate_template(&content, doc_type)?;
168            return Ok(content);
169        }
170
171        // 3. Fall back to embedded defaults
172        self.get_embedded_template(doc_type, template_type)
173    }
174
175    /// Get the embedded default template for a document type.
176    fn get_embedded_template(
177        &self,
178        doc_type: &str,
179        template_type: TemplateType,
180    ) -> Result<String, TemplateError> {
181        let template = match (doc_type, template_type) {
182            ("vision", TemplateType::Content) => defaults::vision::CONTENT,
183            ("vision", TemplateType::ExitCriteria) => defaults::vision::EXIT_CRITERIA,
184            ("strategy", TemplateType::Content) => defaults::strategy::CONTENT,
185            ("strategy", TemplateType::ExitCriteria) => defaults::strategy::EXIT_CRITERIA,
186            ("initiative", TemplateType::Content) => defaults::initiative::CONTENT,
187            ("initiative", TemplateType::ExitCriteria) => defaults::initiative::EXIT_CRITERIA,
188            ("task", TemplateType::Content) => defaults::task::CONTENT,
189            ("task", TemplateType::ExitCriteria) => defaults::task::EXIT_CRITERIA,
190            ("adr", TemplateType::Content) => defaults::adr::CONTENT,
191            ("adr", TemplateType::ExitCriteria) => defaults::adr::EXIT_CRITERIA,
192            _ => return Err(TemplateError::UnknownDocumentType(doc_type.to_string())),
193        };
194
195        Ok(template.to_string())
196    }
197
198    /// Validate a template by rendering it with sample data.
199    ///
200    /// This catches template syntax errors and missing variable references early.
201    pub fn validate_template(&self, template: &str, doc_type: &str) -> Result<(), TemplateError> {
202        let mut tera = Tera::default();
203
204        // Try to parse the template
205        tera.add_raw_template("test_template", template)
206            .map_err(|e| TemplateError::ParseError(e.to_string()))?;
207
208        // Try to render with sample context
209        let context = self.sample_context_for_type(doc_type);
210        tera.render("test_template", &context)
211            .map_err(|e| TemplateError::ValidationError(e.to_string()))?;
212
213        Ok(())
214    }
215
216    /// Generate sample context values for validating templates.
217    ///
218    /// Each document type gets appropriate sample values for all available variables.
219    pub fn sample_context_for_type(&self, doc_type: &str) -> Context {
220        let mut context = Context::new();
221
222        // Common variables for all document types
223        context.insert("title", "Sample Document Title");
224        context.insert("slug", "sample-document-title");
225        context.insert("short_code", &format!("TEST-{}-0001", doc_type_letter(doc_type)));
226        context.insert("created_at", "2025-01-01T00:00:00Z");
227        context.insert("updated_at", "2025-01-01T00:00:00Z");
228        context.insert("archived", "false");
229        context.insert("exit_criteria_met", "false");
230        context.insert("parent_id", "");
231        context.insert("parent_title", "");
232        context.insert("blocked_by", &Vec::<String>::new());
233        context.insert("tags", &vec!["#sample", "#phase/draft"]);
234
235        // Type-specific variables
236        match doc_type {
237            "vision" => {
238                // Vision has no additional required variables
239            }
240            "strategy" => {
241                context.insert("risk_level", "Medium");
242                context.insert("stakeholders", &Vec::<String>::new());
243            }
244            "initiative" => {
245                context.insert("estimated_complexity", "M");
246                context.insert("strategy_id", "NULL");
247                context.insert("initiative_id", "sample-initiative");
248            }
249            "task" => {
250                context.insert("strategy_id", "NULL");
251                context.insert("initiative_id", "NULL");
252                context.insert("parent_title", "Sample Parent Initiative");
253            }
254            "adr" => {
255                context.insert("number", &1);
256                context.insert("decision_maker", "");
257                context.insert("decision_date", "");
258            }
259            _ => {}
260        }
261
262        context
263    }
264
265    /// Check if custom templates exist for a document type.
266    pub fn has_custom_template(&self, doc_type: &str, template_type: TemplateType) -> bool {
267        // Check project-level
268        if let Some(ref project_path) = self.project_path {
269            let project_template = project_path
270                .join("templates")
271                .join(doc_type)
272                .join(template_type.filename());
273            if project_template.exists() {
274                return true;
275            }
276        }
277
278        // Check global-level
279        let global_template = self
280            .global_path
281            .join("templates")
282            .join(doc_type)
283            .join(template_type.filename());
284        global_template.exists()
285    }
286
287    /// Get the source of a template (for debugging/info).
288    pub fn template_source(&self, doc_type: &str, template_type: TemplateType) -> TemplateSource {
289        // Check project-level
290        if let Some(ref project_path) = self.project_path {
291            let project_template = project_path
292                .join("templates")
293                .join(doc_type)
294                .join(template_type.filename());
295            if project_template.exists() {
296                return TemplateSource::Project(project_template);
297            }
298        }
299
300        // Check global-level
301        let global_template = self
302            .global_path
303            .join("templates")
304            .join(doc_type)
305            .join(template_type.filename());
306        if global_template.exists() {
307            return TemplateSource::Global(global_template);
308        }
309
310        TemplateSource::Embedded
311    }
312}
313
314/// Indicates where a template was loaded from.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum TemplateSource {
317    /// Template from project's `.metis/templates/` directory
318    Project(PathBuf),
319    /// Template from global `~/.config/metis/templates/` directory
320    Global(PathBuf),
321    /// Embedded default template
322    Embedded,
323}
324
325impl std::fmt::Display for TemplateSource {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        match self {
328            TemplateSource::Project(path) => write!(f, "project: {}", path.display()),
329            TemplateSource::Global(path) => write!(f, "global: {}", path.display()),
330            TemplateSource::Embedded => write!(f, "embedded default"),
331        }
332    }
333}
334
335/// Helper to get the type letter for short codes
336fn doc_type_letter(doc_type: &str) -> char {
337    match doc_type {
338        "vision" => 'V',
339        "strategy" => 'S',
340        "initiative" => 'I',
341        "task" => 'T',
342        "adr" => 'A',
343        _ => 'X',
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use tempfile::tempdir;
351
352    #[test]
353    fn test_load_embedded_templates() {
354        let loader = TemplateLoader::new(None);
355
356        // All document types should have embedded templates
357        for doc_type in &["vision", "strategy", "initiative", "task", "adr"] {
358            let content = loader.load_content_template(doc_type);
359            assert!(content.is_ok(), "Failed to load content for {}", doc_type);
360            assert!(!content.unwrap().is_empty());
361
362            let exit_criteria = loader.load_exit_criteria_template(doc_type);
363            assert!(
364                exit_criteria.is_ok(),
365                "Failed to load exit criteria for {}",
366                doc_type
367            );
368        }
369    }
370
371    #[test]
372    fn test_unknown_document_type() {
373        let loader = TemplateLoader::new(None);
374        let result = loader.load_content_template("unknown");
375        assert!(matches!(result, Err(TemplateError::UnknownDocumentType(_))));
376    }
377
378    #[test]
379    fn test_project_template_override() {
380        let temp_dir = tempdir().unwrap();
381        let project_path = temp_dir.path().to_path_buf();
382
383        // Create a custom template
384        let template_dir = project_path.join("templates").join("task");
385        std::fs::create_dir_all(&template_dir).unwrap();
386
387        let custom_template = "# {{ title }}\n\nCustom task template!";
388        std::fs::write(template_dir.join("content.md"), custom_template).unwrap();
389
390        let loader = TemplateLoader::for_workspace(&project_path);
391        let content = loader.load_content_template("task").unwrap();
392
393        assert!(content.contains("Custom task template!"));
394        assert_eq!(
395            loader.template_source("task", TemplateType::Content),
396            TemplateSource::Project(template_dir.join("content.md"))
397        );
398    }
399
400    #[test]
401    fn test_template_validation_error() {
402        let temp_dir = tempdir().unwrap();
403        let project_path = temp_dir.path().to_path_buf();
404
405        // Create an invalid template (unclosed Tera tag)
406        let template_dir = project_path.join("templates").join("task");
407        std::fs::create_dir_all(&template_dir).unwrap();
408
409        let invalid_template = "# {{ title }\n\nBroken template";
410        std::fs::write(template_dir.join("content.md"), invalid_template).unwrap();
411
412        let loader = TemplateLoader::for_workspace(&project_path);
413        let result = loader.load_content_template("task");
414
415        assert!(matches!(result, Err(TemplateError::ParseError(_))));
416    }
417
418    #[test]
419    fn test_template_validation_missing_variable() {
420        let temp_dir = tempdir().unwrap();
421        let project_path = temp_dir.path().to_path_buf();
422
423        // Create a template with an undefined variable
424        let template_dir = project_path.join("templates").join("task");
425        std::fs::create_dir_all(&template_dir).unwrap();
426
427        let template_with_missing_var = "# {{ title }}\n\nValue: {{ nonexistent_variable }}";
428        std::fs::write(template_dir.join("content.md"), template_with_missing_var).unwrap();
429
430        let loader = TemplateLoader::for_workspace(&project_path);
431        let result = loader.load_content_template("task");
432
433        assert!(matches!(result, Err(TemplateError::ValidationError(_))));
434    }
435
436    #[test]
437    fn test_sample_context_generation() {
438        let loader = TemplateLoader::new(None);
439
440        for doc_type in &["vision", "strategy", "initiative", "task", "adr"] {
441            let context = loader.sample_context_for_type(doc_type);
442
443            // All types should have common variables
444            assert!(context.get("title").is_some());
445            assert!(context.get("slug").is_some());
446            assert!(context.get("short_code").is_some());
447        }
448
449        // Type-specific variables
450        let initiative_ctx = loader.sample_context_for_type("initiative");
451        assert!(initiative_ctx.get("estimated_complexity").is_some());
452
453        let strategy_ctx = loader.sample_context_for_type("strategy");
454        assert!(strategy_ctx.get("risk_level").is_some());
455    }
456
457    #[test]
458    fn test_has_custom_template() {
459        let temp_dir = tempdir().unwrap();
460        let project_path = temp_dir.path().to_path_buf();
461
462        let loader = TemplateLoader::for_workspace(&project_path);
463
464        // No custom templates initially
465        assert!(!loader.has_custom_template("task", TemplateType::Content));
466
467        // Create a custom template
468        let template_dir = project_path.join("templates").join("task");
469        std::fs::create_dir_all(&template_dir).unwrap();
470        std::fs::write(template_dir.join("content.md"), "# {{ title }}").unwrap();
471
472        assert!(loader.has_custom_template("task", TemplateType::Content));
473        assert!(!loader.has_custom_template("task", TemplateType::ExitCriteria));
474    }
475}