git_adr/core/
templates.rs

1//! Template engine for ADR generation.
2//!
3//! This module provides template rendering using Tera,
4//! with built-in templates for common ADR formats.
5
6use crate::Error;
7use std::collections::HashMap;
8use tera::{Context, Tera};
9
10/// Built-in ADR template: Nygard format.
11pub const TEMPLATE_NYGARD: &str = r#"# {{ title }}
12
13## Status
14
15{{ status }}
16
17## Context
18
19{{ context | default(value="What is the issue that we're seeing that is motivating this decision or change?") }}
20
21## Decision
22
23{{ decision | default(value="What is the change that we're proposing and/or doing?") }}
24
25## Consequences
26
27{{ consequences | default(value="What becomes easier or more difficult to do because of this change?") }}
28"#;
29
30/// Built-in ADR template: MADR format.
31pub const TEMPLATE_MADR: &str = r#"# {{ title }}
32
33## Status
34
35{{ status }}
36
37{% if deciders %}
38Deciders: {{ deciders | join(sep=", ") }}
39{% endif %}
40{% if date %}
41Date: {{ date }}
42{% endif %}
43
44## Context and Problem Statement
45
46{{ context | default(value="Describe the context and problem statement...") }}
47
48## Decision Drivers
49
50{% for driver in decision_drivers | default(value=[]) %}
51* {{ driver }}
52{% else %}
53* Driver 1
54* Driver 2
55{% endfor %}
56
57## Considered Options
58
59{% for option in options | default(value=[]) %}
60* {{ option }}
61{% else %}
62* Option 1
63* Option 2
64{% endfor %}
65
66## Decision Outcome
67
68Chosen option: "{{ chosen_option | default(value="Option X") }}"
69
70### Consequences
71
72#### Good
73
74{% for item in good_consequences | default(value=[]) %}
75* {{ item }}
76{% else %}
77* Good consequence 1
78{% endfor %}
79
80#### Bad
81
82{% for item in bad_consequences | default(value=[]) %}
83* {{ item }}
84{% else %}
85* Bad consequence 1
86{% endfor %}
87
88## More Information
89
90{{ more_info | default(value="Additional information, links, references...") }}
91"#;
92
93/// Built-in ADR template: Y-statement format.
94pub const TEMPLATE_Y_STATEMENT: &str = r#"# {{ title }}
95
96## Status
97
98{{ status }}
99
100## Decision
101
102In the context of {{ context | default(value="<use case/user story>") }},
103facing {{ facing | default(value="<concern>") }},
104we decided for {{ decision | default(value="<option>") }}
105and against {{ against | default(value="<other options>") }},
106to achieve {{ achieve | default(value="<quality>") }},
107accepting {{ accepting | default(value="<downside>") }}.
108"#;
109
110/// Built-in ADR template: Alexandrian format.
111pub const TEMPLATE_ALEXANDRIAN: &str = r#"# {{ title }}
112
113## Status
114
115{{ status }}
116
117## Prologue
118
119{{ prologue | default(value="Summary and context for this ADR") }}
120
121## Problem Statement
122
123{{ problem | default(value="The problem or question being addressed") }}
124
125## Forces
126
127{% for force in forces | default(value=[]) %}
128* {{ force }}
129{% else %}
130* Force 1: Description
131* Force 2: Description
132{% endfor %}
133
134## Solution
135
136{{ solution | default(value="The chosen solution") }}
137
138## Consequences
139
140{{ consequences | default(value="Resulting context after applying the solution") }}
141
142## Related Patterns
143
144{% for pattern in related | default(value=[]) %}
145* {{ pattern }}
146{% endfor %}
147"#;
148
149/// Built-in ADR template: Business Case format.
150pub const TEMPLATE_BUSINESS_CASE: &str = r#"# {{ title }}
151
152## Status
153
154{{ status }}
155
156## Executive Summary
157
158{{ executive_summary | default(value="Brief overview of the decision and its impact") }}
159
160## Background
161
162{{ background | default(value="Context and history leading to this decision") }}
163
164## Problem Statement
165
166{{ problem | default(value="The business problem being addressed") }}
167
168## Proposed Solution
169
170{{ solution | default(value="Recommended approach") }}
171
172## Alternatives Considered
173
174{% for alt in alternatives | default(value=[]) %}
175### {{ alt.name }}
176{{ alt.description }}
177{% else %}
178### Alternative 1
179Description of alternative approach
180{% endfor %}
181
182## Cost-Benefit Analysis
183
184### Costs
185{% for cost in costs | default(value=[]) %}
186* {{ cost }}
187{% else %}
188* Implementation cost
189* Maintenance cost
190{% endfor %}
191
192### Benefits
193{% for benefit in benefits | default(value=[]) %}
194* {{ benefit }}
195{% else %}
196* Benefit 1
197* Benefit 2
198{% endfor %}
199
200## Risk Assessment
201
202{% for risk in risks | default(value=[]) %}
203* {{ risk }}
204{% else %}
205* Risk 1: Mitigation strategy
206{% endfor %}
207
208## Implementation Plan
209
210{{ implementation | default(value="High-level implementation approach") }}
211
212## Success Metrics
213
214{% for metric in metrics | default(value=[]) %}
215* {{ metric }}
216{% else %}
217* Metric 1
218{% endfor %}
219"#;
220
221/// Template engine for ADR generation.
222#[derive(Debug)]
223pub struct TemplateEngine {
224    tera: Tera,
225}
226
227impl Default for TemplateEngine {
228    fn default() -> Self {
229        Self::new()
230    }
231}
232
233impl TemplateEngine {
234    /// Create a new template engine with built-in templates.
235    #[must_use]
236    pub fn new() -> Self {
237        let mut tera = Tera::default();
238
239        // Add built-in templates
240        let _ = tera.add_raw_template("nygard", TEMPLATE_NYGARD);
241        let _ = tera.add_raw_template("madr", TEMPLATE_MADR);
242        let _ = tera.add_raw_template("y-statement", TEMPLATE_Y_STATEMENT);
243        let _ = tera.add_raw_template("alexandrian", TEMPLATE_ALEXANDRIAN);
244        let _ = tera.add_raw_template("business-case", TEMPLATE_BUSINESS_CASE);
245
246        Self { tera }
247    }
248
249    /// Add a custom template.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if the template is invalid.
254    pub fn add_template(&mut self, name: &str, content: &str) -> Result<(), Error> {
255        self.tera
256            .add_raw_template(name, content)
257            .map_err(|e| Error::TemplateError {
258                message: format!("Failed to add template '{name}': {e}"),
259            })
260    }
261
262    /// Render a template with the given context.
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if rendering fails.
267    pub fn render(
268        &self,
269        template: &str,
270        context: &HashMap<String, String>,
271    ) -> Result<String, Error> {
272        let mut tera_context = Context::new();
273        for (key, value) in context {
274            tera_context.insert(key, value);
275        }
276
277        self.tera
278            .render(template, &tera_context)
279            .map_err(|e| Error::TemplateError {
280                message: format!("Failed to render template '{template}': {e}"),
281            })
282    }
283
284    /// List available templates.
285    #[must_use]
286    pub fn list_templates(&self) -> Vec<String> {
287        self.tera.get_template_names().map(String::from).collect()
288    }
289
290    /// Check if a template exists.
291    #[must_use]
292    pub fn has_template(&self, name: &str) -> bool {
293        self.tera.get_template_names().any(|n| n == name)
294    }
295
296    /// Get template content.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if the template doesn't exist.
301    pub fn get_template(&self, name: &str) -> Result<String, Error> {
302        // Built-in templates
303        match name {
304            "nygard" => Ok(TEMPLATE_NYGARD.to_string()),
305            "madr" => Ok(TEMPLATE_MADR.to_string()),
306            "y-statement" => Ok(TEMPLATE_Y_STATEMENT.to_string()),
307            "alexandrian" => Ok(TEMPLATE_ALEXANDRIAN.to_string()),
308            "business-case" => Ok(TEMPLATE_BUSINESS_CASE.to_string()),
309            _ => Err(Error::TemplateNotFound {
310                name: name.to_string(),
311            }),
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_template_engine_new() {
322        let engine = TemplateEngine::new();
323        assert!(engine.has_template("nygard"));
324        assert!(engine.has_template("madr"));
325        assert!(!engine.has_template("nonexistent"));
326    }
327
328    #[test]
329    fn test_template_engine_default() {
330        let engine = TemplateEngine::default();
331        assert!(engine.has_template("nygard"));
332    }
333
334    #[test]
335    fn test_render_nygard() {
336        let engine = TemplateEngine::new();
337        let mut context = HashMap::new();
338        context.insert("title".to_string(), "Test ADR".to_string());
339        context.insert("status".to_string(), "Proposed".to_string());
340
341        let result = engine
342            .render("nygard", &context)
343            .expect("Template should render successfully");
344        assert!(result.contains("# Test ADR"));
345        assert!(result.contains("## Status"));
346        assert!(result.contains("Proposed"));
347    }
348
349    #[test]
350    fn test_render_madr() {
351        let engine = TemplateEngine::new();
352        let mut context = HashMap::new();
353        context.insert("title".to_string(), "MADR Test".to_string());
354        context.insert("status".to_string(), "accepted".to_string());
355
356        let result = engine.render("madr", &context).expect("Should render");
357        assert!(result.contains("# MADR Test"));
358        assert!(result.contains("Context and Problem Statement"));
359    }
360
361    #[test]
362    fn test_render_y_statement() {
363        let engine = TemplateEngine::new();
364        let mut context = HashMap::new();
365        context.insert("title".to_string(), "Y Statement Test".to_string());
366        context.insert("status".to_string(), "proposed".to_string());
367
368        let result = engine
369            .render("y-statement", &context)
370            .expect("Should render");
371        assert!(result.contains("# Y Statement Test"));
372        assert!(result.contains("In the context of"));
373    }
374
375    #[test]
376    fn test_render_alexandrian() {
377        let engine = TemplateEngine::new();
378        let mut context = HashMap::new();
379        context.insert("title".to_string(), "Alexandrian Test".to_string());
380        context.insert("status".to_string(), "proposed".to_string());
381
382        let result = engine
383            .render("alexandrian", &context)
384            .expect("Should render");
385        assert!(result.contains("# Alexandrian Test"));
386        assert!(result.contains("Prologue"));
387        assert!(result.contains("Forces"));
388    }
389
390    #[test]
391    fn test_render_business_case() {
392        let engine = TemplateEngine::new();
393        let mut context = HashMap::new();
394        context.insert("title".to_string(), "Business Case Test".to_string());
395        context.insert("status".to_string(), "proposed".to_string());
396
397        let result = engine
398            .render("business-case", &context)
399            .expect("Should render");
400        assert!(result.contains("# Business Case Test"));
401        assert!(result.contains("Executive Summary"));
402        assert!(result.contains("Cost-Benefit Analysis"));
403    }
404
405    #[test]
406    fn test_list_templates() {
407        let engine = TemplateEngine::new();
408        let templates = engine.list_templates();
409        assert!(templates.contains(&"nygard".to_string()));
410        assert!(templates.contains(&"madr".to_string()));
411        assert!(templates.contains(&"y-statement".to_string()));
412        assert!(templates.contains(&"alexandrian".to_string()));
413        assert!(templates.contains(&"business-case".to_string()));
414    }
415
416    #[test]
417    fn test_add_custom_template() {
418        let mut engine = TemplateEngine::new();
419        let custom = "# {{ title }}\n\nCustom template content";
420        engine
421            .add_template("custom", custom)
422            .expect("Should add template");
423        assert!(engine.has_template("custom"));
424    }
425
426    #[test]
427    fn test_get_template() {
428        let engine = TemplateEngine::new();
429        let nygard = engine.get_template("nygard").expect("Should get template");
430        assert!(nygard.contains("## Context"));
431    }
432
433    #[test]
434    fn test_get_template_not_found() {
435        let engine = TemplateEngine::new();
436        let result = engine.get_template("nonexistent");
437        assert!(result.is_err());
438    }
439
440    #[test]
441    fn test_render_nonexistent_template() {
442        let engine = TemplateEngine::new();
443        let context = HashMap::new();
444        let result = engine.render("nonexistent", &context);
445        assert!(result.is_err());
446    }
447
448    #[test]
449    fn test_add_template_invalid() {
450        let mut engine = TemplateEngine::new();
451        // Invalid Tera template syntax
452        let invalid = "{% for item in items %}{{ item }}{% endwith %}";
453        let result = engine.add_template("invalid", invalid);
454        assert!(result.is_err());
455    }
456}