Skip to main content

adrs_core/
template.rs

1//! Template system for generating ADR files.
2
3use crate::{Adr, Config, Error, Result};
4use minijinja::{Environment, context};
5use std::path::Path;
6
7/// Built-in template formats.
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum TemplateFormat {
10    /// Michael Nygard's original ADR format.
11    #[default]
12    Nygard,
13
14    /// Markdown Any Decision Records format (MADR 4.0.0).
15    Madr,
16}
17
18impl std::fmt::Display for TemplateFormat {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Self::Nygard => write!(f, "nygard"),
22            Self::Madr => write!(f, "madr"),
23        }
24    }
25}
26
27impl std::str::FromStr for TemplateFormat {
28    type Err = Error;
29
30    fn from_str(s: &str) -> Result<Self> {
31        match s.to_lowercase().as_str() {
32            "nygard" | "default" => Ok(Self::Nygard),
33            "madr" => Ok(Self::Madr),
34            _ => Err(Error::TemplateNotFound(s.to_string())),
35        }
36    }
37}
38
39/// Template variants for different levels of detail.
40///
41/// The variant names follow the MADR naming convention:
42/// - **Full**: All sections with guidance text
43/// - **Minimal**: Core sections only, with guidance text
44/// - **Bare**: All sections, but empty (no guidance)
45/// - **BareMinimal**: Core sections only, empty (no guidance)
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub enum TemplateVariant {
48    /// Full template with all sections and guidance.
49    #[default]
50    Full,
51
52    /// Minimal template with essential sections only (with guidance).
53    Minimal,
54
55    /// Bare template - all sections but empty/placeholder content.
56    Bare,
57
58    /// Bare-minimal template - fewest sections, empty content.
59    BareMinimal,
60}
61
62impl std::fmt::Display for TemplateVariant {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Full => write!(f, "full"),
66            Self::Minimal => write!(f, "minimal"),
67            Self::Bare => write!(f, "bare"),
68            Self::BareMinimal => write!(f, "bare-minimal"),
69        }
70    }
71}
72
73impl std::str::FromStr for TemplateVariant {
74    type Err = Error;
75
76    fn from_str(s: &str) -> Result<Self> {
77        match s.to_lowercase().replace('_', "-").as_str() {
78            "full" | "default" => Ok(Self::Full),
79            "minimal" | "min" => Ok(Self::Minimal),
80            "bare" => Ok(Self::Bare),
81            "bare-minimal" | "bareminimal" | "empty" => Ok(Self::BareMinimal),
82            _ => Err(Error::TemplateNotFound(format!("Unknown variant: {s}"))),
83        }
84    }
85}
86
87/// A template for generating ADRs.
88#[derive(Debug, Clone)]
89pub struct Template {
90    /// The template content.
91    content: String,
92
93    /// The template name (for error messages).
94    name: String,
95}
96
97impl Template {
98    /// Create a template from a string.
99    pub fn from_string(name: impl Into<String>, content: impl Into<String>) -> Self {
100        Self {
101            name: name.into(),
102            content: content.into(),
103        }
104    }
105
106    /// Get the template content.
107    pub fn content(&self) -> &str {
108        &self.content
109    }
110
111    /// Get the template name.
112    pub fn name(&self) -> &str {
113        &self.name
114    }
115
116    /// Load a template from a file.
117    pub fn from_file(path: &Path) -> Result<Self> {
118        let content = std::fs::read_to_string(path)?;
119        let name = path
120            .file_name()
121            .and_then(|n| n.to_str())
122            .unwrap_or("custom")
123            .to_string();
124        Ok(Self { name, content })
125    }
126
127    /// Get a built-in template by format (uses Full variant).
128    pub fn builtin(format: TemplateFormat) -> Self {
129        Self::builtin_with_variant(format, TemplateVariant::Full)
130    }
131
132    /// Get a built-in template by format and variant.
133    pub fn builtin_with_variant(format: TemplateFormat, variant: TemplateVariant) -> Self {
134        let (name, content) = match (format, variant) {
135            // Nygard templates
136            (TemplateFormat::Nygard, TemplateVariant::Full) => ("nygard", NYGARD_TEMPLATE),
137            (TemplateFormat::Nygard, TemplateVariant::Minimal) => {
138                ("nygard-minimal", NYGARD_MINIMAL_TEMPLATE)
139            }
140            (TemplateFormat::Nygard, TemplateVariant::Bare) => {
141                ("nygard-bare", NYGARD_BARE_TEMPLATE)
142            }
143            (TemplateFormat::Nygard, TemplateVariant::BareMinimal) => {
144                ("nygard-bare-minimal", NYGARD_BARE_MINIMAL_TEMPLATE)
145            }
146
147            // MADR templates
148            (TemplateFormat::Madr, TemplateVariant::Full) => ("madr", MADR_TEMPLATE),
149            (TemplateFormat::Madr, TemplateVariant::Minimal) => {
150                ("madr-minimal", MADR_MINIMAL_TEMPLATE)
151            }
152            (TemplateFormat::Madr, TemplateVariant::Bare) => ("madr-bare", MADR_BARE_TEMPLATE),
153            (TemplateFormat::Madr, TemplateVariant::BareMinimal) => {
154                ("madr-bare-minimal", MADR_BARE_MINIMAL_TEMPLATE)
155            }
156        };
157        Self::from_string(name, content)
158    }
159
160    /// Render the template with the given ADR data.
161    pub fn render(&self, adr: &Adr, config: &Config) -> Result<String> {
162        use crate::LinkKind;
163
164        let mut env = Environment::new();
165        env.add_template(&self.name, &self.content)
166            .map_err(|e| Error::TemplateError(e.to_string()))?;
167
168        let template = env
169            .get_template(&self.name)
170            .map_err(|e| Error::TemplateError(e.to_string()))?;
171
172        // Convert links to a format with display-friendly kind
173        let links: Vec<_> = adr
174            .links
175            .iter()
176            .map(|link| {
177                let kind_display = match &link.kind {
178                    LinkKind::Supersedes => "Supersedes",
179                    LinkKind::SupersededBy => "Superseded by",
180                    LinkKind::Amends => "Amends",
181                    LinkKind::AmendedBy => "Amended by",
182                    LinkKind::RelatesTo => "Relates to",
183                    LinkKind::Custom(s) => s.as_str(),
184                };
185                context! {
186                    target => link.target,
187                    kind => kind_display,
188                    description => &link.description,
189                }
190            })
191            .collect();
192
193        let output = template
194            .render(context! {
195                number => adr.number,
196                title => &adr.title,
197                date => crate::parse::format_date(adr.date),
198                status => adr.status.to_string(),
199                context => &adr.context,
200                decision => &adr.decision,
201                consequences => &adr.consequences,
202                links => links,
203                tags => &adr.tags,
204                is_ng => config.is_next_gen(),
205                // MADR 4.0.0 fields
206                decision_makers => &adr.decision_makers,
207                consulted => &adr.consulted,
208                informed => &adr.informed,
209            })
210            .map_err(|e| Error::TemplateError(e.to_string()))?;
211
212        Ok(output)
213    }
214}
215
216/// Template engine for managing and rendering templates.
217#[derive(Debug)]
218pub struct TemplateEngine {
219    /// The default template format.
220    default_format: TemplateFormat,
221
222    /// The default template variant.
223    default_variant: TemplateVariant,
224
225    /// Custom template path (overrides built-in).
226    custom_template: Option<Template>,
227}
228
229impl Default for TemplateEngine {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235impl TemplateEngine {
236    /// Create a new template engine.
237    pub fn new() -> Self {
238        Self {
239            default_format: TemplateFormat::default(),
240            default_variant: TemplateVariant::default(),
241            custom_template: None,
242        }
243    }
244
245    /// Set the default template format.
246    pub fn with_format(mut self, format: TemplateFormat) -> Self {
247        self.default_format = format;
248        self
249    }
250
251    /// Set the default template variant.
252    pub fn with_variant(mut self, variant: TemplateVariant) -> Self {
253        self.default_variant = variant;
254        self
255    }
256
257    /// Set a custom template.
258    pub fn with_custom_template(mut self, template: Template) -> Self {
259        self.custom_template = Some(template);
260        self
261    }
262
263    /// Load a custom template from a file.
264    pub fn with_custom_template_file(mut self, path: &Path) -> Result<Self> {
265        self.custom_template = Some(Template::from_file(path)?);
266        Ok(self)
267    }
268
269    /// Get the template to use for rendering.
270    pub fn template(&self) -> Template {
271        self.custom_template.clone().unwrap_or_else(|| {
272            Template::builtin_with_variant(self.default_format, self.default_variant)
273        })
274    }
275
276    /// Render an ADR using the configured template.
277    pub fn render(&self, adr: &Adr, config: &Config) -> Result<String> {
278        self.template().render(adr, config)
279    }
280}
281
282/// Nygard's original ADR template (compatible mode).
283const NYGARD_TEMPLATE: &str = r#"{% if is_ng %}---
284number: {{ number }}
285title: {{ title }}
286date: {{ date }}
287status: {{ status | lower }}
288{% if links %}links:
289{% for link in links %}  - target: {{ link.target }}
290    kind: {{ link.kind | lower }}
291{% endfor %}{% endif %}{% if tags %}tags:
292{% for tag in tags %}  - {{ tag }}
293{% endfor %}{% endif %}---
294
295{% endif %}# {{ number }}. {{ title }}
296
297Date: {{ date }}
298
299## Status
300
301{{ status }}
302{% for link in links %}
303{{ link.kind }} [{{ link.target }}. ...]({{ "%04d" | format(link.target) }}-....md)
304{% endfor %}
305## Context
306
307{{ context if context else "What is the issue that we're seeing that is motivating this decision or change?" }}
308
309## Decision
310
311{{ decision if decision else "What is the change that we're proposing and/or doing?" }}
312
313## Consequences
314
315{{ consequences if consequences else "What becomes easier or more difficult to do because of this change?" }}
316"#;
317
318/// MADR (Markdown Any Decision Records) 4.0.0 template.
319const MADR_TEMPLATE: &str = r#"---
320number: {{ number }}
321title: {{ title }}
322status: {{ status | lower }}
323date: {{ date }}
324{% if decision_makers %}decision-makers:
325{% for dm in decision_makers %}  - {{ dm }}
326{% endfor %}{% endif %}{% if consulted %}consulted:
327{% for c in consulted %}  - {{ c }}
328{% endfor %}{% endif %}{% if informed %}informed:
329{% for i in informed %}  - {{ i }}
330{% endfor %}{% endif %}{% if tags %}tags:
331{% for tag in tags %}  - {{ tag }}
332{% endfor %}{% endif %}---
333
334# {{ title }}
335
336## Context and Problem Statement
337
338{{ context if context else "{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}" }}
339
340<!-- This is an optional element. Feel free to remove. -->
341## Decision Drivers
342
343* {decision driver 1, e.g., a force, facing concern, ...}
344* {decision driver 2, e.g., a force, facing concern, ...}
345* ... <!-- numbers of drivers can vary -->
346
347## Considered Options
348
349* {title of option 1}
350* {title of option 2}
351* {title of option 3}
352* ... <!-- numbers of options can vary -->
353
354## Decision Outcome
355
356{{ decision if decision else "Chosen option: \"{title of option 1}\", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | ... | comes out best (see below)}." }}
357
358<!-- This is an optional element. Feel free to remove. -->
359### Consequences
360
361{{ consequences if consequences else "* Good, because {positive consequence, e.g., improvement of one or more desired qualities, ...}\n* Bad, because {negative consequence, e.g., compromising one or more desired qualities, ...}\n* ... <!-- numbers of consequences can vary -->" }}
362
363<!-- This is an optional element. Feel free to remove. -->
364### Confirmation
365
366{Describe how the implementation/compliance of the ADR can/will be confirmed. Is there any automated or manual fitness function? If so, list it and explain how it is applied. Is the chosen design and its implementation in line with the decision? E.g., a design/code review or a test with a library such as ArchUnit can help validate this. Note that although we classify this element as optional, it is included in many ADRs.}
367
368<!-- This is an optional element. Feel free to remove. -->
369## Pros and Cons of the Options
370
371### {title of option 1}
372
373<!-- This is an optional element. Feel free to remove. -->
374{example | description | pointer to more information | ...}
375
376* Good, because {argument a}
377* Good, because {argument b}
378<!-- use "neutral" if the given argument weights neither for good nor bad -->
379* Neutral, because {argument c}
380* Bad, because {argument d}
381* ... <!-- numbers of pros and cons can vary -->
382
383### {title of other option}
384
385{example | description | pointer to more information | ...}
386
387* Good, because {argument a}
388* Good, because {argument b}
389* Neutral, because {argument c}
390* Bad, because {argument d}
391* ...
392
393<!-- This is an optional element. Feel free to remove. -->
394## More Information
395
396{You might want to provide additional evidence/confidence for the decision outcome here and/or document the team agreement on the decision and/or define when/how this decision should be realized and if/when it should be re-visited. Links to other decisions and resources might appear here as well.}
397"#;
398
399/// Nygard minimal template - essential sections only.
400const NYGARD_MINIMAL_TEMPLATE: &str = r#"{% if is_ng %}---
401number: {{ number }}
402title: {{ title }}
403date: {{ date }}
404status: {{ status | lower }}
405{% if links %}links:
406{% for link in links %}  - target: {{ link.target }}
407    kind: {{ link.kind | lower }}
408{% endfor %}{% endif %}{% if tags %}tags:
409{% for tag in tags %}  - {{ tag }}
410{% endfor %}{% endif %}---
411
412{% endif %}# {{ number }}. {{ title }}
413
414Date: {{ date }}
415
416## Status
417
418{{ status }}
419{% for link in links %}
420{{ link.kind }} [{{ link.target }}. ...]({{ "%04d" | format(link.target) }}-....md)
421{% endfor %}
422## Context
423
424{{ context if context else "" }}
425
426## Decision
427
428{{ decision if decision else "" }}
429
430## Consequences
431
432{{ consequences if consequences else "" }}
433"#;
434
435/// Nygard bare template - just the structure, no guidance.
436const NYGARD_BARE_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
437
438Date: {{ date }}
439
440## Status
441
442{{ status }}
443
444## Context
445
446
447
448## Decision
449
450
451
452## Consequences
453
454"#;
455
456/// Nygard bare-minimal template - fewest sections, empty content.
457const NYGARD_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
458
459## Status
460
461{{ status }}
462
463## Context
464
465
466
467## Decision
468
469
470
471## Consequences
472
473"#;
474
475/// MADR minimal template - core sections only, no frontmatter.
476/// Matches official MADR adr-template-minimal.md
477const MADR_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
478
479## Context and Problem Statement
480
481{{ context if context else "{Describe the context and problem statement, e.g., in free form using two to three sentences or in the form of an illustrative story. You may want to articulate the problem in form of a question and add links to collaboration boards or issue management systems.}" }}
482
483## Considered Options
484
485* {title of option 1}
486* {title of option 2}
487* {title of option 3}
488* ... <!-- numbers of options can vary -->
489
490## Decision Outcome
491
492{{ decision if decision else "Chosen option: \"{title of option 1}\", because {justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force {force} | ... | comes out best (see below)}." }}
493
494<!-- This is an optional element. Feel free to remove. -->
495### Consequences
496
497{{ consequences if consequences else "* Good, because {positive consequence, e.g., improvement of one or more desired qualities, ...}\n* Bad, because {negative consequence, e.g., compromising one or more desired qualities, ...}\n* ... <!-- numbers of consequences can vary -->" }}
498"#;
499
500/// MADR bare template - all sections with empty placeholders.
501/// Matches official MADR adr-template-bare.md
502const MADR_BARE_TEMPLATE: &str = r#"---
503number: {{ number }}
504title: {{ title }}
505status: {{ status | lower }}
506date: {{ date }}
507decision-makers:
508consulted:
509informed:
510{% if tags %}tags:
511{% for tag in tags %}  - {{ tag }}
512{% endfor %}{% else %}tags:
513{% endif %}---
514
515# {{ title }}
516
517## Context and Problem Statement
518
519
520
521## Decision Drivers
522
523* <!-- decision driver -->
524
525## Considered Options
526
527* <!-- option -->
528
529## Decision Outcome
530
531Chosen option: "", because
532
533### Consequences
534
535* Good, because
536* Bad, because
537
538### Confirmation
539
540
541
542## Pros and Cons of the Options
543
544### <!-- title of option -->
545
546* Good, because
547* Neutral, because
548* Bad, because
549
550## More Information
551
552"#;
553
554/// MADR bare-minimal template - fewest sections, empty content.
555/// Matches official MADR adr-template-bare-minimal.md
556const MADR_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
557
558## Context and Problem Statement
559
560
561
562## Considered Options
563
564
565
566## Decision Outcome
567
568
569
570### Consequences
571
572"#;
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::{AdrLink, AdrStatus, ConfigMode, LinkKind};
578    use tempfile::TempDir;
579    use test_case::test_case;
580
581    // ========== TemplateFormat Tests ==========
582
583    #[test]
584    fn test_template_format_default() {
585        assert_eq!(TemplateFormat::default(), TemplateFormat::Nygard);
586    }
587
588    #[test_case("nygard" => TemplateFormat::Nygard; "nygard")]
589    #[test_case("Nygard" => TemplateFormat::Nygard; "nygard capitalized")]
590    #[test_case("NYGARD" => TemplateFormat::Nygard; "nygard uppercase")]
591    #[test_case("default" => TemplateFormat::Nygard; "default alias")]
592    #[test_case("madr" => TemplateFormat::Madr; "madr")]
593    #[test_case("MADR" => TemplateFormat::Madr; "madr uppercase")]
594    fn test_template_format_parse(input: &str) -> TemplateFormat {
595        input.parse().unwrap()
596    }
597
598    #[test]
599    fn test_template_format_parse_unknown() {
600        let result: Result<TemplateFormat> = "unknown".parse();
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn test_template_format_display() {
606        assert_eq!(TemplateFormat::Nygard.to_string(), "nygard");
607        assert_eq!(TemplateFormat::Madr.to_string(), "madr");
608    }
609
610    // ========== TemplateVariant Tests ==========
611
612    #[test]
613    fn test_template_variant_default() {
614        assert_eq!(TemplateVariant::default(), TemplateVariant::Full);
615    }
616
617    #[test_case("full" => TemplateVariant::Full; "full")]
618    #[test_case("Full" => TemplateVariant::Full; "full capitalized")]
619    #[test_case("default" => TemplateVariant::Full; "default alias")]
620    #[test_case("minimal" => TemplateVariant::Minimal; "minimal")]
621    #[test_case("min" => TemplateVariant::Minimal; "min alias")]
622    #[test_case("bare" => TemplateVariant::Bare; "bare")]
623    #[test_case("bare-minimal" => TemplateVariant::BareMinimal; "bare-minimal")]
624    #[test_case("bareminimal" => TemplateVariant::BareMinimal; "bareminimal")]
625    #[test_case("empty" => TemplateVariant::BareMinimal; "empty alias")]
626    fn test_template_variant_parse(input: &str) -> TemplateVariant {
627        input.parse().unwrap()
628    }
629
630    #[test]
631    fn test_template_variant_parse_unknown() {
632        let result: Result<TemplateVariant> = "unknown".parse();
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn test_template_variant_display() {
638        assert_eq!(TemplateVariant::Full.to_string(), "full");
639        assert_eq!(TemplateVariant::Minimal.to_string(), "minimal");
640        assert_eq!(TemplateVariant::Bare.to_string(), "bare");
641        assert_eq!(TemplateVariant::BareMinimal.to_string(), "bare-minimal");
642    }
643
644    // ========== Template Creation Tests ==========
645
646    #[test]
647    fn test_template_from_string() {
648        let template = Template::from_string("test", "# {{ title }}");
649        assert_eq!(template.name, "test");
650        assert_eq!(template.content, "# {{ title }}");
651    }
652
653    #[test]
654    fn test_template_from_file() {
655        let temp = TempDir::new().unwrap();
656        let path = temp.path().join("custom.md");
657        std::fs::write(&path, "# {{ number }}. {{ title }}").unwrap();
658
659        let template = Template::from_file(&path).unwrap();
660        assert_eq!(template.name, "custom.md");
661        assert!(template.content.contains("{{ number }}"));
662    }
663
664    #[test]
665    fn test_template_from_file_not_found() {
666        let result = Template::from_file(Path::new("/nonexistent/template.md"));
667        assert!(result.is_err());
668    }
669
670    #[test]
671    fn test_template_builtin_nygard() {
672        let template = Template::builtin(TemplateFormat::Nygard);
673        assert_eq!(template.name, "nygard");
674        assert!(template.content.contains("## Status"));
675        assert!(template.content.contains("## Context"));
676        assert!(template.content.contains("## Decision"));
677        assert!(template.content.contains("## Consequences"));
678    }
679
680    #[test]
681    fn test_template_builtin_madr() {
682        let template = Template::builtin(TemplateFormat::Madr);
683        assert_eq!(template.name, "madr");
684        assert!(template.content.contains("Context and Problem Statement"));
685        assert!(template.content.contains("Decision Drivers"));
686        assert!(template.content.contains("Considered Options"));
687        assert!(template.content.contains("Decision Outcome"));
688    }
689
690    // ========== Template Rendering - Nygard Compatible Mode ==========
691
692    #[test]
693    fn test_render_nygard_compatible() {
694        let template = Template::builtin(TemplateFormat::Nygard);
695        let mut adr = Adr::new(1, "Use Rust");
696        adr.status = AdrStatus::Accepted;
697
698        let config = Config::default();
699        let output = template.render(&adr, &config).unwrap();
700
701        assert!(output.contains("# 1. Use Rust"));
702        assert!(output.contains("## Status"));
703        assert!(output.contains("Accepted"));
704        assert!(!output.starts_with("---")); // No frontmatter in compatible mode
705    }
706
707    #[test]
708    fn test_render_nygard_all_statuses() {
709        let template = Template::builtin(TemplateFormat::Nygard);
710        let config = Config::default();
711
712        for (status, expected_text) in [
713            (AdrStatus::Proposed, "Proposed"),
714            (AdrStatus::Accepted, "Accepted"),
715            (AdrStatus::Deprecated, "Deprecated"),
716            (AdrStatus::Superseded, "Superseded"),
717            (AdrStatus::Custom("Draft".into()), "Draft"),
718        ] {
719            let mut adr = Adr::new(1, "Test");
720            adr.status = status;
721
722            let output = template.render(&adr, &config).unwrap();
723            assert!(
724                output.contains(expected_text),
725                "Output should contain '{expected_text}': {output}"
726            );
727        }
728    }
729
730    #[test]
731    fn test_render_nygard_with_content() {
732        let template = Template::builtin(TemplateFormat::Nygard);
733        let mut adr = Adr::new(1, "Use Rust");
734        adr.status = AdrStatus::Accepted;
735        adr.context = "We need a safe language.".to_string();
736        adr.decision = "We will use Rust.".to_string();
737        adr.consequences = "Better memory safety.".to_string();
738
739        let config = Config::default();
740        let output = template.render(&adr, &config).unwrap();
741
742        assert!(output.contains("We need a safe language."));
743        assert!(output.contains("We will use Rust."));
744        assert!(output.contains("Better memory safety."));
745    }
746
747    #[test]
748    fn test_render_nygard_with_links() {
749        let template = Template::builtin(TemplateFormat::Nygard);
750        let mut adr = Adr::new(2, "Use PostgreSQL");
751        adr.status = AdrStatus::Accepted;
752        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
753
754        let config = Config::default();
755        let output = template.render(&adr, &config).unwrap();
756
757        assert!(output.contains("Supersedes"));
758        assert!(output.contains("[1. ...]"));
759        assert!(output.contains("0001-....md"));
760    }
761
762    #[test]
763    fn test_render_nygard_with_multiple_links() {
764        let template = Template::builtin(TemplateFormat::Nygard);
765        let mut adr = Adr::new(5, "Combined Decision");
766        adr.status = AdrStatus::Accepted;
767        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
768        adr.links.push(AdrLink::new(2, LinkKind::Amends));
769        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
770
771        let config = Config::default();
772        let output = template.render(&adr, &config).unwrap();
773
774        assert!(output.contains("Supersedes"));
775        assert!(output.contains("Amends"));
776        assert!(output.contains("Superseded by"));
777    }
778
779    // ========== Template Rendering - Nygard NextGen Mode ==========
780
781    #[test]
782    fn test_render_nygard_ng() {
783        let template = Template::builtin(TemplateFormat::Nygard);
784        let mut adr = Adr::new(1, "Use Rust");
785        adr.status = AdrStatus::Accepted;
786
787        let config = Config {
788            mode: ConfigMode::NextGen,
789            ..Default::default()
790        };
791        let output = template.render(&adr, &config).unwrap();
792
793        assert!(output.starts_with("---")); // Has frontmatter in ng mode
794        assert!(output.contains("number: 1"));
795        assert!(output.contains("title: Use Rust"));
796        assert!(output.contains("status: accepted"));
797    }
798
799    #[test]
800    fn test_render_nygard_ng_with_links() {
801        let template = Template::builtin(TemplateFormat::Nygard);
802        let mut adr = Adr::new(2, "Test");
803        adr.status = AdrStatus::Accepted;
804        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
805
806        let config = Config {
807            mode: ConfigMode::NextGen,
808            ..Default::default()
809        };
810        let output = template.render(&adr, &config).unwrap();
811
812        assert!(output.contains("links:"));
813        assert!(output.contains("target: 1"));
814    }
815
816    // ========== Template Rendering - MADR 4.0.0 ==========
817
818    #[test]
819    fn test_render_madr_basic() {
820        let template = Template::builtin(TemplateFormat::Madr);
821        let mut adr = Adr::new(1, "Use Rust");
822        adr.status = AdrStatus::Accepted;
823
824        let config = Config::default();
825        let output = template.render(&adr, &config).unwrap();
826
827        assert!(output.starts_with("---")); // MADR always has frontmatter
828        assert!(output.contains("status: accepted"));
829        assert!(output.contains("# Use Rust"));
830        assert!(output.contains("## Context and Problem Statement"));
831        assert!(output.contains("## Decision Drivers"));
832        assert!(output.contains("## Considered Options"));
833        assert!(output.contains("## Decision Outcome"));
834        assert!(output.contains("## Pros and Cons of the Options"));
835    }
836
837    #[test]
838    fn test_render_madr_with_decision_makers() {
839        let template = Template::builtin(TemplateFormat::Madr);
840        let mut adr = Adr::new(1, "Use Rust");
841        adr.status = AdrStatus::Accepted;
842        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
843
844        let config = Config::default();
845        let output = template.render(&adr, &config).unwrap();
846
847        assert!(output.contains("decision-makers:"));
848        assert!(output.contains("  - Alice"));
849        assert!(output.contains("  - Bob"));
850    }
851
852    #[test]
853    fn test_render_madr_with_consulted() {
854        let template = Template::builtin(TemplateFormat::Madr);
855        let mut adr = Adr::new(1, "Use Rust");
856        adr.status = AdrStatus::Accepted;
857        adr.consulted = vec!["Carol".into()];
858
859        let config = Config::default();
860        let output = template.render(&adr, &config).unwrap();
861
862        assert!(output.contains("consulted:"));
863        assert!(output.contains("  - Carol"));
864    }
865
866    #[test]
867    fn test_render_madr_with_informed() {
868        let template = Template::builtin(TemplateFormat::Madr);
869        let mut adr = Adr::new(1, "Use Rust");
870        adr.status = AdrStatus::Accepted;
871        adr.informed = vec!["Dave".into(), "Eve".into()];
872
873        let config = Config::default();
874        let output = template.render(&adr, &config).unwrap();
875
876        assert!(output.contains("informed:"));
877        assert!(output.contains("  - Dave"));
878        assert!(output.contains("  - Eve"));
879    }
880
881    #[test]
882    fn test_render_madr_full_frontmatter() {
883        let template = Template::builtin(TemplateFormat::Madr);
884        let mut adr = Adr::new(1, "Use MADR Format");
885        adr.status = AdrStatus::Accepted;
886        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
887        adr.consulted = vec!["Carol".into()];
888        adr.informed = vec!["Dave".into()];
889
890        let config = Config::default();
891        let output = template.render(&adr, &config).unwrap();
892
893        // Check frontmatter structure - now includes number and title
894        assert!(
895            output.starts_with("---\nnumber: 1\ntitle: Use MADR Format\nstatus: accepted\ndate:")
896        );
897        assert!(output.contains("decision-makers:\n  - Alice\n  - Bob"));
898        assert!(output.contains("consulted:\n  - Carol"));
899        assert!(output.contains("informed:\n  - Dave"));
900        assert!(output.contains("---\n\n# Use MADR Format"));
901    }
902
903    #[test]
904    fn test_render_madr_empty_optional_fields() {
905        let template = Template::builtin(TemplateFormat::Madr);
906        let mut adr = Adr::new(1, "Simple ADR");
907        adr.status = AdrStatus::Proposed;
908
909        let config = Config::default();
910        let output = template.render(&adr, &config).unwrap();
911
912        // Empty optional fields should not appear
913        assert!(!output.contains("decision-makers:"));
914        assert!(!output.contains("consulted:"));
915        assert!(!output.contains("informed:"));
916    }
917
918    // ========== Template Variants Tests ==========
919
920    #[test]
921    fn test_nygard_minimal_template() {
922        let template =
923            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
924        let adr = Adr::new(1, "Minimal Test");
925        let config = Config::default();
926        let output = template.render(&adr, &config).unwrap();
927
928        // Should have basic structure but no guidance text
929        assert!(output.contains("# 1. Minimal Test"));
930        assert!(output.contains("## Status"));
931        assert!(output.contains("## Context"));
932        assert!(output.contains("## Decision"));
933        assert!(output.contains("## Consequences"));
934        // Should NOT have guidance text
935        assert!(!output.contains("What is the issue"));
936    }
937
938    #[test]
939    fn test_nygard_bare_template() {
940        let template =
941            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Bare);
942        let adr = Adr::new(1, "Bare Test");
943        let config = Config::default();
944        let output = template.render(&adr, &config).unwrap();
945
946        // Should have basic structure
947        assert!(output.contains("# 1. Bare Test"));
948        assert!(output.contains("## Status"));
949        assert!(output.contains("## Context"));
950        // Bare template has no frontmatter
951        assert!(!output.contains("---"));
952    }
953
954    #[test]
955    fn test_madr_minimal_template() {
956        let template =
957            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Minimal);
958        let adr = Adr::new(1, "MADR Minimal");
959        let config = Config::default();
960        let output = template.render(&adr, &config).unwrap();
961
962        // MADR minimal has NO frontmatter (matches official adr-template-minimal.md)
963        assert!(!output.starts_with("---"));
964        assert!(output.contains("# MADR Minimal"));
965        assert!(output.contains("## Context and Problem Statement"));
966        assert!(output.contains("## Considered Options"));
967        assert!(output.contains("## Decision Outcome"));
968        // Should NOT have full MADR sections
969        assert!(!output.contains("## Decision Drivers"));
970        assert!(!output.contains("## Pros and Cons"));
971    }
972
973    #[test]
974    fn test_madr_bare_template() {
975        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
976        let adr = Adr::new(1, "MADR Bare");
977        let config = Config::default();
978        let output = template.render(&adr, &config).unwrap();
979
980        // MADR bare has frontmatter with empty fields (matches official adr-template-bare.md)
981        assert!(output.starts_with("---"));
982        assert!(output.contains("status:"));
983        assert!(output.contains("decision-makers:"));
984        assert!(output.contains("consulted:"));
985        assert!(output.contains("informed:"));
986        assert!(output.contains("# MADR Bare"));
987        // Should have ALL sections (empty)
988        assert!(output.contains("## Decision Drivers"));
989        assert!(output.contains("## Considered Options"));
990        assert!(output.contains("## Pros and Cons of the Options"));
991        assert!(output.contains("## More Information"));
992    }
993
994    #[test]
995    fn test_madr_bare_minimal_template() {
996        let template =
997            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::BareMinimal);
998        let adr = Adr::new(1, "MADR Bare Minimal");
999        let config = Config::default();
1000        let output = template.render(&adr, &config).unwrap();
1001
1002        // MADR bare-minimal has NO frontmatter, minimal sections
1003        assert!(!output.starts_with("---"));
1004        assert!(output.contains("# MADR Bare Minimal"));
1005        assert!(output.contains("## Context and Problem Statement"));
1006        assert!(output.contains("## Considered Options"));
1007        assert!(output.contains("## Decision Outcome"));
1008        assert!(output.contains("### Consequences"));
1009        // Should NOT have extended sections
1010        assert!(!output.contains("## Decision Drivers"));
1011        assert!(!output.contains("## Pros and Cons"));
1012    }
1013
1014    #[test]
1015    fn test_nygard_bare_minimal_template() {
1016        let template =
1017            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
1018        let adr = Adr::new(1, "Nygard Bare Minimal");
1019        let config = Config::default();
1020        let output = template.render(&adr, &config).unwrap();
1021
1022        // Should have basic structure without Date line
1023        assert!(output.contains("# 1. Nygard Bare Minimal"));
1024        assert!(output.contains("## Status"));
1025        assert!(output.contains("## Context"));
1026        assert!(output.contains("## Decision"));
1027        assert!(output.contains("## Consequences"));
1028        // No frontmatter, no date
1029        assert!(!output.contains("---"));
1030        assert!(!output.contains("Date:"));
1031    }
1032
1033    #[test]
1034    fn test_builtin_defaults_to_full() {
1035        let full = Template::builtin(TemplateFormat::Nygard);
1036        let explicit_full =
1037            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Full);
1038
1039        assert_eq!(full.name, explicit_full.name);
1040        assert_eq!(full.content, explicit_full.content);
1041    }
1042
1043    // ========== Template Engine Tests ==========
1044
1045    #[test]
1046    fn test_template_engine_new() {
1047        let engine = TemplateEngine::new();
1048        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1049        assert_eq!(engine.default_variant, TemplateVariant::Full);
1050        assert!(engine.custom_template.is_none());
1051    }
1052
1053    #[test]
1054    fn test_template_engine_default() {
1055        let engine = TemplateEngine::default();
1056        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1057        assert_eq!(engine.default_variant, TemplateVariant::Full);
1058    }
1059
1060    #[test]
1061    fn test_template_engine_with_format() {
1062        let engine = TemplateEngine::new().with_format(TemplateFormat::Madr);
1063        assert_eq!(engine.default_format, TemplateFormat::Madr);
1064    }
1065
1066    #[test]
1067    fn test_template_engine_with_custom_template() {
1068        let custom = Template::from_string("custom", "# {{ title }}");
1069        let engine = TemplateEngine::new().with_custom_template(custom);
1070        assert!(engine.custom_template.is_some());
1071    }
1072
1073    #[test]
1074    fn test_template_engine_with_custom_template_file() {
1075        let temp = TempDir::new().unwrap();
1076        let path = temp.path().join("template.md");
1077        std::fs::write(&path, "# {{ title }}").unwrap();
1078
1079        let engine = TemplateEngine::new()
1080            .with_custom_template_file(&path)
1081            .unwrap();
1082        assert!(engine.custom_template.is_some());
1083    }
1084
1085    #[test]
1086    fn test_template_engine_with_custom_template_file_not_found() {
1087        let result = TemplateEngine::new().with_custom_template_file(Path::new("/nonexistent.md"));
1088        assert!(result.is_err());
1089    }
1090
1091    #[test]
1092    fn test_template_engine_template_builtin() {
1093        let engine = TemplateEngine::new();
1094        let template = engine.template();
1095        assert_eq!(template.name, "nygard");
1096    }
1097
1098    #[test]
1099    fn test_template_engine_template_custom() {
1100        let custom = Template::from_string("my-template", "# Custom");
1101        let engine = TemplateEngine::new().with_custom_template(custom);
1102        let template = engine.template();
1103        assert_eq!(template.name, "my-template");
1104    }
1105
1106    #[test]
1107    fn test_template_engine_render() {
1108        let engine = TemplateEngine::new();
1109        let adr = Adr::new(1, "Test");
1110        let config = Config::default();
1111
1112        let output = engine.render(&adr, &config).unwrap();
1113        assert!(output.contains("# 1. Test"));
1114    }
1115
1116    #[test]
1117    fn test_template_engine_render_custom() {
1118        let custom = Template::from_string("custom", "ADR {{ number }}: {{ title }}");
1119        let engine = TemplateEngine::new().with_custom_template(custom);
1120        let adr = Adr::new(42, "Custom ADR");
1121        let config = Config::default();
1122
1123        let output = engine.render(&adr, &config).unwrap();
1124        assert_eq!(output, "ADR 42: Custom ADR");
1125    }
1126
1127    // ========== Custom Template Tests ==========
1128
1129    #[test]
1130    fn test_custom_template_all_fields() {
1131        let custom = Template::from_string(
1132            "full",
1133            r#"# {{ number }}. {{ title }}
1134Date: {{ date }}
1135Status: {{ status }}
1136Context: {{ context }}
1137Decision: {{ decision }}
1138Consequences: {{ consequences }}
1139Links: {% for link in links %}{{ link.kind }} {{ link.target }}{% endfor %}"#,
1140        );
1141
1142        let mut adr = Adr::new(1, "Test");
1143        adr.status = AdrStatus::Accepted;
1144        adr.context = "Context text".into();
1145        adr.decision = "Decision text".into();
1146        adr.consequences = "Consequences text".into();
1147        adr.links.push(AdrLink::new(2, LinkKind::Amends));
1148
1149        let config = Config::default();
1150        let output = custom.render(&adr, &config).unwrap();
1151
1152        assert!(output.contains("# 1. Test"));
1153        assert!(output.contains("Status: Accepted"));
1154        assert!(output.contains("Context: Context text"));
1155        assert!(output.contains("Decision: Decision text"));
1156        assert!(output.contains("Consequences: Consequences text"));
1157        assert!(output.contains("Amends 2"));
1158    }
1159
1160    #[test]
1161    fn test_custom_template_is_ng_flag() {
1162        let custom = Template::from_string(
1163            "ng-check",
1164            r#"{% if is_ng %}NextGen Mode{% else %}Compatible Mode{% endif %}"#,
1165        );
1166
1167        let adr = Adr::new(1, "Test");
1168
1169        let compat_config = Config::default();
1170        let output = custom.render(&adr, &compat_config).unwrap();
1171        assert_eq!(output, "Compatible Mode");
1172
1173        let ng_config = Config {
1174            mode: ConfigMode::NextGen,
1175            ..Default::default()
1176        };
1177        let output = custom.render(&adr, &ng_config).unwrap();
1178        assert_eq!(output, "NextGen Mode");
1179    }
1180
1181    #[test]
1182    fn test_custom_template_link_kinds() {
1183        let custom = Template::from_string(
1184            "links",
1185            r#"{% for link in links %}{{ link.kind }}|{% endfor %}"#,
1186        );
1187
1188        let mut adr = Adr::new(1, "Test");
1189        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1190        adr.links.push(AdrLink::new(2, LinkKind::SupersededBy));
1191        adr.links.push(AdrLink::new(3, LinkKind::Amends));
1192        adr.links.push(AdrLink::new(4, LinkKind::AmendedBy));
1193        adr.links.push(AdrLink::new(5, LinkKind::RelatesTo));
1194        adr.links
1195            .push(AdrLink::new(6, LinkKind::Custom("Depends on".into())));
1196
1197        let config = Config::default();
1198        let output = custom.render(&adr, &config).unwrap();
1199
1200        assert!(output.contains("Supersedes|"));
1201        assert!(output.contains("Superseded by|"));
1202        assert!(output.contains("Amends|"));
1203        assert!(output.contains("Amended by|"));
1204        assert!(output.contains("Relates to|"));
1205        assert!(output.contains("Depends on|"));
1206    }
1207
1208    // ========== Error Cases ==========
1209
1210    #[test]
1211    fn test_template_invalid_syntax() {
1212        let custom = Template::from_string("invalid", "{{ unclosed");
1213        let adr = Adr::new(1, "Test");
1214        let config = Config::default();
1215
1216        let result = custom.render(&adr, &config);
1217        assert!(result.is_err());
1218    }
1219
1220    #[test]
1221    fn test_template_undefined_variable() {
1222        let custom = Template::from_string("undefined", "{{ nonexistent }}");
1223        let adr = Adr::new(1, "Test");
1224        let config = Config::default();
1225
1226        // minijinja treats undefined as empty string by default
1227        let result = custom.render(&adr, &config);
1228        assert!(result.is_ok());
1229    }
1230
1231    // ========== Large Number Formatting ==========
1232
1233    #[test]
1234    fn test_render_four_digit_number() {
1235        let template = Template::builtin(TemplateFormat::Nygard);
1236        let adr = Adr::new(9999, "Large Number");
1237        let config = Config::default();
1238
1239        let output = template.render(&adr, &config).unwrap();
1240        assert!(output.contains("# 9999. Large Number"));
1241    }
1242
1243    #[test]
1244    fn test_render_link_number_formatting() {
1245        let template = Template::builtin(TemplateFormat::Nygard);
1246        let mut adr = Adr::new(2, "Test");
1247        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1248
1249        let config = Config::default();
1250        let output = template.render(&adr, &config).unwrap();
1251
1252        // Link should use 4-digit padding
1253        assert!(output.contains("0001-"));
1254    }
1255
1256    // ========== Tags Rendering ==========
1257
1258    #[test]
1259    fn test_render_tags_in_nextgen_mode() {
1260        let template = Template::builtin(TemplateFormat::Nygard);
1261        let mut adr = Adr::new(1, "Test ADR");
1262        adr.tags = vec!["database".to_string(), "infrastructure".to_string()];
1263
1264        let config = Config {
1265            mode: ConfigMode::NextGen,
1266            ..Default::default()
1267        };
1268        let output = template.render(&adr, &config).unwrap();
1269
1270        // Tags should appear in YAML frontmatter
1271        assert!(output.contains("tags:"));
1272        assert!(output.contains("- database"));
1273        assert!(output.contains("- infrastructure"));
1274    }
1275
1276    #[test]
1277    fn test_render_tags_in_madr_format() {
1278        let template = Template::builtin(TemplateFormat::Madr);
1279        let mut adr = Adr::new(1, "Test ADR");
1280        adr.tags = vec!["api".to_string(), "security".to_string()];
1281
1282        let config = Config::default();
1283        let output = template.render(&adr, &config).unwrap();
1284
1285        // Tags should appear in YAML frontmatter
1286        assert!(output.contains("tags:"));
1287        assert!(output.contains("- api"));
1288        assert!(output.contains("- security"));
1289    }
1290
1291    #[test]
1292    fn test_render_no_tags_section_when_empty() {
1293        let template = Template::builtin(TemplateFormat::Nygard);
1294        let adr = Adr::new(1, "Test ADR");
1295
1296        let config = Config {
1297            mode: ConfigMode::NextGen,
1298            ..Default::default()
1299        };
1300        let output = template.render(&adr, &config).unwrap();
1301
1302        // No tags section when tags are empty
1303        assert!(!output.contains("tags:"));
1304    }
1305}