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                is_ng => config.is_next_gen(),
204                // MADR 4.0.0 fields
205                decision_makers => &adr.decision_makers,
206                consulted => &adr.consulted,
207                informed => &adr.informed,
208            })
209            .map_err(|e| Error::TemplateError(e.to_string()))?;
210
211        Ok(output)
212    }
213}
214
215/// Template engine for managing and rendering templates.
216#[derive(Debug)]
217pub struct TemplateEngine {
218    /// The default template format.
219    default_format: TemplateFormat,
220
221    /// The default template variant.
222    default_variant: TemplateVariant,
223
224    /// Custom template path (overrides built-in).
225    custom_template: Option<Template>,
226}
227
228impl Default for TemplateEngine {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl TemplateEngine {
235    /// Create a new template engine.
236    pub fn new() -> Self {
237        Self {
238            default_format: TemplateFormat::default(),
239            default_variant: TemplateVariant::default(),
240            custom_template: None,
241        }
242    }
243
244    /// Set the default template format.
245    pub fn with_format(mut self, format: TemplateFormat) -> Self {
246        self.default_format = format;
247        self
248    }
249
250    /// Set the default template variant.
251    pub fn with_variant(mut self, variant: TemplateVariant) -> Self {
252        self.default_variant = variant;
253        self
254    }
255
256    /// Set a custom template.
257    pub fn with_custom_template(mut self, template: Template) -> Self {
258        self.custom_template = Some(template);
259        self
260    }
261
262    /// Load a custom template from a file.
263    pub fn with_custom_template_file(mut self, path: &Path) -> Result<Self> {
264        self.custom_template = Some(Template::from_file(path)?);
265        Ok(self)
266    }
267
268    /// Get the template to use for rendering.
269    pub fn template(&self) -> Template {
270        self.custom_template.clone().unwrap_or_else(|| {
271            Template::builtin_with_variant(self.default_format, self.default_variant)
272        })
273    }
274
275    /// Render an ADR using the configured template.
276    pub fn render(&self, adr: &Adr, config: &Config) -> Result<String> {
277        self.template().render(adr, config)
278    }
279}
280
281/// Nygard's original ADR template (compatible mode).
282const NYGARD_TEMPLATE: &str = r#"{% if is_ng %}---
283number: {{ number }}
284title: {{ title }}
285date: {{ date }}
286status: {{ status | lower }}
287{% if links %}links:
288{% for link in links %}  - target: {{ link.target }}
289    kind: {{ link.kind | lower }}
290{% endfor %}{% endif %}---
291
292{% endif %}# {{ number }}. {{ title }}
293
294Date: {{ date }}
295
296## Status
297
298{{ status }}
299{% for link in links %}
300{{ link.kind }} [{{ link.target }}. ...]({{ "%04d" | format(link.target) }}-....md)
301{% endfor %}
302## Context
303
304{{ context if context else "What is the issue that we're seeing that is motivating this decision or change?" }}
305
306## Decision
307
308{{ decision if decision else "What is the change that we're proposing and/or doing?" }}
309
310## Consequences
311
312{{ consequences if consequences else "What becomes easier or more difficult to do because of this change?" }}
313"#;
314
315/// MADR (Markdown Any Decision Records) 4.0.0 template.
316const MADR_TEMPLATE: &str = r#"---
317status: {{ status | lower }}
318date: {{ date }}
319{% if decision_makers %}decision-makers:
320{% for dm in decision_makers %}  - {{ dm }}
321{% endfor %}{% endif %}{% if consulted %}consulted:
322{% for c in consulted %}  - {{ c }}
323{% endfor %}{% endif %}{% if informed %}informed:
324{% for i in informed %}  - {{ i }}
325{% endfor %}{% endif %}---
326
327# {{ title }}
328
329## Context and Problem Statement
330
331{{ 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.}" }}
332
333<!-- This is an optional element. Feel free to remove. -->
334## Decision Drivers
335
336* {decision driver 1, e.g., a force, facing concern, ...}
337* {decision driver 2, e.g., a force, facing concern, ...}
338* ... <!-- numbers of drivers can vary -->
339
340## Considered Options
341
342* {title of option 1}
343* {title of option 2}
344* {title of option 3}
345* ... <!-- numbers of options can vary -->
346
347## Decision Outcome
348
349{{ 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)}." }}
350
351<!-- This is an optional element. Feel free to remove. -->
352### Consequences
353
354{{ 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 -->" }}
355
356<!-- This is an optional element. Feel free to remove. -->
357### Confirmation
358
359{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.}
360
361<!-- This is an optional element. Feel free to remove. -->
362## Pros and Cons of the Options
363
364### {title of option 1}
365
366<!-- This is an optional element. Feel free to remove. -->
367{example | description | pointer to more information | ...}
368
369* Good, because {argument a}
370* Good, because {argument b}
371<!-- use "neutral" if the given argument weights neither for good nor bad -->
372* Neutral, because {argument c}
373* Bad, because {argument d}
374* ... <!-- numbers of pros and cons can vary -->
375
376### {title of other option}
377
378{example | description | pointer to more information | ...}
379
380* Good, because {argument a}
381* Good, because {argument b}
382* Neutral, because {argument c}
383* Bad, because {argument d}
384* ...
385
386<!-- This is an optional element. Feel free to remove. -->
387## More Information
388
389{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.}
390"#;
391
392/// Nygard minimal template - essential sections only.
393const NYGARD_MINIMAL_TEMPLATE: &str = r#"{% if is_ng %}---
394number: {{ number }}
395title: {{ title }}
396date: {{ date }}
397status: {{ status | lower }}
398{% if links %}links:
399{% for link in links %}  - target: {{ link.target }}
400    kind: {{ link.kind | lower }}
401{% endfor %}{% endif %}---
402
403{% endif %}# {{ number }}. {{ title }}
404
405Date: {{ date }}
406
407## Status
408
409{{ status }}
410{% for link in links %}
411{{ link.kind }} [{{ link.target }}. ...]({{ "%04d" | format(link.target) }}-....md)
412{% endfor %}
413## Context
414
415{{ context if context else "" }}
416
417## Decision
418
419{{ decision if decision else "" }}
420
421## Consequences
422
423{{ consequences if consequences else "" }}
424"#;
425
426/// Nygard bare template - just the structure, no guidance.
427const NYGARD_BARE_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
428
429Date: {{ date }}
430
431## Status
432
433{{ status }}
434
435## Context
436
437
438
439## Decision
440
441
442
443## Consequences
444
445"#;
446
447/// Nygard bare-minimal template - fewest sections, empty content.
448const NYGARD_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
449
450## Status
451
452{{ status }}
453
454## Context
455
456
457
458## Decision
459
460
461
462## Consequences
463
464"#;
465
466/// MADR minimal template - core sections only, no frontmatter.
467/// Matches official MADR adr-template-minimal.md
468const MADR_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
469
470## Context and Problem Statement
471
472{{ 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.}" }}
473
474## Considered Options
475
476* {title of option 1}
477* {title of option 2}
478* {title of option 3}
479* ... <!-- numbers of options can vary -->
480
481## Decision Outcome
482
483{{ 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)}." }}
484
485<!-- This is an optional element. Feel free to remove. -->
486### Consequences
487
488{{ 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 -->" }}
489"#;
490
491/// MADR bare template - all sections with empty placeholders.
492/// Matches official MADR adr-template-bare.md
493const MADR_BARE_TEMPLATE: &str = r#"---
494status: {{ status | lower }}
495date: {{ date }}
496decision-makers:
497consulted:
498informed:
499---
500
501# {{ title }}
502
503## Context and Problem Statement
504
505
506
507## Decision Drivers
508
509* <!-- decision driver -->
510
511## Considered Options
512
513* <!-- option -->
514
515## Decision Outcome
516
517Chosen option: "", because
518
519### Consequences
520
521* Good, because
522* Bad, because
523
524### Confirmation
525
526
527
528## Pros and Cons of the Options
529
530### <!-- title of option -->
531
532* Good, because
533* Neutral, because
534* Bad, because
535
536## More Information
537
538"#;
539
540/// MADR bare-minimal template - fewest sections, empty content.
541/// Matches official MADR adr-template-bare-minimal.md
542const MADR_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
543
544## Context and Problem Statement
545
546
547
548## Considered Options
549
550
551
552## Decision Outcome
553
554
555
556### Consequences
557
558"#;
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use crate::{AdrLink, AdrStatus, ConfigMode, LinkKind};
564    use tempfile::TempDir;
565    use test_case::test_case;
566
567    // ========== TemplateFormat Tests ==========
568
569    #[test]
570    fn test_template_format_default() {
571        assert_eq!(TemplateFormat::default(), TemplateFormat::Nygard);
572    }
573
574    #[test_case("nygard" => TemplateFormat::Nygard; "nygard")]
575    #[test_case("Nygard" => TemplateFormat::Nygard; "nygard capitalized")]
576    #[test_case("NYGARD" => TemplateFormat::Nygard; "nygard uppercase")]
577    #[test_case("default" => TemplateFormat::Nygard; "default alias")]
578    #[test_case("madr" => TemplateFormat::Madr; "madr")]
579    #[test_case("MADR" => TemplateFormat::Madr; "madr uppercase")]
580    fn test_template_format_parse(input: &str) -> TemplateFormat {
581        input.parse().unwrap()
582    }
583
584    #[test]
585    fn test_template_format_parse_unknown() {
586        let result: Result<TemplateFormat> = "unknown".parse();
587        assert!(result.is_err());
588    }
589
590    #[test]
591    fn test_template_format_display() {
592        assert_eq!(TemplateFormat::Nygard.to_string(), "nygard");
593        assert_eq!(TemplateFormat::Madr.to_string(), "madr");
594    }
595
596    // ========== TemplateVariant Tests ==========
597
598    #[test]
599    fn test_template_variant_default() {
600        assert_eq!(TemplateVariant::default(), TemplateVariant::Full);
601    }
602
603    #[test_case("full" => TemplateVariant::Full; "full")]
604    #[test_case("Full" => TemplateVariant::Full; "full capitalized")]
605    #[test_case("default" => TemplateVariant::Full; "default alias")]
606    #[test_case("minimal" => TemplateVariant::Minimal; "minimal")]
607    #[test_case("min" => TemplateVariant::Minimal; "min alias")]
608    #[test_case("bare" => TemplateVariant::Bare; "bare")]
609    #[test_case("bare-minimal" => TemplateVariant::BareMinimal; "bare-minimal")]
610    #[test_case("bareminimal" => TemplateVariant::BareMinimal; "bareminimal")]
611    #[test_case("empty" => TemplateVariant::BareMinimal; "empty alias")]
612    fn test_template_variant_parse(input: &str) -> TemplateVariant {
613        input.parse().unwrap()
614    }
615
616    #[test]
617    fn test_template_variant_parse_unknown() {
618        let result: Result<TemplateVariant> = "unknown".parse();
619        assert!(result.is_err());
620    }
621
622    #[test]
623    fn test_template_variant_display() {
624        assert_eq!(TemplateVariant::Full.to_string(), "full");
625        assert_eq!(TemplateVariant::Minimal.to_string(), "minimal");
626        assert_eq!(TemplateVariant::Bare.to_string(), "bare");
627        assert_eq!(TemplateVariant::BareMinimal.to_string(), "bare-minimal");
628    }
629
630    // ========== Template Creation Tests ==========
631
632    #[test]
633    fn test_template_from_string() {
634        let template = Template::from_string("test", "# {{ title }}");
635        assert_eq!(template.name, "test");
636        assert_eq!(template.content, "# {{ title }}");
637    }
638
639    #[test]
640    fn test_template_from_file() {
641        let temp = TempDir::new().unwrap();
642        let path = temp.path().join("custom.md");
643        std::fs::write(&path, "# {{ number }}. {{ title }}").unwrap();
644
645        let template = Template::from_file(&path).unwrap();
646        assert_eq!(template.name, "custom.md");
647        assert!(template.content.contains("{{ number }}"));
648    }
649
650    #[test]
651    fn test_template_from_file_not_found() {
652        let result = Template::from_file(Path::new("/nonexistent/template.md"));
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_template_builtin_nygard() {
658        let template = Template::builtin(TemplateFormat::Nygard);
659        assert_eq!(template.name, "nygard");
660        assert!(template.content.contains("## Status"));
661        assert!(template.content.contains("## Context"));
662        assert!(template.content.contains("## Decision"));
663        assert!(template.content.contains("## Consequences"));
664    }
665
666    #[test]
667    fn test_template_builtin_madr() {
668        let template = Template::builtin(TemplateFormat::Madr);
669        assert_eq!(template.name, "madr");
670        assert!(template.content.contains("Context and Problem Statement"));
671        assert!(template.content.contains("Decision Drivers"));
672        assert!(template.content.contains("Considered Options"));
673        assert!(template.content.contains("Decision Outcome"));
674    }
675
676    // ========== Template Rendering - Nygard Compatible Mode ==========
677
678    #[test]
679    fn test_render_nygard_compatible() {
680        let template = Template::builtin(TemplateFormat::Nygard);
681        let mut adr = Adr::new(1, "Use Rust");
682        adr.status = AdrStatus::Accepted;
683
684        let config = Config::default();
685        let output = template.render(&adr, &config).unwrap();
686
687        assert!(output.contains("# 1. Use Rust"));
688        assert!(output.contains("## Status"));
689        assert!(output.contains("Accepted"));
690        assert!(!output.starts_with("---")); // No frontmatter in compatible mode
691    }
692
693    #[test]
694    fn test_render_nygard_all_statuses() {
695        let template = Template::builtin(TemplateFormat::Nygard);
696        let config = Config::default();
697
698        for (status, expected_text) in [
699            (AdrStatus::Proposed, "Proposed"),
700            (AdrStatus::Accepted, "Accepted"),
701            (AdrStatus::Deprecated, "Deprecated"),
702            (AdrStatus::Superseded, "Superseded"),
703            (AdrStatus::Custom("Draft".into()), "Draft"),
704        ] {
705            let mut adr = Adr::new(1, "Test");
706            adr.status = status;
707
708            let output = template.render(&adr, &config).unwrap();
709            assert!(
710                output.contains(expected_text),
711                "Output should contain '{expected_text}': {output}"
712            );
713        }
714    }
715
716    #[test]
717    fn test_render_nygard_with_content() {
718        let template = Template::builtin(TemplateFormat::Nygard);
719        let mut adr = Adr::new(1, "Use Rust");
720        adr.status = AdrStatus::Accepted;
721        adr.context = "We need a safe language.".to_string();
722        adr.decision = "We will use Rust.".to_string();
723        adr.consequences = "Better memory safety.".to_string();
724
725        let config = Config::default();
726        let output = template.render(&adr, &config).unwrap();
727
728        assert!(output.contains("We need a safe language."));
729        assert!(output.contains("We will use Rust."));
730        assert!(output.contains("Better memory safety."));
731    }
732
733    #[test]
734    fn test_render_nygard_with_links() {
735        let template = Template::builtin(TemplateFormat::Nygard);
736        let mut adr = Adr::new(2, "Use PostgreSQL");
737        adr.status = AdrStatus::Accepted;
738        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
739
740        let config = Config::default();
741        let output = template.render(&adr, &config).unwrap();
742
743        assert!(output.contains("Supersedes"));
744        assert!(output.contains("[1. ...]"));
745        assert!(output.contains("0001-....md"));
746    }
747
748    #[test]
749    fn test_render_nygard_with_multiple_links() {
750        let template = Template::builtin(TemplateFormat::Nygard);
751        let mut adr = Adr::new(5, "Combined Decision");
752        adr.status = AdrStatus::Accepted;
753        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
754        adr.links.push(AdrLink::new(2, LinkKind::Amends));
755        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
756
757        let config = Config::default();
758        let output = template.render(&adr, &config).unwrap();
759
760        assert!(output.contains("Supersedes"));
761        assert!(output.contains("Amends"));
762        assert!(output.contains("Superseded by"));
763    }
764
765    // ========== Template Rendering - Nygard NextGen Mode ==========
766
767    #[test]
768    fn test_render_nygard_ng() {
769        let template = Template::builtin(TemplateFormat::Nygard);
770        let mut adr = Adr::new(1, "Use Rust");
771        adr.status = AdrStatus::Accepted;
772
773        let config = Config {
774            mode: ConfigMode::NextGen,
775            ..Default::default()
776        };
777        let output = template.render(&adr, &config).unwrap();
778
779        assert!(output.starts_with("---")); // Has frontmatter in ng mode
780        assert!(output.contains("number: 1"));
781        assert!(output.contains("title: Use Rust"));
782        assert!(output.contains("status: accepted"));
783    }
784
785    #[test]
786    fn test_render_nygard_ng_with_links() {
787        let template = Template::builtin(TemplateFormat::Nygard);
788        let mut adr = Adr::new(2, "Test");
789        adr.status = AdrStatus::Accepted;
790        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
791
792        let config = Config {
793            mode: ConfigMode::NextGen,
794            ..Default::default()
795        };
796        let output = template.render(&adr, &config).unwrap();
797
798        assert!(output.contains("links:"));
799        assert!(output.contains("target: 1"));
800    }
801
802    // ========== Template Rendering - MADR 4.0.0 ==========
803
804    #[test]
805    fn test_render_madr_basic() {
806        let template = Template::builtin(TemplateFormat::Madr);
807        let mut adr = Adr::new(1, "Use Rust");
808        adr.status = AdrStatus::Accepted;
809
810        let config = Config::default();
811        let output = template.render(&adr, &config).unwrap();
812
813        assert!(output.starts_with("---")); // MADR always has frontmatter
814        assert!(output.contains("status: accepted"));
815        assert!(output.contains("# Use Rust"));
816        assert!(output.contains("## Context and Problem Statement"));
817        assert!(output.contains("## Decision Drivers"));
818        assert!(output.contains("## Considered Options"));
819        assert!(output.contains("## Decision Outcome"));
820        assert!(output.contains("## Pros and Cons of the Options"));
821    }
822
823    #[test]
824    fn test_render_madr_with_decision_makers() {
825        let template = Template::builtin(TemplateFormat::Madr);
826        let mut adr = Adr::new(1, "Use Rust");
827        adr.status = AdrStatus::Accepted;
828        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
829
830        let config = Config::default();
831        let output = template.render(&adr, &config).unwrap();
832
833        assert!(output.contains("decision-makers:"));
834        assert!(output.contains("  - Alice"));
835        assert!(output.contains("  - Bob"));
836    }
837
838    #[test]
839    fn test_render_madr_with_consulted() {
840        let template = Template::builtin(TemplateFormat::Madr);
841        let mut adr = Adr::new(1, "Use Rust");
842        adr.status = AdrStatus::Accepted;
843        adr.consulted = vec!["Carol".into()];
844
845        let config = Config::default();
846        let output = template.render(&adr, &config).unwrap();
847
848        assert!(output.contains("consulted:"));
849        assert!(output.contains("  - Carol"));
850    }
851
852    #[test]
853    fn test_render_madr_with_informed() {
854        let template = Template::builtin(TemplateFormat::Madr);
855        let mut adr = Adr::new(1, "Use Rust");
856        adr.status = AdrStatus::Accepted;
857        adr.informed = vec!["Dave".into(), "Eve".into()];
858
859        let config = Config::default();
860        let output = template.render(&adr, &config).unwrap();
861
862        assert!(output.contains("informed:"));
863        assert!(output.contains("  - Dave"));
864        assert!(output.contains("  - Eve"));
865    }
866
867    #[test]
868    fn test_render_madr_full_frontmatter() {
869        let template = Template::builtin(TemplateFormat::Madr);
870        let mut adr = Adr::new(1, "Use MADR Format");
871        adr.status = AdrStatus::Accepted;
872        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
873        adr.consulted = vec!["Carol".into()];
874        adr.informed = vec!["Dave".into()];
875
876        let config = Config::default();
877        let output = template.render(&adr, &config).unwrap();
878
879        // Check frontmatter structure
880        assert!(output.starts_with("---\nstatus: accepted\ndate:"));
881        assert!(output.contains("decision-makers:\n  - Alice\n  - Bob"));
882        assert!(output.contains("consulted:\n  - Carol"));
883        assert!(output.contains("informed:\n  - Dave"));
884        assert!(output.contains("---\n\n# Use MADR Format"));
885    }
886
887    #[test]
888    fn test_render_madr_empty_optional_fields() {
889        let template = Template::builtin(TemplateFormat::Madr);
890        let mut adr = Adr::new(1, "Simple ADR");
891        adr.status = AdrStatus::Proposed;
892
893        let config = Config::default();
894        let output = template.render(&adr, &config).unwrap();
895
896        // Empty optional fields should not appear
897        assert!(!output.contains("decision-makers:"));
898        assert!(!output.contains("consulted:"));
899        assert!(!output.contains("informed:"));
900    }
901
902    // ========== Template Variants Tests ==========
903
904    #[test]
905    fn test_nygard_minimal_template() {
906        let template =
907            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
908        let adr = Adr::new(1, "Minimal Test");
909        let config = Config::default();
910        let output = template.render(&adr, &config).unwrap();
911
912        // Should have basic structure but no guidance text
913        assert!(output.contains("# 1. Minimal Test"));
914        assert!(output.contains("## Status"));
915        assert!(output.contains("## Context"));
916        assert!(output.contains("## Decision"));
917        assert!(output.contains("## Consequences"));
918        // Should NOT have guidance text
919        assert!(!output.contains("What is the issue"));
920    }
921
922    #[test]
923    fn test_nygard_bare_template() {
924        let template =
925            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Bare);
926        let adr = Adr::new(1, "Bare Test");
927        let config = Config::default();
928        let output = template.render(&adr, &config).unwrap();
929
930        // Should have basic structure
931        assert!(output.contains("# 1. Bare Test"));
932        assert!(output.contains("## Status"));
933        assert!(output.contains("## Context"));
934        // Bare template has no frontmatter
935        assert!(!output.contains("---"));
936    }
937
938    #[test]
939    fn test_madr_minimal_template() {
940        let template =
941            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Minimal);
942        let adr = Adr::new(1, "MADR Minimal");
943        let config = Config::default();
944        let output = template.render(&adr, &config).unwrap();
945
946        // MADR minimal has NO frontmatter (matches official adr-template-minimal.md)
947        assert!(!output.starts_with("---"));
948        assert!(output.contains("# MADR Minimal"));
949        assert!(output.contains("## Context and Problem Statement"));
950        assert!(output.contains("## Considered Options"));
951        assert!(output.contains("## Decision Outcome"));
952        // Should NOT have full MADR sections
953        assert!(!output.contains("## Decision Drivers"));
954        assert!(!output.contains("## Pros and Cons"));
955    }
956
957    #[test]
958    fn test_madr_bare_template() {
959        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
960        let adr = Adr::new(1, "MADR Bare");
961        let config = Config::default();
962        let output = template.render(&adr, &config).unwrap();
963
964        // MADR bare has frontmatter with empty fields (matches official adr-template-bare.md)
965        assert!(output.starts_with("---"));
966        assert!(output.contains("status:"));
967        assert!(output.contains("decision-makers:"));
968        assert!(output.contains("consulted:"));
969        assert!(output.contains("informed:"));
970        assert!(output.contains("# MADR Bare"));
971        // Should have ALL sections (empty)
972        assert!(output.contains("## Decision Drivers"));
973        assert!(output.contains("## Considered Options"));
974        assert!(output.contains("## Pros and Cons of the Options"));
975        assert!(output.contains("## More Information"));
976    }
977
978    #[test]
979    fn test_madr_bare_minimal_template() {
980        let template =
981            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::BareMinimal);
982        let adr = Adr::new(1, "MADR Bare Minimal");
983        let config = Config::default();
984        let output = template.render(&adr, &config).unwrap();
985
986        // MADR bare-minimal has NO frontmatter, minimal sections
987        assert!(!output.starts_with("---"));
988        assert!(output.contains("# MADR Bare Minimal"));
989        assert!(output.contains("## Context and Problem Statement"));
990        assert!(output.contains("## Considered Options"));
991        assert!(output.contains("## Decision Outcome"));
992        assert!(output.contains("### Consequences"));
993        // Should NOT have extended sections
994        assert!(!output.contains("## Decision Drivers"));
995        assert!(!output.contains("## Pros and Cons"));
996    }
997
998    #[test]
999    fn test_nygard_bare_minimal_template() {
1000        let template =
1001            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
1002        let adr = Adr::new(1, "Nygard Bare Minimal");
1003        let config = Config::default();
1004        let output = template.render(&adr, &config).unwrap();
1005
1006        // Should have basic structure without Date line
1007        assert!(output.contains("# 1. Nygard Bare Minimal"));
1008        assert!(output.contains("## Status"));
1009        assert!(output.contains("## Context"));
1010        assert!(output.contains("## Decision"));
1011        assert!(output.contains("## Consequences"));
1012        // No frontmatter, no date
1013        assert!(!output.contains("---"));
1014        assert!(!output.contains("Date:"));
1015    }
1016
1017    #[test]
1018    fn test_builtin_defaults_to_full() {
1019        let full = Template::builtin(TemplateFormat::Nygard);
1020        let explicit_full =
1021            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Full);
1022
1023        assert_eq!(full.name, explicit_full.name);
1024        assert_eq!(full.content, explicit_full.content);
1025    }
1026
1027    // ========== Template Engine Tests ==========
1028
1029    #[test]
1030    fn test_template_engine_new() {
1031        let engine = TemplateEngine::new();
1032        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1033        assert_eq!(engine.default_variant, TemplateVariant::Full);
1034        assert!(engine.custom_template.is_none());
1035    }
1036
1037    #[test]
1038    fn test_template_engine_default() {
1039        let engine = TemplateEngine::default();
1040        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1041        assert_eq!(engine.default_variant, TemplateVariant::Full);
1042    }
1043
1044    #[test]
1045    fn test_template_engine_with_format() {
1046        let engine = TemplateEngine::new().with_format(TemplateFormat::Madr);
1047        assert_eq!(engine.default_format, TemplateFormat::Madr);
1048    }
1049
1050    #[test]
1051    fn test_template_engine_with_custom_template() {
1052        let custom = Template::from_string("custom", "# {{ title }}");
1053        let engine = TemplateEngine::new().with_custom_template(custom);
1054        assert!(engine.custom_template.is_some());
1055    }
1056
1057    #[test]
1058    fn test_template_engine_with_custom_template_file() {
1059        let temp = TempDir::new().unwrap();
1060        let path = temp.path().join("template.md");
1061        std::fs::write(&path, "# {{ title }}").unwrap();
1062
1063        let engine = TemplateEngine::new()
1064            .with_custom_template_file(&path)
1065            .unwrap();
1066        assert!(engine.custom_template.is_some());
1067    }
1068
1069    #[test]
1070    fn test_template_engine_with_custom_template_file_not_found() {
1071        let result = TemplateEngine::new().with_custom_template_file(Path::new("/nonexistent.md"));
1072        assert!(result.is_err());
1073    }
1074
1075    #[test]
1076    fn test_template_engine_template_builtin() {
1077        let engine = TemplateEngine::new();
1078        let template = engine.template();
1079        assert_eq!(template.name, "nygard");
1080    }
1081
1082    #[test]
1083    fn test_template_engine_template_custom() {
1084        let custom = Template::from_string("my-template", "# Custom");
1085        let engine = TemplateEngine::new().with_custom_template(custom);
1086        let template = engine.template();
1087        assert_eq!(template.name, "my-template");
1088    }
1089
1090    #[test]
1091    fn test_template_engine_render() {
1092        let engine = TemplateEngine::new();
1093        let adr = Adr::new(1, "Test");
1094        let config = Config::default();
1095
1096        let output = engine.render(&adr, &config).unwrap();
1097        assert!(output.contains("# 1. Test"));
1098    }
1099
1100    #[test]
1101    fn test_template_engine_render_custom() {
1102        let custom = Template::from_string("custom", "ADR {{ number }}: {{ title }}");
1103        let engine = TemplateEngine::new().with_custom_template(custom);
1104        let adr = Adr::new(42, "Custom ADR");
1105        let config = Config::default();
1106
1107        let output = engine.render(&adr, &config).unwrap();
1108        assert_eq!(output, "ADR 42: Custom ADR");
1109    }
1110
1111    // ========== Custom Template Tests ==========
1112
1113    #[test]
1114    fn test_custom_template_all_fields() {
1115        let custom = Template::from_string(
1116            "full",
1117            r#"# {{ number }}. {{ title }}
1118Date: {{ date }}
1119Status: {{ status }}
1120Context: {{ context }}
1121Decision: {{ decision }}
1122Consequences: {{ consequences }}
1123Links: {% for link in links %}{{ link.kind }} {{ link.target }}{% endfor %}"#,
1124        );
1125
1126        let mut adr = Adr::new(1, "Test");
1127        adr.status = AdrStatus::Accepted;
1128        adr.context = "Context text".into();
1129        adr.decision = "Decision text".into();
1130        adr.consequences = "Consequences text".into();
1131        adr.links.push(AdrLink::new(2, LinkKind::Amends));
1132
1133        let config = Config::default();
1134        let output = custom.render(&adr, &config).unwrap();
1135
1136        assert!(output.contains("# 1. Test"));
1137        assert!(output.contains("Status: Accepted"));
1138        assert!(output.contains("Context: Context text"));
1139        assert!(output.contains("Decision: Decision text"));
1140        assert!(output.contains("Consequences: Consequences text"));
1141        assert!(output.contains("Amends 2"));
1142    }
1143
1144    #[test]
1145    fn test_custom_template_is_ng_flag() {
1146        let custom = Template::from_string(
1147            "ng-check",
1148            r#"{% if is_ng %}NextGen Mode{% else %}Compatible Mode{% endif %}"#,
1149        );
1150
1151        let adr = Adr::new(1, "Test");
1152
1153        let compat_config = Config::default();
1154        let output = custom.render(&adr, &compat_config).unwrap();
1155        assert_eq!(output, "Compatible Mode");
1156
1157        let ng_config = Config {
1158            mode: ConfigMode::NextGen,
1159            ..Default::default()
1160        };
1161        let output = custom.render(&adr, &ng_config).unwrap();
1162        assert_eq!(output, "NextGen Mode");
1163    }
1164
1165    #[test]
1166    fn test_custom_template_link_kinds() {
1167        let custom = Template::from_string(
1168            "links",
1169            r#"{% for link in links %}{{ link.kind }}|{% endfor %}"#,
1170        );
1171
1172        let mut adr = Adr::new(1, "Test");
1173        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1174        adr.links.push(AdrLink::new(2, LinkKind::SupersededBy));
1175        adr.links.push(AdrLink::new(3, LinkKind::Amends));
1176        adr.links.push(AdrLink::new(4, LinkKind::AmendedBy));
1177        adr.links.push(AdrLink::new(5, LinkKind::RelatesTo));
1178        adr.links
1179            .push(AdrLink::new(6, LinkKind::Custom("Depends on".into())));
1180
1181        let config = Config::default();
1182        let output = custom.render(&adr, &config).unwrap();
1183
1184        assert!(output.contains("Supersedes|"));
1185        assert!(output.contains("Superseded by|"));
1186        assert!(output.contains("Amends|"));
1187        assert!(output.contains("Amended by|"));
1188        assert!(output.contains("Relates to|"));
1189        assert!(output.contains("Depends on|"));
1190    }
1191
1192    // ========== Error Cases ==========
1193
1194    #[test]
1195    fn test_template_invalid_syntax() {
1196        let custom = Template::from_string("invalid", "{{ unclosed");
1197        let adr = Adr::new(1, "Test");
1198        let config = Config::default();
1199
1200        let result = custom.render(&adr, &config);
1201        assert!(result.is_err());
1202    }
1203
1204    #[test]
1205    fn test_template_undefined_variable() {
1206        let custom = Template::from_string("undefined", "{{ nonexistent }}");
1207        let adr = Adr::new(1, "Test");
1208        let config = Config::default();
1209
1210        // minijinja treats undefined as empty string by default
1211        let result = custom.render(&adr, &config);
1212        assert!(result.is_ok());
1213    }
1214
1215    // ========== Large Number Formatting ==========
1216
1217    #[test]
1218    fn test_render_four_digit_number() {
1219        let template = Template::builtin(TemplateFormat::Nygard);
1220        let adr = Adr::new(9999, "Large Number");
1221        let config = Config::default();
1222
1223        let output = template.render(&adr, &config).unwrap();
1224        assert!(output.contains("# 9999. Large Number"));
1225    }
1226
1227    #[test]
1228    fn test_render_link_number_formatting() {
1229        let template = Template::builtin(TemplateFormat::Nygard);
1230        let mut adr = Adr::new(2, "Test");
1231        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1232
1233        let config = Config::default();
1234        let output = template.render(&adr, &config).unwrap();
1235
1236        // Link should use 4-digit padding
1237        assert!(output.contains("0001-"));
1238    }
1239}