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