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 }}
540decision-makers:
541consulted:
542informed:
543{% if tags %}tags:
544{% for tag in tags %}  - {{ tag }}
545{% endfor %}{% else %}tags:
546{% endif %}---
547
548# {{ title }}
549
550## Context and Problem Statement
551
552
553
554## Decision Drivers
555
556* <!-- decision driver -->
557
558## Considered Options
559
560* <!-- option -->
561
562## Decision Outcome
563
564Chosen option: "", because
565
566### Consequences
567
568* Good, because
569* Bad, because
570
571### Confirmation
572
573
574
575## Pros and Cons of the Options
576
577### <!-- title of option -->
578
579* Good, because
580* Neutral, because
581* Bad, because
582
583## More Information
584
585"#;
586
587/// MADR bare-minimal template - fewest sections, empty content.
588/// Matches official MADR adr-template-bare-minimal.md
589const MADR_BARE_MINIMAL_TEMPLATE: &str = r#"# {{ title }}
590
591## Context and Problem Statement
592
593
594
595## Considered Options
596
597
598
599## Decision Outcome
600
601
602
603### Consequences
604
605"#;
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::{AdrLink, AdrStatus, ConfigMode, LinkKind};
611    use std::collections::HashMap;
612    use tempfile::TempDir;
613    use test_case::test_case;
614
615    fn no_link_titles() -> HashMap<u32, (String, String)> {
616        HashMap::new()
617    }
618
619    // ========== TemplateFormat Tests ==========
620
621    #[test]
622    fn test_template_format_default() {
623        assert_eq!(TemplateFormat::default(), TemplateFormat::Nygard);
624    }
625
626    #[test_case("nygard" => TemplateFormat::Nygard; "nygard")]
627    #[test_case("Nygard" => TemplateFormat::Nygard; "nygard capitalized")]
628    #[test_case("NYGARD" => TemplateFormat::Nygard; "nygard uppercase")]
629    #[test_case("default" => TemplateFormat::Nygard; "default alias")]
630    #[test_case("madr" => TemplateFormat::Madr; "madr")]
631    #[test_case("MADR" => TemplateFormat::Madr; "madr uppercase")]
632    fn test_template_format_parse(input: &str) -> TemplateFormat {
633        input.parse().unwrap()
634    }
635
636    #[test]
637    fn test_template_format_parse_unknown() {
638        let result: Result<TemplateFormat> = "unknown".parse();
639        assert!(result.is_err());
640    }
641
642    #[test]
643    fn test_template_format_display() {
644        assert_eq!(TemplateFormat::Nygard.to_string(), "nygard");
645        assert_eq!(TemplateFormat::Madr.to_string(), "madr");
646    }
647
648    // ========== TemplateVariant Tests ==========
649
650    #[test]
651    fn test_template_variant_default() {
652        assert_eq!(TemplateVariant::default(), TemplateVariant::Full);
653    }
654
655    #[test_case("full" => TemplateVariant::Full; "full")]
656    #[test_case("Full" => TemplateVariant::Full; "full capitalized")]
657    #[test_case("default" => TemplateVariant::Full; "default alias")]
658    #[test_case("minimal" => TemplateVariant::Minimal; "minimal")]
659    #[test_case("min" => TemplateVariant::Minimal; "min alias")]
660    #[test_case("bare" => TemplateVariant::Bare; "bare")]
661    #[test_case("bare-minimal" => TemplateVariant::BareMinimal; "bare-minimal")]
662    #[test_case("bareminimal" => TemplateVariant::BareMinimal; "bareminimal")]
663    #[test_case("empty" => TemplateVariant::BareMinimal; "empty alias")]
664    fn test_template_variant_parse(input: &str) -> TemplateVariant {
665        input.parse().unwrap()
666    }
667
668    #[test]
669    fn test_template_variant_parse_unknown() {
670        let result: Result<TemplateVariant> = "unknown".parse();
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn test_template_variant_display() {
676        assert_eq!(TemplateVariant::Full.to_string(), "full");
677        assert_eq!(TemplateVariant::Minimal.to_string(), "minimal");
678        assert_eq!(TemplateVariant::Bare.to_string(), "bare");
679        assert_eq!(TemplateVariant::BareMinimal.to_string(), "bare-minimal");
680    }
681
682    // ========== Template Creation Tests ==========
683
684    #[test]
685    fn test_template_from_string() {
686        let template = Template::from_string("test", "# {{ title }}");
687        assert_eq!(template.name, "test");
688        assert_eq!(template.content, "# {{ title }}");
689    }
690
691    #[test]
692    fn test_template_from_file() {
693        let temp = TempDir::new().unwrap();
694        let path = temp.path().join("custom.md");
695        std::fs::write(&path, "# {{ number }}. {{ title }}").unwrap();
696
697        let template = Template::from_file(&path).unwrap();
698        assert_eq!(template.name, "custom.md");
699        assert!(template.content.contains("{{ number }}"));
700    }
701
702    #[test]
703    fn test_template_from_file_not_found() {
704        let result = Template::from_file(Path::new("/nonexistent/template.md"));
705        assert!(result.is_err());
706    }
707
708    #[test]
709    fn test_template_builtin_nygard() {
710        let template = Template::builtin(TemplateFormat::Nygard);
711        assert_eq!(template.name, "nygard");
712        assert!(template.content.contains("## Status"));
713        assert!(template.content.contains("## Context"));
714        assert!(template.content.contains("## Decision"));
715        assert!(template.content.contains("## Consequences"));
716    }
717
718    #[test]
719    fn test_template_builtin_madr() {
720        let template = Template::builtin(TemplateFormat::Madr);
721        assert_eq!(template.name, "madr");
722        assert!(template.content.contains("Context and Problem Statement"));
723        assert!(template.content.contains("Decision Drivers"));
724        assert!(template.content.contains("Considered Options"));
725        assert!(template.content.contains("Decision Outcome"));
726    }
727
728    // ========== Template Rendering - Nygard Compatible Mode ==========
729
730    #[test]
731    fn test_render_nygard_compatible() {
732        let template = Template::builtin(TemplateFormat::Nygard);
733        let mut adr = Adr::new(1, "Use Rust");
734        adr.status = AdrStatus::Accepted;
735
736        let config = Config::default();
737        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
738
739        assert!(output.contains("# 1. Use Rust"));
740        assert!(output.contains("## Status"));
741        assert!(output.contains("Accepted"));
742        assert!(!output.starts_with("---")); // No frontmatter in compatible mode
743    }
744
745    #[test]
746    fn test_render_nygard_all_statuses() {
747        let template = Template::builtin(TemplateFormat::Nygard);
748        let config = Config::default();
749
750        for (status, expected_text) in [
751            (AdrStatus::Proposed, "Proposed"),
752            (AdrStatus::Accepted, "Accepted"),
753            (AdrStatus::Deprecated, "Deprecated"),
754            (AdrStatus::Superseded, "Superseded"),
755            (AdrStatus::Custom("Draft".into()), "Draft"),
756        ] {
757            let mut adr = Adr::new(1, "Test");
758            adr.status = status;
759
760            let output = template.render(&adr, &config, &no_link_titles()).unwrap();
761            assert!(
762                output.contains(expected_text),
763                "Output should contain '{expected_text}': {output}"
764            );
765        }
766    }
767
768    #[test]
769    fn test_render_nygard_with_content() {
770        let template = Template::builtin(TemplateFormat::Nygard);
771        let mut adr = Adr::new(1, "Use Rust");
772        adr.status = AdrStatus::Accepted;
773        adr.context = "We need a safe language.".to_string();
774        adr.decision = "We will use Rust.".to_string();
775        adr.consequences = "Better memory safety.".to_string();
776
777        let config = Config::default();
778        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
779
780        assert!(output.contains("We need a safe language."));
781        assert!(output.contains("We will use Rust."));
782        assert!(output.contains("Better memory safety."));
783    }
784
785    #[test]
786    fn test_render_nygard_with_links() {
787        let template = Template::builtin(TemplateFormat::Nygard);
788        let mut adr = Adr::new(2, "Use PostgreSQL");
789        adr.status = AdrStatus::Accepted;
790        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
791
792        let config = Config::default();
793        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
794
795        assert!(output.contains("Supersedes"));
796        assert!(output.contains("[1. ...]"));
797        assert!(output.contains("0001-....md"));
798    }
799
800    #[test]
801    fn test_render_nygard_with_multiple_links() {
802        let template = Template::builtin(TemplateFormat::Nygard);
803        let mut adr = Adr::new(5, "Combined Decision");
804        adr.status = AdrStatus::Accepted;
805        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
806        adr.links.push(AdrLink::new(2, LinkKind::Amends));
807        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
808
809        let config = Config::default();
810        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
811
812        assert!(output.contains("Supersedes"));
813        assert!(output.contains("Amends"));
814        assert!(output.contains("Superseded by"));
815    }
816
817    // ========== Resolved Link Titles (Issue #180) ==========
818
819    #[test]
820    fn test_render_nygard_with_resolved_link_titles() {
821        let template = Template::builtin(TemplateFormat::Nygard);
822        let mut adr = Adr::new(3, "Use PostgreSQL instead");
823        adr.status = AdrStatus::Accepted;
824        adr.links.push(AdrLink::new(2, LinkKind::Supersedes));
825
826        let mut link_titles = HashMap::new();
827        link_titles.insert(
828            2,
829            (
830                "Use MySQL for persistence".to_string(),
831                "0002-use-mysql-for-persistence.md".to_string(),
832            ),
833        );
834
835        let config = Config::default();
836        let output = template.render(&adr, &config, &link_titles).unwrap();
837
838        assert!(
839            output.contains(
840                "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
841            ),
842            "Link should contain resolved title and filename. Got:\n{output}"
843        );
844    }
845
846    #[test]
847    fn test_render_nygard_with_resolved_superseded_by_link() {
848        let template = Template::builtin(TemplateFormat::Nygard);
849        let mut adr = Adr::new(2, "Use MySQL");
850        adr.status = AdrStatus::Superseded;
851        adr.links.push(AdrLink::new(3, LinkKind::SupersededBy));
852
853        let mut link_titles = HashMap::new();
854        link_titles.insert(
855            3,
856            (
857                "Use PostgreSQL instead".to_string(),
858                "0003-use-postgresql-instead.md".to_string(),
859            ),
860        );
861
862        let config = Config::default();
863        let output = template.render(&adr, &config, &link_titles).unwrap();
864
865        assert!(
866            output.contains(
867                "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
868            ),
869            "Superseded-by link should contain resolved title and filename. Got:\n{output}"
870        );
871    }
872
873    #[test]
874    fn test_render_nygard_with_multiple_resolved_links() {
875        let template = Template::builtin(TemplateFormat::Nygard);
876        let mut adr = Adr::new(5, "Combined Decision");
877        adr.status = AdrStatus::Accepted;
878        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
879        adr.links.push(AdrLink::new(2, LinkKind::Amends));
880
881        let mut link_titles = HashMap::new();
882        link_titles.insert(
883            1,
884            (
885                "Initial Decision".to_string(),
886                "0001-initial-decision.md".to_string(),
887            ),
888        );
889        link_titles.insert(
890            2,
891            (
892                "Second Decision".to_string(),
893                "0002-second-decision.md".to_string(),
894            ),
895        );
896
897        let config = Config::default();
898        let output = template.render(&adr, &config, &link_titles).unwrap();
899
900        assert!(
901            output.contains("Supersedes [1. Initial Decision](0001-initial-decision.md)"),
902            "First link should be resolved. Got:\n{output}"
903        );
904        assert!(
905            output.contains("Amends [2. Second Decision](0002-second-decision.md)"),
906            "Second link should be resolved. Got:\n{output}"
907        );
908    }
909
910    #[test]
911    fn test_render_nygard_unresolved_link_falls_back() {
912        let template = Template::builtin(TemplateFormat::Nygard);
913        let mut adr = Adr::new(2, "Test");
914        adr.status = AdrStatus::Accepted;
915        adr.links.push(AdrLink::new(99, LinkKind::Supersedes));
916
917        let config = Config::default();
918        // Empty link_titles = no resolution available
919        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
920
921        assert!(
922            output.contains("Supersedes [99. ...](0099-....md)"),
923            "Unresolved link should fall back to '...' placeholder. Got:\n{output}"
924        );
925    }
926
927    #[test]
928    fn test_render_nygard_minimal_with_resolved_links() {
929        let template =
930            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
931        let mut adr = Adr::new(2, "New Approach");
932        adr.status = AdrStatus::Accepted;
933        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
934
935        let mut link_titles = HashMap::new();
936        link_titles.insert(
937            1,
938            (
939                "Old Approach".to_string(),
940                "0001-old-approach.md".to_string(),
941            ),
942        );
943
944        let config = Config::default();
945        let output = template.render(&adr, &config, &link_titles).unwrap();
946
947        assert!(
948            output.contains("Supersedes [1. Old Approach](0001-old-approach.md)"),
949            "Minimal template should also resolve link titles. Got:\n{output}"
950        );
951    }
952
953    #[test]
954    fn test_render_nygard_ng_with_resolved_links() {
955        let template = Template::builtin(TemplateFormat::Nygard);
956        let mut adr = Adr::new(2, "New Approach");
957        adr.status = AdrStatus::Accepted;
958        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
959
960        let mut link_titles = HashMap::new();
961        link_titles.insert(
962            1,
963            (
964                "Old Approach".to_string(),
965                "0001-old-approach.md".to_string(),
966            ),
967        );
968
969        let config = Config {
970            mode: ConfigMode::NextGen,
971            ..Default::default()
972        };
973        let output = template.render(&adr, &config, &link_titles).unwrap();
974
975        // Body should have resolved link
976        assert!(
977            output.contains("Supersedes [1. Old Approach](0001-old-approach.md)"),
978            "NG mode body should have resolved links. Got:\n{output}"
979        );
980        // Frontmatter should still have structured link data
981        assert!(output.contains("links:"));
982        assert!(output.contains("target: 1"));
983    }
984
985    // ========== Template Rendering - Nygard NextGen Mode ==========
986
987    #[test]
988    fn test_render_nygard_ng() {
989        let template = Template::builtin(TemplateFormat::Nygard);
990        let mut adr = Adr::new(1, "Use Rust");
991        adr.status = AdrStatus::Accepted;
992
993        let config = Config {
994            mode: ConfigMode::NextGen,
995            ..Default::default()
996        };
997        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
998
999        assert!(output.starts_with("---")); // Has frontmatter in ng mode
1000        assert!(output.contains("number: 1"));
1001        assert!(output.contains("title: Use Rust"));
1002        assert!(output.contains("status: accepted"));
1003    }
1004
1005    #[test]
1006    fn test_render_nygard_ng_with_links() {
1007        let template = Template::builtin(TemplateFormat::Nygard);
1008        let mut adr = Adr::new(2, "Test");
1009        adr.status = AdrStatus::Accepted;
1010        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1011
1012        let config = Config {
1013            mode: ConfigMode::NextGen,
1014            ..Default::default()
1015        };
1016        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1017
1018        assert!(output.contains("links:"));
1019        assert!(output.contains("target: 1"));
1020    }
1021
1022    // ========== Template Rendering - MADR 4.0.0 ==========
1023
1024    #[test]
1025    fn test_render_madr_basic() {
1026        let template = Template::builtin(TemplateFormat::Madr);
1027        let mut adr = Adr::new(1, "Use Rust");
1028        adr.status = AdrStatus::Accepted;
1029
1030        let config = Config::default();
1031        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1032
1033        assert!(output.starts_with("---")); // MADR always has frontmatter
1034        assert!(output.contains("status: accepted"));
1035        assert!(output.contains("# Use Rust"));
1036        assert!(output.contains("## Context and Problem Statement"));
1037        assert!(output.contains("## Decision Drivers"));
1038        assert!(output.contains("## Considered Options"));
1039        assert!(output.contains("## Decision Outcome"));
1040        assert!(output.contains("## Pros and Cons of the Options"));
1041    }
1042
1043    #[test]
1044    fn test_render_madr_with_decision_makers() {
1045        let template = Template::builtin(TemplateFormat::Madr);
1046        let mut adr = Adr::new(1, "Use Rust");
1047        adr.status = AdrStatus::Accepted;
1048        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
1049
1050        let config = Config::default();
1051        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1052
1053        assert!(output.contains("decision-makers:"));
1054        assert!(output.contains("  - Alice"));
1055        assert!(output.contains("  - Bob"));
1056    }
1057
1058    #[test]
1059    fn test_render_madr_with_consulted() {
1060        let template = Template::builtin(TemplateFormat::Madr);
1061        let mut adr = Adr::new(1, "Use Rust");
1062        adr.status = AdrStatus::Accepted;
1063        adr.consulted = vec!["Carol".into()];
1064
1065        let config = Config::default();
1066        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1067
1068        assert!(output.contains("consulted:"));
1069        assert!(output.contains("  - Carol"));
1070    }
1071
1072    #[test]
1073    fn test_render_madr_with_informed() {
1074        let template = Template::builtin(TemplateFormat::Madr);
1075        let mut adr = Adr::new(1, "Use Rust");
1076        adr.status = AdrStatus::Accepted;
1077        adr.informed = vec!["Dave".into(), "Eve".into()];
1078
1079        let config = Config::default();
1080        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1081
1082        assert!(output.contains("informed:"));
1083        assert!(output.contains("  - Dave"));
1084        assert!(output.contains("  - Eve"));
1085    }
1086
1087    #[test]
1088    fn test_render_madr_full_frontmatter() {
1089        let template = Template::builtin(TemplateFormat::Madr);
1090        let mut adr = Adr::new(1, "Use MADR Format");
1091        adr.status = AdrStatus::Accepted;
1092        adr.decision_makers = vec!["Alice".into(), "Bob".into()];
1093        adr.consulted = vec!["Carol".into()];
1094        adr.informed = vec!["Dave".into()];
1095
1096        let config = Config::default();
1097        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1098
1099        // Check frontmatter structure - now includes number and title
1100        assert!(
1101            output.starts_with("---\nnumber: 1\ntitle: Use MADR Format\nstatus: accepted\ndate:")
1102        );
1103        assert!(output.contains("decision-makers:\n  - Alice\n  - Bob"));
1104        assert!(output.contains("consulted:\n  - Carol"));
1105        assert!(output.contains("informed:\n  - Dave"));
1106        assert!(output.contains("---\n\n# Use MADR Format"));
1107    }
1108
1109    #[test]
1110    fn test_render_madr_empty_optional_fields() {
1111        let template = Template::builtin(TemplateFormat::Madr);
1112        let mut adr = Adr::new(1, "Simple ADR");
1113        adr.status = AdrStatus::Proposed;
1114
1115        let config = Config::default();
1116        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1117
1118        // Empty optional fields should not appear
1119        assert!(!output.contains("decision-makers:"));
1120        assert!(!output.contains("consulted:"));
1121        assert!(!output.contains("informed:"));
1122    }
1123
1124    // ========== Template Variants Tests ==========
1125
1126    #[test]
1127    fn test_nygard_minimal_template() {
1128        let template =
1129            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Minimal);
1130        let adr = Adr::new(1, "Minimal Test");
1131        let config = Config::default();
1132        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1133
1134        // Should have basic structure but no guidance text
1135        assert!(output.contains("# 1. Minimal Test"));
1136        assert!(output.contains("## Status"));
1137        assert!(output.contains("## Context"));
1138        assert!(output.contains("## Decision"));
1139        assert!(output.contains("## Consequences"));
1140        // Should NOT have guidance text
1141        assert!(!output.contains("What is the issue"));
1142    }
1143
1144    #[test]
1145    fn test_nygard_bare_template() {
1146        let template =
1147            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Bare);
1148        let adr = Adr::new(1, "Bare Test");
1149        let config = Config::default();
1150        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1151
1152        // Should have basic structure
1153        assert!(output.contains("# 1. Bare Test"));
1154        assert!(output.contains("## Status"));
1155        assert!(output.contains("## Context"));
1156        // Bare template has no frontmatter
1157        assert!(!output.contains("---"));
1158    }
1159
1160    #[test]
1161    fn test_madr_minimal_template() {
1162        let template =
1163            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Minimal);
1164        let adr = Adr::new(1, "MADR Minimal");
1165        let config = Config::default();
1166        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1167
1168        // MADR minimal has NO frontmatter (matches official adr-template-minimal.md)
1169        assert!(!output.starts_with("---"));
1170        assert!(output.contains("# MADR Minimal"));
1171        assert!(output.contains("## Context and Problem Statement"));
1172        assert!(output.contains("## Considered Options"));
1173        assert!(output.contains("## Decision Outcome"));
1174        // Should NOT have full MADR sections
1175        assert!(!output.contains("## Decision Drivers"));
1176        assert!(!output.contains("## Pros and Cons"));
1177    }
1178
1179    #[test]
1180    fn test_madr_bare_template() {
1181        let template = Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::Bare);
1182        let adr = Adr::new(1, "MADR Bare");
1183        let config = Config::default();
1184        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1185
1186        // MADR bare has frontmatter with empty fields (matches official adr-template-bare.md)
1187        assert!(output.starts_with("---"));
1188        assert!(output.contains("status:"));
1189        assert!(output.contains("decision-makers:"));
1190        assert!(output.contains("consulted:"));
1191        assert!(output.contains("informed:"));
1192        assert!(output.contains("# MADR Bare"));
1193        // Should have ALL sections (empty)
1194        assert!(output.contains("## Decision Drivers"));
1195        assert!(output.contains("## Considered Options"));
1196        assert!(output.contains("## Pros and Cons of the Options"));
1197        assert!(output.contains("## More Information"));
1198    }
1199
1200    #[test]
1201    fn test_madr_bare_minimal_template() {
1202        let template =
1203            Template::builtin_with_variant(TemplateFormat::Madr, TemplateVariant::BareMinimal);
1204        let adr = Adr::new(1, "MADR Bare Minimal");
1205        let config = Config::default();
1206        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1207
1208        // MADR bare-minimal has NO frontmatter, minimal sections
1209        assert!(!output.starts_with("---"));
1210        assert!(output.contains("# MADR Bare Minimal"));
1211        assert!(output.contains("## Context and Problem Statement"));
1212        assert!(output.contains("## Considered Options"));
1213        assert!(output.contains("## Decision Outcome"));
1214        assert!(output.contains("### Consequences"));
1215        // Should NOT have extended sections
1216        assert!(!output.contains("## Decision Drivers"));
1217        assert!(!output.contains("## Pros and Cons"));
1218    }
1219
1220    #[test]
1221    fn test_nygard_bare_minimal_template() {
1222        let template =
1223            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
1224        let adr = Adr::new(1, "Nygard Bare Minimal");
1225        let config = Config::default();
1226        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1227
1228        // Should have basic structure without Date line
1229        assert!(output.contains("# 1. Nygard Bare Minimal"));
1230        assert!(output.contains("## Status"));
1231        assert!(output.contains("## Context"));
1232        assert!(output.contains("## Decision"));
1233        assert!(output.contains("## Consequences"));
1234        // No frontmatter, no date
1235        assert!(!output.contains("---"));
1236        assert!(!output.contains("Date:"));
1237    }
1238
1239    #[test]
1240    fn test_builtin_defaults_to_full() {
1241        let full = Template::builtin(TemplateFormat::Nygard);
1242        let explicit_full =
1243            Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::Full);
1244
1245        assert_eq!(full.name, explicit_full.name);
1246        assert_eq!(full.content, explicit_full.content);
1247    }
1248
1249    // ========== Template Engine Tests ==========
1250
1251    #[test]
1252    fn test_template_engine_new() {
1253        let engine = TemplateEngine::new();
1254        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1255        assert_eq!(engine.default_variant, TemplateVariant::Full);
1256        assert!(engine.custom_template.is_none());
1257    }
1258
1259    #[test]
1260    fn test_template_engine_default() {
1261        let engine = TemplateEngine::default();
1262        assert_eq!(engine.default_format, TemplateFormat::Nygard);
1263        assert_eq!(engine.default_variant, TemplateVariant::Full);
1264    }
1265
1266    #[test]
1267    fn test_template_engine_with_format() {
1268        let engine = TemplateEngine::new().with_format(TemplateFormat::Madr);
1269        assert_eq!(engine.default_format, TemplateFormat::Madr);
1270    }
1271
1272    #[test]
1273    fn test_template_engine_with_custom_template() {
1274        let custom = Template::from_string("custom", "# {{ title }}");
1275        let engine = TemplateEngine::new().with_custom_template(custom);
1276        assert!(engine.custom_template.is_some());
1277    }
1278
1279    #[test]
1280    fn test_template_engine_with_custom_template_file() {
1281        let temp = TempDir::new().unwrap();
1282        let path = temp.path().join("template.md");
1283        std::fs::write(&path, "# {{ title }}").unwrap();
1284
1285        let engine = TemplateEngine::new()
1286            .with_custom_template_file(&path)
1287            .unwrap();
1288        assert!(engine.custom_template.is_some());
1289    }
1290
1291    #[test]
1292    fn test_template_engine_with_custom_template_file_not_found() {
1293        let result = TemplateEngine::new().with_custom_template_file(Path::new("/nonexistent.md"));
1294        assert!(result.is_err());
1295    }
1296
1297    #[test]
1298    fn test_template_engine_template_builtin() {
1299        let engine = TemplateEngine::new();
1300        let template = engine.template();
1301        assert_eq!(template.name, "nygard");
1302    }
1303
1304    #[test]
1305    fn test_template_engine_template_custom() {
1306        let custom = Template::from_string("my-template", "# Custom");
1307        let engine = TemplateEngine::new().with_custom_template(custom);
1308        let template = engine.template();
1309        assert_eq!(template.name, "my-template");
1310    }
1311
1312    #[test]
1313    fn test_template_engine_render() {
1314        let engine = TemplateEngine::new();
1315        let adr = Adr::new(1, "Test");
1316        let config = Config::default();
1317
1318        let output = engine.render(&adr, &config, &no_link_titles()).unwrap();
1319        assert!(output.contains("# 1. Test"));
1320    }
1321
1322    #[test]
1323    fn test_template_engine_render_custom() {
1324        let custom = Template::from_string("custom", "ADR {{ number }}: {{ title }}");
1325        let engine = TemplateEngine::new().with_custom_template(custom);
1326        let adr = Adr::new(42, "Custom ADR");
1327        let config = Config::default();
1328
1329        let output = engine.render(&adr, &config, &no_link_titles()).unwrap();
1330        assert_eq!(output, "ADR 42: Custom ADR");
1331    }
1332
1333    // ========== Custom Template Tests ==========
1334
1335    #[test]
1336    fn test_custom_template_all_fields() {
1337        let custom = Template::from_string(
1338            "full",
1339            r#"# {{ number }}. {{ title }}
1340Date: {{ date }}
1341Status: {{ status }}
1342Context: {{ context }}
1343Decision: {{ decision }}
1344Consequences: {{ consequences }}
1345Links: {% for link in links %}{{ link.kind }} {{ link.target }}{% endfor %}"#,
1346        );
1347
1348        let mut adr = Adr::new(1, "Test");
1349        adr.status = AdrStatus::Accepted;
1350        adr.context = "Context text".into();
1351        adr.decision = "Decision text".into();
1352        adr.consequences = "Consequences text".into();
1353        adr.links.push(AdrLink::new(2, LinkKind::Amends));
1354
1355        let config = Config::default();
1356        let output = custom.render(&adr, &config, &no_link_titles()).unwrap();
1357
1358        assert!(output.contains("# 1. Test"));
1359        assert!(output.contains("Status: Accepted"));
1360        assert!(output.contains("Context: Context text"));
1361        assert!(output.contains("Decision: Decision text"));
1362        assert!(output.contains("Consequences: Consequences text"));
1363        assert!(output.contains("Amends 2"));
1364    }
1365
1366    #[test]
1367    fn test_custom_template_is_ng_flag() {
1368        let custom = Template::from_string(
1369            "ng-check",
1370            r#"{% if is_ng %}NextGen Mode{% else %}Compatible Mode{% endif %}"#,
1371        );
1372
1373        let adr = Adr::new(1, "Test");
1374
1375        let compat_config = Config::default();
1376        let output = custom
1377            .render(&adr, &compat_config, &no_link_titles())
1378            .unwrap();
1379        assert_eq!(output, "Compatible Mode");
1380
1381        let ng_config = Config {
1382            mode: ConfigMode::NextGen,
1383            ..Default::default()
1384        };
1385        let output = custom.render(&adr, &ng_config, &no_link_titles()).unwrap();
1386        assert_eq!(output, "NextGen Mode");
1387    }
1388
1389    #[test]
1390    fn test_custom_template_link_kinds() {
1391        let custom = Template::from_string(
1392            "links",
1393            r#"{% for link in links %}{{ link.kind }}|{% endfor %}"#,
1394        );
1395
1396        let mut adr = Adr::new(1, "Test");
1397        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1398        adr.links.push(AdrLink::new(2, LinkKind::SupersededBy));
1399        adr.links.push(AdrLink::new(3, LinkKind::Amends));
1400        adr.links.push(AdrLink::new(4, LinkKind::AmendedBy));
1401        adr.links.push(AdrLink::new(5, LinkKind::RelatesTo));
1402        adr.links
1403            .push(AdrLink::new(6, LinkKind::Custom("Depends on".into())));
1404
1405        let config = Config::default();
1406        let output = custom.render(&adr, &config, &no_link_titles()).unwrap();
1407
1408        assert!(output.contains("Supersedes|"));
1409        assert!(output.contains("Superseded by|"));
1410        assert!(output.contains("Amends|"));
1411        assert!(output.contains("Amended by|"));
1412        assert!(output.contains("Relates to|"));
1413        assert!(output.contains("Depends on|"));
1414    }
1415
1416    // ========== Error Cases ==========
1417
1418    #[test]
1419    fn test_template_invalid_syntax() {
1420        let custom = Template::from_string("invalid", "{{ unclosed");
1421        let adr = Adr::new(1, "Test");
1422        let config = Config::default();
1423
1424        let result = custom.render(&adr, &config, &no_link_titles());
1425        assert!(result.is_err());
1426    }
1427
1428    #[test]
1429    fn test_template_undefined_variable() {
1430        let custom = Template::from_string("undefined", "{{ nonexistent }}");
1431        let adr = Adr::new(1, "Test");
1432        let config = Config::default();
1433
1434        // minijinja treats undefined as empty string by default
1435        let result = custom.render(&adr, &config, &no_link_titles());
1436        assert!(result.is_ok());
1437    }
1438
1439    // ========== Large Number Formatting ==========
1440
1441    #[test]
1442    fn test_render_four_digit_number() {
1443        let template = Template::builtin(TemplateFormat::Nygard);
1444        let adr = Adr::new(9999, "Large Number");
1445        let config = Config::default();
1446
1447        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1448        assert!(output.contains("# 9999. Large Number"));
1449    }
1450
1451    #[test]
1452    fn test_render_link_number_formatting() {
1453        let template = Template::builtin(TemplateFormat::Nygard);
1454        let mut adr = Adr::new(2, "Test");
1455        adr.links.push(AdrLink::new(1, LinkKind::Supersedes));
1456
1457        let config = Config::default();
1458        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1459
1460        // Link should use 4-digit padding
1461        assert!(output.contains("0001-"));
1462    }
1463
1464    // ========== Tags Rendering ==========
1465
1466    #[test]
1467    fn test_render_tags_in_nextgen_mode() {
1468        let template = Template::builtin(TemplateFormat::Nygard);
1469        let mut adr = Adr::new(1, "Test ADR");
1470        adr.tags = vec!["database".to_string(), "infrastructure".to_string()];
1471
1472        let config = Config {
1473            mode: ConfigMode::NextGen,
1474            ..Default::default()
1475        };
1476        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1477
1478        // Tags should appear in YAML frontmatter
1479        assert!(output.contains("tags:"));
1480        assert!(output.contains("- database"));
1481        assert!(output.contains("- infrastructure"));
1482    }
1483
1484    #[test]
1485    fn test_render_tags_in_madr_format() {
1486        let template = Template::builtin(TemplateFormat::Madr);
1487        let mut adr = Adr::new(1, "Test ADR");
1488        adr.tags = vec!["api".to_string(), "security".to_string()];
1489
1490        let config = Config::default();
1491        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1492
1493        // Tags should appear in YAML frontmatter
1494        assert!(output.contains("tags:"));
1495        assert!(output.contains("- api"));
1496        assert!(output.contains("- security"));
1497    }
1498
1499    #[test]
1500    fn test_render_no_tags_section_when_empty() {
1501        let template = Template::builtin(TemplateFormat::Nygard);
1502        let adr = Adr::new(1, "Test ADR");
1503
1504        let config = Config {
1505            mode: ConfigMode::NextGen,
1506            ..Default::default()
1507        };
1508        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1509
1510        // No tags section when tags are empty
1511        assert!(!output.contains("tags:"));
1512    }
1513
1514    // ========== Pad Filter Tests (#185) ==========
1515
1516    #[test]
1517    fn test_pad_filter_default_width() {
1518        let template = Template::from_string("test", "{{ number | pad }}");
1519        let adr = Adr::new(1, "Test");
1520        let config = Config::default();
1521        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1522
1523        assert_eq!(output, "0001");
1524    }
1525
1526    #[test]
1527    fn test_pad_filter_custom_width() {
1528        let template = Template::from_string("test", "{{ number | pad(width=6) }}");
1529        let adr = Adr::new(1, "Test");
1530        let config = Config::default();
1531        let output = template.render(&adr, &config, &no_link_titles()).unwrap();
1532
1533        assert_eq!(output, "000001");
1534    }
1535}