metis_core/application/services/
template.rs1use std::path::{Path, PathBuf};
9use tera::{Context, Tera};
10
11mod 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#[derive(Debug, Clone)]
46pub enum TemplateError {
47 IoError(String),
49 ParseError(String),
51 ValidationError(String),
53 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#[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
86pub struct TemplateLoader {
93 project_path: Option<PathBuf>,
95 global_path: PathBuf,
97}
98
99impl TemplateLoader {
100 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 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 pub fn load_content_template(&self, doc_type: &str) -> Result<String, TemplateError> {
124 self.load_template(doc_type, TemplateType::Content)
125 }
126
127 pub fn load_exit_criteria_template(&self, doc_type: &str) -> Result<String, TemplateError> {
129 self.load_template(doc_type, TemplateType::ExitCriteria)
130 }
131
132 fn load_template(
134 &self,
135 doc_type: &str,
136 template_type: TemplateType,
137 ) -> Result<String, TemplateError> {
138 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 self.validate_template(&content, doc_type)?;
151 return Ok(content);
152 }
153 }
154
155 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 self.validate_template(&content, doc_type)?;
168 return Ok(content);
169 }
170
171 self.get_embedded_template(doc_type, template_type)
173 }
174
175 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 pub fn validate_template(&self, template: &str, doc_type: &str) -> Result<(), TemplateError> {
202 let mut tera = Tera::default();
203
204 tera.add_raw_template("test_template", template)
206 .map_err(|e| TemplateError::ParseError(e.to_string()))?;
207
208 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 pub fn sample_context_for_type(&self, doc_type: &str) -> Context {
220 let mut context = Context::new();
221
222 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 match doc_type {
237 "vision" => {
238 }
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 pub fn has_custom_template(&self, doc_type: &str, template_type: TemplateType) -> bool {
267 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 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 pub fn template_source(&self, doc_type: &str, template_type: TemplateType) -> TemplateSource {
289 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 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#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum TemplateSource {
317 Project(PathBuf),
319 Global(PathBuf),
321 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
335fn 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 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 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 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 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 assert!(context.get("title").is_some());
445 assert!(context.get("slug").is_some());
446 assert!(context.get("short_code").is_some());
447 }
448
449 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 assert!(!loader.has_custom_template("task", TemplateType::Content));
466
467 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}