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/// Zero-pad a number to a given width (default 4).
88///
89/// Used in templates as `{{ number | pad }}` or `{{ number | pad(width=6) }}`.
90fn pad_filter(
91    value: u32,
92    kwargs: minijinja::value::Kwargs,
93) -> std::result::Result<String, minijinja::Error> {
94    let width: Option<u32> = kwargs.get("width")?;
95    kwargs.assert_all_used()?;
96    let w = width.unwrap_or(4) as usize;
97    Ok(format!("{value:0>w$}"))
98}
99
100/// A template for generating ADRs.
101#[derive(Debug, Clone)]
102pub struct Template {
103    /// The template content.
104    content: String,
105
106    /// The template name (for error messages).
107    name: String,
108}
109
110impl Template {
111    /// Create a template from a string.
112    pub fn from_string(name: impl Into<String>, content: impl Into<String>) -> Self {
113        Self {
114            name: name.into(),
115            content: content.into(),
116        }
117    }
118
119    /// Get the template content.
120    pub fn content(&self) -> &str {
121        &self.content
122    }
123
124    /// Get the template name.
125    pub fn name(&self) -> &str {
126        &self.name
127    }
128
129    /// Load a template from a file.
130    pub fn from_file(path: &Path) -> Result<Self> {
131        let content = std::fs::read_to_string(path)?;
132        let name = path
133            .file_name()
134            .and_then(|n| n.to_str())
135            .unwrap_or("custom")
136            .to_string();
137        Ok(Self { name, content })
138    }
139
140    /// Get a built-in template by format (uses Full variant).
141    pub fn builtin(format: TemplateFormat) -> Self {
142        Self::builtin_with_variant(format, TemplateVariant::Full)
143    }
144
145    /// Get a built-in template by format and variant.
146    pub fn builtin_with_variant(format: TemplateFormat, variant: TemplateVariant) -> Self {
147        let (name, content) = match (format, variant) {
148            // Nygard templates
149            (TemplateFormat::Nygard, TemplateVariant::Full) => ("nygard", NYGARD_TEMPLATE),
150            (TemplateFormat::Nygard, TemplateVariant::Minimal) => {
151                ("nygard-minimal", NYGARD_MINIMAL_TEMPLATE)
152            }
153            (TemplateFormat::Nygard, TemplateVariant::Bare) => {
154                ("nygard-bare", NYGARD_BARE_TEMPLATE)
155            }
156            (TemplateFormat::Nygard, TemplateVariant::BareMinimal) => {
157                ("nygard-bare-minimal", NYGARD_BARE_MINIMAL_TEMPLATE)
158            }
159
160            // MADR templates
161            (TemplateFormat::Madr, TemplateVariant::Full) => ("madr", MADR_TEMPLATE),
162            (TemplateFormat::Madr, TemplateVariant::Minimal) => {
163                ("madr-minimal", MADR_MINIMAL_TEMPLATE)
164            }
165            (TemplateFormat::Madr, TemplateVariant::Bare) => ("madr-bare", MADR_BARE_TEMPLATE),
166            (TemplateFormat::Madr, TemplateVariant::BareMinimal) => {
167                ("madr-bare-minimal", MADR_BARE_MINIMAL_TEMPLATE)
168            }
169        };
170        Self::from_string(name, content)
171    }
172
173    /// Render the template with the given ADR data.
174    ///
175    /// `link_titles` maps link target ADR numbers to `(title, filename)` pairs
176    /// for generating functional markdown links.
177    pub fn render(
178        &self,
179        adr: &Adr,
180        config: &Config,
181        link_titles: &std::collections::HashMap<u32, (String, String)>,
182    ) -> Result<String> {
183        use crate::LinkKind;
184
185        let mut env = Environment::new();
186        env.add_filter("pad", pad_filter);
187        env.add_template(&self.name, &self.content)
188            .map_err(|e| Error::TemplateError(e.to_string()))?;
189
190        let template = env
191            .get_template(&self.name)
192            .map_err(|e| Error::TemplateError(e.to_string()))?;
193
194        // Convert links to a format with display-friendly kind and resolved titles
195        let links: Vec<_> = adr
196            .links
197            .iter()
198            .map(|link| {
199                let kind_display = match &link.kind {
200                    LinkKind::Supersedes => "Supersedes",
201                    LinkKind::SupersededBy => "Superseded by",
202                    LinkKind::Amends => "Amends",
203                    LinkKind::AmendedBy => "Amended by",
204                    LinkKind::RelatesTo => "Relates to",
205                    LinkKind::Custom(s) => s.as_str(),
206                };
207                let (target_title, target_filename) = link_titles
208                    .get(&link.target)
209                    .cloned()
210                    .unwrap_or_else(|| ("...".to_string(), format!("{:04}-....md", link.target)));
211                context! {
212                    target => link.target,
213                    kind => kind_display,
214                    description => &link.description,
215                    target_title => target_title,
216                    target_filename => target_filename,
217                }
218            })
219            .collect();
220
221        let output = template
222            .render(context! {
223                number => adr.number,
224                title => &adr.title,
225                date => crate::parse::format_date(adr.date),
226                status => adr.status.to_string(),
227                context => &adr.context,
228                decision => &adr.decision,
229                consequences => &adr.consequences,
230                links => links,
231                tags => &adr.tags,
232                is_ng => config.is_next_gen(),
233                // MADR 4.0.0 fields
234                decision_makers => &adr.decision_makers,
235                consulted => &adr.consulted,
236                informed => &adr.informed,
237            })
238            .map_err(|e| Error::TemplateError(e.to_string()))?;
239
240        // Normalize the ending to exactly one newline (#320). minijinja strips
241        // the template source's final newline, so without this the rendered
242        // ending depends on the template and on whether its last expression is
243        // empty: MADR full/minimal rendered with no trailing newline, Nygard
244        // minimal with two when the last section was empty. Normalizing here
245        // covers custom templates as well.
246        let mut output = output.trim_end().to_string();
247        output.push('\n');
248
249        Ok(output)
250    }
251}
252
253/// Template engine for managing and rendering templates.
254#[derive(Debug)]
255pub struct TemplateEngine {
256    /// The default template format.
257    default_format: TemplateFormat,
258
259    /// The default template variant.
260    default_variant: TemplateVariant,
261
262    /// Custom template path (overrides built-in).
263    custom_template: Option<Template>,
264}
265
266impl Default for TemplateEngine {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272impl TemplateEngine {
273    /// Create a new template engine.
274    pub fn new() -> Self {
275        Self {
276            default_format: TemplateFormat::default(),
277            default_variant: TemplateVariant::default(),
278            custom_template: None,
279        }
280    }
281
282    /// Set the default template format.
283    pub fn with_format(mut self, format: TemplateFormat) -> Self {
284        self.default_format = format;
285        self
286    }
287
288    /// Set the default template variant.
289    pub fn with_variant(mut self, variant: TemplateVariant) -> Self {
290        self.default_variant = variant;
291        self
292    }
293
294    /// Set a custom template.
295    pub fn with_custom_template(mut self, template: Template) -> Self {
296        self.custom_template = Some(template);
297        self
298    }
299
300    /// Load a custom template from a file.
301    pub fn with_custom_template_file(mut self, path: &Path) -> Result<Self> {
302        self.custom_template = Some(Template::from_file(path)?);
303        Ok(self)
304    }
305
306    /// Get the template to use for rendering.
307    pub fn template(&self) -> Template {
308        self.custom_template.clone().unwrap_or_else(|| {
309            Template::builtin_with_variant(self.default_format, self.default_variant)
310        })
311    }
312
313    /// Render an ADR using the configured template.
314    pub fn render(
315        &self,
316        adr: &Adr,
317        config: &Config,
318        link_titles: &std::collections::HashMap<u32, (String, String)>,
319    ) -> Result<String> {
320        self.template().render(adr, config, link_titles)
321    }
322}
323
324/// Nygard's original ADR template (compatible mode).
325const NYGARD_TEMPLATE: &str = r#"{% if is_ng %}---
326number: {{ number }}
327title: {{ title }}
328date: {{ date }}
329status: {{ status | lower }}
330{% if links %}links:
331{% for link in links %}  - target: {{ link.target }}
332    kind: {{ link.kind | lower }}
333{% endfor %}{% endif %}{% if tags %}tags:
334{% for tag in tags %}  - {{ tag }}
335{% endfor %}{% endif %}---
336
337{% endif %}# {{ number }}. {{ title }}
338
339Date: {{ date }}
340
341## Status
342
343{{ status }}
344{% for link in links %}
345{{ link.kind }} [{{ link.target }}. {{ link.target_title }}]({{ link.target_filename }})
346{% endfor %}
347## Context
348
349{{ context if context else "What is the issue that we're seeing that is motivating this decision or change?" }}
350
351## Decision
352
353{{ decision if decision else "What is the change that we're proposing and/or doing?" }}
354
355## Consequences
356
357{{ consequences if consequences else "What becomes easier or more difficult to do because of this change?" }}
358
359"#;
360
361/// MADR (Markdown Any Decision Records) 4.0.0 template.
362const MADR_TEMPLATE: &str = r#"---
363number: {{ number }}
364title: {{ title }}
365status: {{ status | lower }}
366date: {{ date }}
367{% if decision_makers %}decision-makers:
368{% for dm in decision_makers %}  - {{ dm }}
369{% endfor %}{% endif %}{% if consulted %}consulted:
370{% for c in consulted %}  - {{ c }}
371{% endfor %}{% endif %}{% if informed %}informed:
372{% for i in informed %}  - {{ i }}
373{% endfor %}{% endif %}{% if tags %}tags:
374{% for tag in tags %}  - {{ tag }}
375{% endfor %}{% endif %}---
376
377# {{ title }}
378
379## Context and Problem Statement
380
381{{ 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.}" }}
382
383<!-- This is an optional element. Feel free to remove. -->
384## Decision Drivers
385
386* {decision driver 1, e.g., a force, facing concern, ...}
387* {decision driver 2, e.g., a force, facing concern, ...}
388* ... <!-- numbers of drivers can vary -->
389
390## Considered Options
391
392* {title of option 1}
393* {title of option 2}
394* {title of option 3}
395* ... <!-- numbers of options can vary -->
396
397## Decision Outcome
398
399{{ 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)}." }}
400
401<!-- This is an optional element. Feel free to remove. -->
402### Consequences
403
404{{ 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 -->" }}
405
406<!-- This is an optional element. Feel free to remove. -->
407### Confirmation
408
409{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.}
410
411<!-- This is an optional element. Feel free to remove. -->
412## Pros and Cons of the Options
413
414### {title of option 1}
415
416<!-- This is an optional element. Feel free to remove. -->
417{example | description | pointer to more information | ...}
418
419* Good, because {argument a}
420* Good, because {argument b}
421<!-- use "neutral" if the given argument weights neither for good nor bad -->
422* Neutral, because {argument c}
423* Bad, because {argument d}
424* ... <!-- numbers of pros and cons can vary -->
425
426### {title of other option}
427
428{example | description | pointer to more information | ...}
429
430* Good, because {argument a}
431* Good, because {argument b}
432* Neutral, because {argument c}
433* Bad, because {argument d}
434* ...
435
436<!-- This is an optional element. Feel free to remove. -->
437## More Information
438
439{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.}
440"#;
441
442/// Nygard minimal template - essential sections only.
443const NYGARD_MINIMAL_TEMPLATE: &str = r#"{% if is_ng %}---
444number: {{ number }}
445title: {{ title }}
446date: {{ date }}
447status: {{ status | lower }}
448{% if links %}links:
449{% for link in links %}  - target: {{ link.target }}
450    kind: {{ link.kind | lower }}
451{% endfor %}{% endif %}{% if tags %}tags:
452{% for tag in tags %}  - {{ tag }}
453{% endfor %}{% endif %}---
454
455{% endif %}# {{ number }}. {{ title }}
456
457Date: {{ date }}
458
459## Status
460
461{{ status }}
462{% for link in links %}
463{{ link.kind }} [{{ link.target }}. {{ link.target_title }}]({{ link.target_filename }})
464{% endfor %}
465## Context
466
467{{ context if context else "" }}
468
469## Decision
470
471{{ decision if decision else "" }}
472
473## Consequences
474
475{{ consequences if consequences else "" }}
476"#;
477
478/// Nygard bare template - just the structure, no guidance.
479const NYGARD_BARE_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
480
481Date: {{ date }}
482
483## Status
484
485{{ status }}
486
487## Context
488
489
490
491## Decision
492
493
494
495## Consequences
496
497"#;
498
499/// Nygard bare-minimal template - fewest sections, empty content.
500const NYGARD_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ number }}. {{ title }}
501
502Date: {{ date }}
503
504## Status
505
506{{ status }}
507
508## Context
509
510
511
512## Decision
513
514
515
516## Consequences
517
518"#;
519
520/// MADR minimal template - core sections only, no frontmatter.
521/// Matches official MADR adr-template-minimal.md
522const MADR_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
523
524## Context and Problem Statement
525
526{{ 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.}" }}
527
528## Considered Options
529
530* {title of option 1}
531* {title of option 2}
532* {title of option 3}
533* ... <!-- numbers of options can vary -->
534
535## Decision Outcome
536
537{{ 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)}." }}
538
539<!-- This is an optional element. Feel free to remove. -->
540### Consequences
541
542{{ 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 -->" }}
543"#;
544
545/// MADR bare template - all sections with empty placeholders.
546/// Matches official MADR adr-template-bare.md
547const MADR_BARE_TEMPLATE: &str = r#"---
548number: {{ number }}
549title: {{ title }}
550status: {{ status | lower }}
551date: {{ date }}
552{% if decision_makers %}decision-makers:
553{% for dm in decision_makers %}  - {{ dm }}
554{% endfor %}{% endif %}{% if consulted %}consulted:
555{% for c in consulted %}  - {{ c }}
556{% endfor %}{% endif %}{% if informed %}informed:
557{% for i in informed %}  - {{ i }}
558{% endfor %}{% endif %}{% if tags %}tags:
559{% for tag in tags %}  - {{ tag }}
560{% endfor %}{% endif %}---
561
562# {{ title }}
563
564## Context and Problem Statement
565
566
567
568## Decision Drivers
569
570* <!-- decision driver -->
571
572## Considered Options
573
574* <!-- option -->
575
576## Decision Outcome
577
578Chosen option: "", because
579
580### Consequences
581
582* Good, because
583* Bad, because
584
585### Confirmation
586
587
588
589## Pros and Cons of the Options
590
591### <!-- title of option -->
592
593* Good, because
594* Neutral, because
595* Bad, because
596
597## More Information
598
599"#;
600
601/// MADR bare-minimal template - fewest sections, empty content.
602/// Matches official MADR adr-template-bare-minimal.md
603const MADR_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
604
605## Context and Problem Statement
606
607
608
609## Considered Options
610
611
612
613## Decision Outcome
614
615
616
617### Consequences
618
619"#;
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::{AdrLink, AdrStatus, ConfigMode, LinkKind};
625    use std::collections::HashMap;
626    use tempfile::TempDir;
627    use test_case::test_case;
628
629    fn no_link_titles() -> HashMap<u32, (String, String)> {
630        HashMap::new()
631    }
632
633    // ========== TemplateFormat Tests ==========
634
635    #[test]
636    fn test_template_format_default() {
637        assert_eq!(TemplateFormat::default(), TemplateFormat::Nygard);
638    }
639
640    #[test_case("nygard" => TemplateFormat::Nygard; "nygard")]
641    #[test_case("Nygard" => TemplateFormat::Nygard; "nygard capitalized")]
642    #[test_case("NYGARD" => TemplateFormat::Nygard; "nygard uppercase")]
643    #[test_case("default" => TemplateFormat::Nygard; "default alias")]
644    #[test_case("madr" => TemplateFormat::Madr; "madr")]
645    #[test_case("MADR" => TemplateFormat::Madr; "madr uppercase")]
646    fn test_template_format_parse(input: &str) -> TemplateFormat {
647        input.parse().unwrap()
648    }
649
650    #[test]
651    fn test_template_format_parse_unknown() {
652        let result: Result<TemplateFormat> = "unknown".parse();
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn test_template_format_display() {
658        assert_eq!(TemplateFormat::Nygard.to_string(), "nygard");
659        assert_eq!(TemplateFormat::Madr.to_string(), "madr");
660    }
661
662    // ========== TemplateVariant Tests ==========
663
664    #[test]
665    fn test_template_variant_default() {
666        assert_eq!(TemplateVariant::default(), TemplateVariant::Full);
667    }
668
669    #[test_case("full" => TemplateVariant::Full; "full")]
670    #[test_case("Full" => TemplateVariant::Full; "full capitalized")]
671    #[test_case("default" => TemplateVariant::Full; "default alias")]
672    #[test_case("minimal" => TemplateVariant::Minimal; "minimal")]
673    #[test_case("min" => TemplateVariant::Minimal; "min alias")]
674    #[test_case("bare" => TemplateVariant::Bare; "bare")]
675    #[test_case("bare-minimal" => TemplateVariant::BareMinimal; "bare-minimal")]
676    #[test_case("bareminimal" => TemplateVariant::BareMinimal; "bareminimal")]
677    #[test_case("empty" => TemplateVariant::BareMinimal; "empty alias")]
678    fn test_template_variant_parse(input: &str) -> TemplateVariant {
679        input.parse().unwrap()
680    }
681
682    #[test]
683    fn test_template_variant_parse_unknown() {
684        let result: Result<TemplateVariant> = "unknown".parse();
685        assert!(result.is_err());
686    }
687
688    #[test]
689    fn test_template_variant_display() {
690        assert_eq!(TemplateVariant::Full.to_string(), "full");
691        assert_eq!(TemplateVariant::Minimal.to_string(), "minimal");
692        assert_eq!(TemplateVariant::Bare.to_string(), "bare");
693        assert_eq!(TemplateVariant::BareMinimal.to_string(), "bare-minimal");
694    }
695
696    // ========== Template Creation Tests ==========
697
698    #[test]
699    fn test_template_from_string() {
700        let template = Template::from_string("test", "# {{ title }}");
701        assert_eq!(template.name, "test");
702        assert_eq!(template.content, "# {{ title }}");
703    }
704
705    #[test]
706    fn test_template_from_file() {
707        let temp = TempDir::new().unwrap();
708        let path = temp.path().join("custom.md");
709        std::fs::write(&path, "# {{ number }}. {{ title }}").unwrap();
710
711        let template = Template::from_file(&path).unwrap();
712        assert_eq!(template.name, "custom.md");
713        assert!(template.content.contains("{{ number }}"));
714    }
715
716    #[test]
717    fn test_template_from_file_not_found() {
718        let result = Template::from_file(Path::new("/nonexistent/template.md"));
719        assert!(result.is_err());
720    }
721
722    #[test]
723    fn test_template_builtin_nygard() {
724        let template = Template::builtin(TemplateFormat::Nygard);
725        assert_eq!(template.name, "nygard");
726        assert!(template.content.contains("## Status"));
727        assert!(template.content.contains("## Context"));
728        assert!(template.content.contains("## Decision"));
729        assert!(template.content.contains("## Consequences"));
730    }
731
732    #[test]
733    fn test_template_builtin_madr() {
734        let template = Template::builtin(TemplateFormat::Madr);
735        assert_eq!(template.name, "madr");
736        assert!(template.content.contains("Context and Problem Statement"));
737        assert!(template.content.contains("Decision Drivers"));
738        assert!(template.content.contains("Considered Options"));
739        assert!(template.content.contains("Decision Outcome"));
740    }
741
742    // ========== Template Rendering - Nygard Compatible Mode ==========
743
744    #[test]
745    fn test_render_nygard_compatible() {
746        let template = Template::builtin(TemplateFormat::Nygard);
747        let mut adr = Adr::new(1, "Use Rust");
748        adr.status = AdrStatus::Accepted;
749
750        let config = Config::default();
751        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
752
753        assert!(output.contains("# 1. Use Rust"));
754        assert!(output.contains("## Status"));
755        assert!(output.contains("Accepted"));
756        assert!(!output.starts_with("---")); // No frontmatter in compatible mode
757    }
758
759    #[test]
760    fn test_madr_bare_roundtrips_when_empty() {
761        // Regression for #264: the bare MADR template emitted null-valued
762        // metadata keys that the parser rejected, silently dropping the ADR.
763        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
764        let adr = Adr::new(2, "Bare MADR decision");
765        let config = Config::default();
766        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
767
768        // Empty metadata keys are omitted entirely, not emitted as null.
769        assert!(!output.contains("decision-makers:"));
770
771        // And the rendered file parses back cleanly.
772        let parsed = crate::Parser::new().parse(&output).unwrap();
773        assert_eq!(parsed.title, "Bare MADR decision");
774        assert!(parsed.decision_makers.is_empty());
775        assert!(parsed.tags.is_empty());
776    }
777
778    #[test]
779    fn test_madr_bare_renders_decision_makers() {
780        // The old bare template ignored decision-makers entirely; confirm they
781        // now render and round-trip (relevant to MCP create_adr with MADR).
782        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
783        let mut adr = Adr::new(2, "With deciders");
784        adr.decision_makers = vec!["alice".to_string(), "bob".to_string()];
785        let config = Config::default();
786        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
787
788        assert!(output.contains("decision-makers:"));
789        let parsed = crate::Parser::new().parse(&output).unwrap();
790        assert_eq!(parsed.decision_makers, vec!["alice", "bob"]);
791    }
792
793    #[test]
794    fn test_render_nygard_all_statuses() {
795        let template = Template::builtin(TemplateFormat::Nygard);
796        let config = Config::default();
797
798        for (status, expected_text) in [
799            (AdrStatus::Proposed, "Proposed"),
800            (AdrStatus::Accepted, "Accepted"),
801            (AdrStatus::Deprecated, "Deprecated"),
802            (AdrStatus::Superseded, "Superseded"),
803            (AdrStatus::Custom("Draft".into()), "Draft"),
804        ] {
805            let mut adr = Adr::new(1, "Test");
806            adr.status = status;
807
808            let output = template.render(&adr, &config, &no_link_titles()).unwrap();
809            assert!(
810                output.contains(expected_text),
811                "Output should contain '{expected_text}': {output}"
812            );
813        }
814    }
815
816    #[test]
817    fn test_render_nygard_with_content() {
818        let template = Template::builtin(TemplateFormat::Nygard);
819        let mut adr = Adr::new(1, "Use Rust");
820        adr.status = AdrStatus::Accepted;
821        adr.context = "We need a safe language.".to_string();
822        adr.decision = "We will use Rust.".to_string();
823        adr.consequences = "Better memory safety.".to_string();
824
825        let config = Config::default();
826        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
827
828        assert!(output.contains("We need a safe language."));
829        assert!(output.contains("We will use Rust."));
830        assert!(output.contains("Better memory safety."));
831    }
832
833    #[test]
834    fn test_render_nygard_with_links() {
835        let template = Template::builtin(TemplateFormat::Nygard);
836        let mut adr = Adr::new(2, "Use PostgreSQL");
837        adr.status = AdrStatus::Accepted;
838        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
839
840        let config = Config::default();
841        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
842
843        assert!(output.contains("Supersedes"));
844        assert!(output.contains("[1. ...]"));
845        assert!(output.contains("0001-....md"));
846    }
847
848    #[test]
849    fn test_render_nygard_with_multiple_links() {
850        let template = Template::builtin(TemplateFormat::Nygard);
851        let mut adr = Adr::new(5, "Combined Decision");
852        adr.status = AdrStatus::Accepted;
853        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
854        adr.links.push(AdrLink::new(2, LinkKind::Amends));
855        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
856
857        let config = Config::default();
858        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
859
860        assert!(output.contains("Supersedes"));
861        assert!(output.contains("Amends"));
862        assert!(output.contains("Superseded by"));
863    }
864
865    // ========== Resolved Link Titles (Issue #180) ==========
866
867    #[test]
868    fn test_render_nygard_with_resolved_link_titles() {
869        let template = Template::builtin(TemplateFormat::Nygard);
870        let mut adr = Adr::new(3, "Use PostgreSQL instead");
871        adr.status = AdrStatus::Accepted;
872        adr.links.push(AdrLink::new(2, LinkKind::Supersedes));
873
874        let mut link_titles = HashMap::new();
875        link_titles.insert(
876            2,
877            (
878                "Use MySQL for persistence".to_string(),
879                "0002-use-mysql-for-persistence.md".to_string(),
880            ),
881        );
882
883        let config = Config::default();
884        let output = template.render(&adr, &config, &link_titles).unwrap();
885
886        assert!(
887            output.contains(
888                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
889            ),
890            "Link should contain resolved title and filename. Got:\n{output}"
891        );
892    }
893
894    #[test]
895    fn test_render_nygard_with_resolved_superseded_by_link() {
896        let template = Template::builtin(TemplateFormat::Nygard);
897        let mut adr = Adr::new(2, "Use MySQL");
898        adr.status = AdrStatus::Superseded;
899        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
900
901        let mut link_titles = HashMap::new();
902        link_titles.insert(
903            3,
904            (
905                "Use PostgreSQL instead".to_string(),
906                "0003-use-postgresql-instead.md".to_string(),
907            ),
908        );
909
910        let config = Config::default();
911        let output = template.render(&adr, &config, &link_titles).unwrap();
912
913        assert!(
914            output.contains(
915                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
916            ),
917            "Superseded-by link should contain resolved title and filename. Got:\n{output}"
918        );
919    }
920
921    #[test]
922    fn test_render_nygard_with_multiple_resolved_links() {
923        let template = Template::builtin(TemplateFormat::Nygard);
924        let mut adr = Adr::new(5, "Combined Decision");
925        adr.status = AdrStatus::Accepted;
926        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
927        adr.links.push(AdrLink::new(2, LinkKind::Amends));
928
929        let mut link_titles = HashMap::new();
930        link_titles.insert(
931            1,
932            (
933                "Initial Decision".to_string(),
934                "0001-initial-decision.md".to_string(),
935            ),
936        );
937        link_titles.insert(
938            2,
939            (
940                "Second Decision".to_string(),
941                "0002-second-decision.md".to_string(),
942            ),
943        );
944
945        let config = Config::default();
946        let output = template.render(&adr, &config, &link_titles).unwrap();
947
948        assert!(
949            output.contains("Supersedes [1. Initial Decision](0001-initial-decision.md)"),
950            "First link should be resolved. Got:\n{output}"
951        );
952        assert!(
953            output.contains("Amends [2. Second Decision](0002-second-decision.md)"),
954            "Second link should be resolved. Got:\n{output}"
955        );
956    }
957
958    #[test]
959    fn test_render_nygard_unresolved_link_falls_back() {
960        let template = Template::builtin(TemplateFormat::Nygard);
961        let mut adr = Adr::new(2, "Test");
962        adr.status = AdrStatus::Accepted;
963        adr.links.push(AdrLink::new(99, LinkKind::Supersedes));
964
965        let config = Config::default();
966        // Empty link_titles = no resolution available
967        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
968
969        assert!(
970            output.contains("Supersedes [99. ...](0099-....md)"),
971            "Unresolved link should fall back to '...' placeholder. Got:\n{output}"
972        );
973    }
974
975    #[test]
976    fn test_render_nygard_minimal_with_resolved_links() {
977        let template =
978            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
979        let mut adr = Adr::new(2, "New Approach");
980        adr.status = AdrStatus::Accepted;
981        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
982
983        let mut link_titles = HashMap::new();
984        link_titles.insert(
985            1,
986            (
987                "Old Approach".to_string(),
988                "0001-old-approach.md".to_string(),
989            ),
990        );
991
992        let config = Config::default();
993        let output = template.render(&adr, &config, &link_titles).unwrap();
994
995        assert!(
996            output.contains("Supersedes [1. Old Approach](0001-old-approach.md)"),
997            "Minimal template should also resolve link titles. Got:\n{output}"
998        );
999    }
1000
1001    #[test]
1002    fn test_render_nygard_ng_with_resolved_links() {
1003        let template = Template::builtin(TemplateFormat::Nygard);
1004        let mut adr = Adr::new(2, "New Approach");
1005        adr.status = AdrStatus::Accepted;
1006        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1007
1008        let mut link_titles = HashMap::new();
1009        link_titles.insert(
1010            1,
1011            (
1012                "Old Approach".to_string(),
1013                "0001-old-approach.md".to_string(),
1014            ),
1015        );
1016
1017        let config = Config {
1018            mode: ConfigMode::NextGen,
1019            ..Default::default()
1020        };
1021        let output = template.render(&adr, &config, &link_titles).unwrap();
1022
1023        // Body should have resolved link
1024        assert!(
1025            output.contains("Supersedes [1. Old Approach](0001-old-approach.md)"),
1026            "NG mode body should have resolved links. Got:\n{output}"
1027        );
1028        // Frontmatter should still have structured link data
1029        assert!(output.contains("links:"));
1030        assert!(output.contains("target: 1"));
1031    }
1032
1033    // ========== Template Rendering - Nygard NextGen Mode ==========
1034
1035    #[test]
1036    fn test_render_nygard_ng() {
1037        let template = Template::builtin(TemplateFormat::Nygard);
1038        let mut adr = Adr::new(1, "Use Rust");
1039        adr.status = AdrStatus::Accepted;
1040
1041        let config = Config {
1042            mode: ConfigMode::NextGen,
1043            ..Default::default()
1044        };
1045        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1046
1047        assert!(output.starts_with("---")); // Has frontmatter in ng mode
1048        assert!(output.contains("number: 1"));
1049        assert!(output.contains("title: Use Rust"));
1050        assert!(output.contains("status: accepted"));
1051    }
1052
1053    #[test]
1054    fn test_render_nygard_ng_with_links() {
1055        let template = Template::builtin(TemplateFormat::Nygard);
1056        let mut adr = Adr::new(2, "Test");
1057        adr.status = AdrStatus::Accepted;
1058        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1059
1060        let config = Config {
1061            mode: ConfigMode::NextGen,
1062            ..Default::default()
1063        };
1064        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1065
1066        assert!(output.contains("links:"));
1067        assert!(output.contains("target: 1"));
1068    }
1069
1070    // ========== Template Rendering - MADR 4.0.0 ==========
1071
1072    #[test]
1073    fn test_render_madr_basic() {
1074        let template = Template::builtin(TemplateFormat::Madr);
1075        let mut adr = Adr::new(1, "Use Rust");
1076        adr.status = AdrStatus::Accepted;
1077
1078        let config = Config::default();
1079        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1080
1081        assert!(output.starts_with("---")); // MADR always has frontmatter
1082        assert!(output.contains("status: accepted"));
1083        assert!(output.contains("# Use Rust"));
1084        assert!(output.contains("## Context and Problem Statement"));
1085        assert!(output.contains("## Decision Drivers"));
1086        assert!(output.contains("## Considered Options"));
1087        assert!(output.contains("## Decision Outcome"));
1088        assert!(output.contains("## Pros and Cons of the Options"));
1089    }
1090
1091    #[test]
1092    fn test_render_madr_with_decision_makers() {
1093        let template = Template::builtin(TemplateFormat::Madr);
1094        let mut adr = Adr::new(1, "Use Rust");
1095        adr.status = AdrStatus::Accepted;
1096        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
1097
1098        let config = Config::default();
1099        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1100
1101        assert!(output.contains("decision-makers:"));
1102        assert!(output.contains("  - Alice"));
1103        assert!(output.contains("  - Bob"));
1104    }
1105
1106    #[test]
1107    fn test_render_madr_with_consulted() {
1108        let template = Template::builtin(TemplateFormat::Madr);
1109        let mut adr = Adr::new(1, "Use Rust");
1110        adr.status = AdrStatus::Accepted;
1111        adr.consulted = vec!["Carol".into()];
1112
1113        let config = Config::default();
1114        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1115
1116        assert!(output.contains("consulted:"));
1117        assert!(output.contains("  - Carol"));
1118    }
1119
1120    #[test]
1121    fn test_render_madr_with_informed() {
1122        let template = Template::builtin(TemplateFormat::Madr);
1123        let mut adr = Adr::new(1, "Use Rust");
1124        adr.status = AdrStatus::Accepted;
1125        adr.informed = vec!["Dave".into(), "Eve".into()];
1126
1127        let config = Config::default();
1128        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1129
1130        assert!(output.contains("informed:"));
1131        assert!(output.contains("  - Dave"));
1132        assert!(output.contains("  - Eve"));
1133    }
1134
1135    #[test]
1136    fn test_render_madr_full_frontmatter() {
1137        let template = Template::builtin(TemplateFormat::Madr);
1138        let mut adr = Adr::new(1, "Use MADR Format");
1139        adr.status = AdrStatus::Accepted;
1140        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
1141        adr.consulted = vec!["Carol".into()];
1142        adr.informed = vec!["Dave".into()];
1143
1144        let config = Config::default();
1145        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1146
1147        // Check frontmatter structure - now includes number and title
1148        assert!(
1149            output.starts_with("---\nnumber: 1\ntitle: Use MADR Format\nstatus: accepted\ndate:")
1150        );
1151        assert!(output.contains("decision-makers:\n  - Alice\n  - Bob"));
1152        assert!(output.contains("consulted:\n  - Carol"));
1153        assert!(output.contains("informed:\n  - Dave"));
1154        assert!(output.contains("---\n\n# Use MADR Format"));
1155    }
1156
1157    #[test]
1158    fn test_render_madr_empty_optional_fields() {
1159        let template = Template::builtin(TemplateFormat::Madr);
1160        let mut adr = Adr::new(1, "Simple ADR");
1161        adr.status = AdrStatus::Proposed;
1162
1163        let config = Config::default();
1164        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1165
1166        // Empty optional fields should not appear
1167        assert!(!output.contains("decision-makers:"));
1168        assert!(!output.contains("consulted:"));
1169        assert!(!output.contains("informed:"));
1170    }
1171
1172    // ========== Template Variants Tests ==========
1173
1174    #[test]
1175    fn test_nygard_minimal_template() {
1176        let template =
1177            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
1178        let adr = Adr::new(1, "Minimal Test");
1179        let config = Config::default();
1180        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1181
1182        // Should have basic structure but no guidance text
1183        assert!(output.contains("# 1. Minimal Test"));
1184        assert!(output.contains("## Status"));
1185        assert!(output.contains("## Context"));
1186        assert!(output.contains("## Decision"));
1187        assert!(output.contains("## Consequences"));
1188        // Should NOT have guidance text
1189        assert!(!output.contains("What is the issue"));
1190    }
1191
1192    #[test]
1193    fn test_nygard_bare_template() {
1194        let template =
1195            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Bare);
1196        let adr = Adr::new(1, "Bare Test");
1197        let config = Config::default();
1198        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1199
1200        // Should have basic structure
1201        assert!(output.contains("# 1. Bare Test"));
1202        assert!(output.contains("## Status"));
1203        assert!(output.contains("## Context"));
1204        // Bare template has no frontmatter
1205        assert!(!output.contains("---"));
1206    }
1207
1208    #[test]
1209    fn test_madr_minimal_template() {
1210        let template =
1211            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Minimal);
1212        let adr = Adr::new(1, "MADR Minimal");
1213        let config = Config::default();
1214        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1215
1216        // MADR minimal has NO frontmatter (matches official adr-template-minimal.md)
1217        assert!(!output.starts_with("---"));
1218        assert!(output.contains("# MADR Minimal"));
1219        assert!(output.contains("## Context and Problem Statement"));
1220        assert!(output.contains("## Considered Options"));
1221        assert!(output.contains("## Decision Outcome"));
1222        // Should NOT have full MADR sections
1223        assert!(!output.contains("## Decision Drivers"));
1224        assert!(!output.contains("## Pros and Cons"));
1225    }
1226
1227    #[test]
1228    fn test_madr_bare_template() {
1229        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
1230        let adr = Adr::new(1, "MADR Bare");
1231        let config = Config::default();
1232        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1233
1234        // MADR bare has frontmatter; empty metadata keys are omitted rather
1235        // than emitted as null YAML (which the parser rejects -- see #264).
1236        assert!(output.starts_with("---"));
1237        assert!(output.contains("status:"));
1238        assert!(!output.contains("decision-makers:"));
1239        assert!(!output.contains("consulted:"));
1240        assert!(!output.contains("informed:"));
1241        assert!(output.contains("# MADR Bare"));
1242        // Should have ALL sections (empty)
1243        assert!(output.contains("## Decision Drivers"));
1244        assert!(output.contains("## Considered Options"));
1245        assert!(output.contains("## Pros and Cons of the Options"));
1246        assert!(output.contains("## More Information"));
1247    }
1248
1249    #[test]
1250    fn test_madr_bare_minimal_template() {
1251        let template =
1252            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::BareMinimal);
1253        let adr = Adr::new(1, "MADR Bare Minimal");
1254        let config = Config::default();
1255        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1256
1257        // MADR bare-minimal has NO frontmatter, minimal sections
1258        assert!(!output.starts_with("---"));
1259        assert!(output.contains("# MADR Bare Minimal"));
1260        assert!(output.contains("## Context and Problem Statement"));
1261        assert!(output.contains("## Considered Options"));
1262        assert!(output.contains("## Decision Outcome"));
1263        assert!(output.contains("### Consequences"));
1264        // Should NOT have extended sections
1265        assert!(!output.contains("## Decision Drivers"));
1266        assert!(!output.contains("## Pros and Cons"));
1267    }
1268
1269    #[test]
1270    fn test_nygard_bare_minimal_template() {
1271        let template =
1272            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
1273        let adr = Adr::new(1, "Nygard Bare Minimal");
1274        let config = Config::default();
1275        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1276
1277        // Should have basic structure including the Date line (#330: without
1278        // it, `adrs doctor` flags the file with ADR003).
1279        assert!(output.contains("# 1. Nygard Bare Minimal"));
1280        assert!(output.contains("Date:"));
1281        assert!(output.contains("## Status"));
1282        assert!(output.contains("## Context"));
1283        assert!(output.contains("## Decision"));
1284        assert!(output.contains("## Consequences"));
1285        // No frontmatter (compatible-mode layout).
1286        assert!(!output.contains("---"));
1287    }
1288
1289    #[test]
1290    fn test_builtin_defaults_to_full() {
1291        let full = Template::builtin(TemplateFormat::Nygard);
1292        let explicit_full =
1293            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Full);
1294
1295        assert_eq!(full.name, explicit_full.name);
1296        assert_eq!(full.content, explicit_full.content);
1297    }
1298
1299    #[test]
1300    fn test_every_builtin_render_ends_with_exactly_one_newline() {
1301        // Regression for #320: rendered output must end with exactly one
1302        // newline for every builtin, with both empty and populated body
1303        // fields (minijinja's trailing-newline strip made the ending depend
1304        // on the template and on whether the final expression was empty).
1305        let formats = [TemplateFormat::Nygard, TemplateFormat::Madr];
1306        let variants = [
1307            TemplateVariant::Full,
1308            TemplateVariant::Minimal,
1309            TemplateVariant::Bare,
1310            TemplateVariant::BareMinimal,
1311        ];
1312        let empty = Adr::new(1, "Empty fields");
1313        let mut populated = Adr::new(2, "Populated fields");
1314        populated.context = "Some context.".into();
1315        populated.decision = "Some decision.".into();
1316        populated.consequences = "Some consequences.".into();
1317
1318        for format in formats {
1319            for variant in variants {
1320                let template = Template::builtin_with_variant(format, variant);
1321                for adr in [&empty, &populated] {
1322                    let output = template
1323                        .render(adr, &Config::default(), &no_link_titles())
1324                        .unwrap();
1325                    assert!(
1326                        output.ends_with('\n') && !output.ends_with("\n\n"),
1327                        "{format}-{variant} (adr {}) must end with exactly one newline, got tail {:?}",
1328                        adr.number,
1329                        &output[output.len().saturating_sub(12)..]
1330                    );
1331                }
1332            }
1333        }
1334    }
1335
1336    // ========== Template Engine Tests ==========
1337
1338    #[test]
1339    fn test_template_engine_new() {
1340        let engine = TemplateEngine::new();
1341        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1342        assert_eq!(engine.default_variant, TemplateVariant::Full);
1343        assert!(engine.custom_template.is_none());
1344    }
1345
1346    #[test]
1347    fn test_template_engine_default() {
1348        let engine = TemplateEngine::default();
1349        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1350        assert_eq!(engine.default_variant, TemplateVariant::Full);
1351    }
1352
1353    #[test]
1354    fn test_template_engine_with_format() {
1355        let engine = TemplateEngine::new().with_format(TemplateFormat::Madr);
1356        assert_eq!(engine.default_format, TemplateFormat::Madr);
1357    }
1358
1359    #[test]
1360    fn test_template_engine_with_custom_template() {
1361        let custom = Template::from_string("custom", "# {{ title }}");
1362        let engine = TemplateEngine::new().with_custom_template(custom);
1363        assert!(engine.custom_template.is_some());
1364    }
1365
1366    #[test]
1367    fn test_template_engine_with_custom_template_file() {
1368        let temp = TempDir::new().unwrap();
1369        let path = temp.path().join("template.md");
1370        std::fs::write(&path, "# {{ title }}").unwrap();
1371
1372        let engine = TemplateEngine::new()
1373            .with_custom_template_file(&path)
1374            .unwrap();
1375        assert!(engine.custom_template.is_some());
1376    }
1377
1378    #[test]
1379    fn test_template_engine_with_custom_template_file_not_found() {
1380        let result = TemplateEngine::new().with_custom_template_file(Path::new("/nonexistent.md"));
1381        assert!(result.is_err());
1382    }
1383
1384    #[test]
1385    fn test_template_engine_template_builtin() {
1386        let engine = TemplateEngine::new();
1387        let template = engine.template();
1388        assert_eq!(template.name, "nygard");
1389    }
1390
1391    #[test]
1392    fn test_template_engine_template_custom() {
1393        let custom = Template::from_string("my-template", "# Custom");
1394        let engine = TemplateEngine::new().with_custom_template(custom);
1395        let template = engine.template();
1396        assert_eq!(template.name, "my-template");
1397    }
1398
1399    #[test]
1400    fn test_template_engine_render() {
1401        let engine = TemplateEngine::new();
1402        let adr = Adr::new(1, "Test");
1403        let config = Config::default();
1404
1405        let output = engine.render(&adr, &config, &no_link_titles()).unwrap();
1406        assert!(output.contains("# 1. Test"));
1407    }
1408
1409    #[test]
1410    fn test_template_engine_render_custom() {
1411        let custom = Template::from_string("custom", "ADR {{ number }}: {{ title }}");
1412        let engine = TemplateEngine::new().with_custom_template(custom);
1413        let adr = Adr::new(42, "Custom ADR");
1414        let config = Config::default();
1415
1416        let output = engine.render(&adr, &config, &no_link_titles()).unwrap();
1417        assert_eq!(output, "ADR 42: Custom ADR\n");
1418    }
1419
1420    // ========== Custom Template Tests ==========
1421
1422    #[test]
1423    fn test_custom_template_all_fields() {
1424        let custom = Template::from_string(
1425            "full",
1426            r#"# {{ number }}. {{ title }}
1427Date: {{ date }}
1428Status: {{ status }}
1429Context: {{ context }}
1430Decision: {{ decision }}
1431Consequences: {{ consequences }}
1432Links: {% for link in links %}{{ link.kind }} {{ link.target }}{% endfor %}"#,
1433        );
1434
1435        let mut adr = Adr::new(1, "Test");
1436        adr.status = AdrStatus::Accepted;
1437        adr.context = "Context text".into();
1438        adr.decision = "Decision text".into();
1439        adr.consequences = "Consequences text".into();
1440        adr.links.push(AdrLink::new(2, LinkKind::Amends));
1441
1442        let config = Config::default();
1443        let output = custom.render(&adr, &config, &no_link_titles()).unwrap();
1444
1445        assert!(output.contains("# 1. Test"));
1446        assert!(output.contains("Status: Accepted"));
1447        assert!(output.contains("Context: Context text"));
1448        assert!(output.contains("Decision: Decision text"));
1449        assert!(output.contains("Consequences: Consequences text"));
1450        assert!(output.contains("Amends 2"));
1451    }
1452
1453    #[test]
1454    fn test_custom_template_is_ng_flag() {
1455        let custom = Template::from_string(
1456            "ng-check",
1457            r#"{% if is_ng %}NextGen Mode{% else %}Compatible Mode{% endif %}"#,
1458        );
1459
1460        let adr = Adr::new(1, "Test");
1461
1462        let compat_config = Config::default();
1463        let output = custom
1464            .render(&adr, &compat_config, &no_link_titles())
1465            .unwrap();
1466        assert_eq!(output, "Compatible Mode\n");
1467
1468        let ng_config = Config {
1469            mode: ConfigMode::NextGen,
1470            ..Default::default()
1471        };
1472        let output = custom.render(&adr, &ng_config, &no_link_titles()).unwrap();
1473        assert_eq!(output, "NextGen Mode\n");
1474    }
1475
1476    #[test]
1477    fn test_custom_template_link_kinds() {
1478        let custom = Template::from_string(
1479            "links",
1480            r#"{% for link in links %}{{ link.kind }}|{% endfor %}"#,
1481        );
1482
1483        let mut adr = Adr::new(1, "Test");
1484        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1485        adr.links.push(AdrLink::new(2, LinkKind::SupersededBy));
1486        adr.links.push(AdrLink::new(3, LinkKind::Amends));
1487        adr.links.push(AdrLink::new(4, LinkKind::AmendedBy));
1488        adr.links.push(AdrLink::new(5, LinkKind::RelatesTo));
1489        adr.links
1490            .push(AdrLink::new(6, LinkKind::Custom("Depends on".into())));
1491
1492        let config = Config::default();
1493        let output = custom.render(&adr, &config, &no_link_titles()).unwrap();
1494
1495        assert!(output.contains("Supersedes|"));
1496        assert!(output.contains("Superseded by|"));
1497        assert!(output.contains("Amends|"));
1498        assert!(output.contains("Amended by|"));
1499        assert!(output.contains("Relates to|"));
1500        assert!(output.contains("Depends on|"));
1501    }
1502
1503    // ========== Error Cases ==========
1504
1505    #[test]
1506    fn test_template_invalid_syntax() {
1507        let custom = Template::from_string("invalid", "{{ unclosed");
1508        let adr = Adr::new(1, "Test");
1509        let config = Config::default();
1510
1511        let result = custom.render(&adr, &config, &no_link_titles());
1512        assert!(result.is_err());
1513    }
1514
1515    #[test]
1516    fn test_template_undefined_variable() {
1517        let custom = Template::from_string("undefined", "{{ nonexistent }}");
1518        let adr = Adr::new(1, "Test");
1519        let config = Config::default();
1520
1521        // minijinja treats undefined as empty string by default
1522        let result = custom.render(&adr, &config, &no_link_titles());
1523        assert!(result.is_ok());
1524    }
1525
1526    // ========== Large Number Formatting ==========
1527
1528    #[test]
1529    fn test_render_four_digit_number() {
1530        let template = Template::builtin(TemplateFormat::Nygard);
1531        let adr = Adr::new(9999, "Large Number");
1532        let config = Config::default();
1533
1534        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1535        assert!(output.contains("# 9999. Large Number"));
1536    }
1537
1538    #[test]
1539    fn test_render_link_number_formatting() {
1540        let template = Template::builtin(TemplateFormat::Nygard);
1541        let mut adr = Adr::new(2, "Test");
1542        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1543
1544        let config = Config::default();
1545        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1546
1547        // Link should use 4-digit padding
1548        assert!(output.contains("0001-"));
1549    }
1550
1551    // ========== Tags Rendering ==========
1552
1553    #[test]
1554    fn test_render_tags_in_nextgen_mode() {
1555        let template = Template::builtin(TemplateFormat::Nygard);
1556        let mut adr = Adr::new(1, "Test ADR");
1557        adr.tags = vec!["database".to_string(), "infrastructure".to_string()];
1558
1559        let config = Config {
1560            mode: ConfigMode::NextGen,
1561            ..Default::default()
1562        };
1563        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1564
1565        // Tags should appear in YAML frontmatter
1566        assert!(output.contains("tags:"));
1567        assert!(output.contains("- database"));
1568        assert!(output.contains("- infrastructure"));
1569    }
1570
1571    #[test]
1572    fn test_render_tags_in_madr_format() {
1573        let template = Template::builtin(TemplateFormat::Madr);
1574        let mut adr = Adr::new(1, "Test ADR");
1575        adr.tags = vec!["api".to_string(), "security".to_string()];
1576
1577        let config = Config::default();
1578        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1579
1580        // Tags should appear in YAML frontmatter
1581        assert!(output.contains("tags:"));
1582        assert!(output.contains("- api"));
1583        assert!(output.contains("- security"));
1584    }
1585
1586    #[test]
1587    fn test_render_no_tags_section_when_empty() {
1588        let template = Template::builtin(TemplateFormat::Nygard);
1589        let adr = Adr::new(1, "Test ADR");
1590
1591        let config = Config {
1592            mode: ConfigMode::NextGen,
1593            ..Default::default()
1594        };
1595        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1596
1597        // No tags section when tags are empty
1598        assert!(!output.contains("tags:"));
1599    }
1600
1601    // ========== Pad Filter Tests (#185) ==========
1602
1603    #[test]
1604    fn test_pad_filter_default_width() {
1605        let template = Template::from_string("test", "{{ number | pad }}");
1606        let adr = Adr::new(1, "Test");
1607        let config = Config::default();
1608        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1609
1610        assert_eq!(output, "0001\n");
1611    }
1612
1613    #[test]
1614    fn test_pad_filter_custom_width() {
1615        let template = Template::from_string("test", "{{ number | pad(width=6) }}");
1616        let adr = Adr::new(1, "Test");
1617        let config = Config::default();
1618        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1619
1620        assert_eq!(output, "000001\n");
1621    }
1622    // ========== Template accessor tests (issue #235) ==========
1623
1624    #[test]
1625    fn test_template_content_accessor() {
1626        let template = Template::from_string("test", "# {{ title }}");
1627        assert_eq!(template.content(), "# {{ title }}");
1628    }
1629
1630    #[test]
1631    fn test_template_name_accessor() {
1632        let template = Template::from_string("my-template", "# {{ title }}");
1633        assert_eq!(template.name(), "my-template");
1634    }
1635
1636    // ========== TemplateEngine::with_variant tests (issue #235) ==========
1637
1638    #[test]
1639    fn test_template_engine_with_variant() {
1640        let engine = TemplateEngine::new().with_variant(TemplateVariant::Minimal);
1641        assert_eq!(engine.default_variant, TemplateVariant::Minimal);
1642    }
1643
1644    #[test]
1645    fn test_template_engine_with_variant_bare() {
1646        let engine = TemplateEngine::new().with_variant(TemplateVariant::Bare);
1647        assert_eq!(engine.default_variant, TemplateVariant::Bare);
1648        let template = engine.template();
1649        assert_eq!(template.name(), "nygard-bare");
1650    }
1651
1652    #[test]
1653    fn test_template_engine_with_format_and_variant() {
1654        let engine = TemplateEngine::new()
1655            .with_format(TemplateFormat::Madr)
1656            .with_variant(TemplateVariant::Minimal);
1657        assert_eq!(engine.default_format, TemplateFormat::Madr);
1658        assert_eq!(engine.default_variant, TemplateVariant::Minimal);
1659        let template = engine.template();
1660        assert_eq!(template.name(), "madr-minimal");
1661    }
1662}