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