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