Skip to main content

adrs_core/
template.rs

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