//! Template system for generating ADR files. use crate::{Adr, Config, Error, Result}; use minijinja::{Environment, context}; use std::path::Path; /// Built-in template formats. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum TemplateFormat { /// Michael Nygard's original ADR format. #[default] Nygard, /// Markdown Any Decision Records format (MADR 4.0.0). Madr, } impl std::fmt::Display for TemplateFormat { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Nygard => write!(f, "nygard"), Self::Madr => write!(f, "madr"), } } } impl std::str::FromStr for TemplateFormat { type Err = Error; fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "nygard" | "default" => Ok(Self::Nygard), "madr" => Ok(Self::Madr), _ => Err(Error::TemplateNotFound(s.to_string())), } } } /// Template variants for different levels of detail. /// /// The variant names follow the MADR naming convention: /// - **Full**: All sections with guidance text /// - **Minimal**: Core sections only, with guidance text /// - **Bare**: All sections, but empty (no guidance) /// - **BareMinimal**: Core sections only, empty (no guidance) #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum TemplateVariant { /// Full template with all sections and guidance. #[default] Full, /// Minimal template with essential sections only (with guidance). Minimal, /// Bare template - all sections but empty/placeholder content. Bare, /// Bare-minimal template - fewest sections, empty content. BareMinimal, } impl std::fmt::Display for TemplateVariant { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Full => write!(f, "full"), Self::Minimal => write!(f, "minimal"), Self::Bare => write!(f, "bare"), Self::BareMinimal => write!(f, "bare-minimal"), } } } impl std::str::FromStr for TemplateVariant { type Err = Error; fn from_str(s: &str) -> Result { match s.to_lowercase().replace('_', "-").as_str() { "full" | "default" => Ok(Self::Full), "minimal" | "min" => Ok(Self::Minimal), "bare" => Ok(Self::Bare), "bare-minimal" | "bareminimal" | "empty" => Ok(Self::BareMinimal), _ => Err(Error::TemplateNotFound(format!("Unknown variant: {s}"))), } } } /// Zero-pad a number to a given width (default 4). /// /// Used in templates as `{{ number | pad }}` or `{{ number | pad(width=6) }}`. fn pad_filter( value: u32, kwargs: minijinja::value::Kwargs, ) -> std::result::Result { let width: Option = kwargs.get("width")?; kwargs.assert_all_used()?; let w = width.unwrap_or(4) as usize; Ok(format!("{value:0>w$}")) } /// A template for generating ADRs. #[derive(Debug, Clone)] pub struct Template { /// The template content. content: String, /// The template name (for error messages). name: String, } impl Template { /// Create a template from a string. pub fn from_string(name: impl Into, content: impl Into) -> Self { Self { name: name.into(), content: content.into(), } } /// Get the template content. pub fn content(&self) -> &str { &self.content } /// Get the template name. pub fn name(&self) -> &str { &self.name } /// Load a template from a file. pub fn from_file(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("custom") .to_string(); Ok(Self { name, content }) } /// Get a built-in template by format (uses Full variant). pub fn builtin(format: TemplateFormat) -> Self { Self::builtin_with_variant(format, TemplateVariant::Full) } /// Get a built-in template by format and variant. pub fn builtin_with_variant(format: TemplateFormat, variant: TemplateVariant) -> Self { let (name, content) = match (format, variant) { // Nygard templates (TemplateFormat::Nygard, TemplateVariant::Full) => ("nygard", NYGARD_TEMPLATE), (TemplateFormat::Nygard, TemplateVariant::Minimal) => { ("nygard-minimal", NYGARD_MINIMAL_TEMPLATE) } (TemplateFormat::Nygard, TemplateVariant::Bare) => { ("nygard-bare", NYGARD_BARE_TEMPLATE) } (TemplateFormat::Nygard, TemplateVariant::BareMinimal) => { ("nygard-bare-minimal", NYGARD_BARE_MINIMAL_TEMPLATE) } // MADR templates (TemplateFormat::Madr, TemplateVariant::Full) => ("madr", MADR_TEMPLATE), (TemplateFormat::Madr, TemplateVariant::Minimal) => { ("madr-minimal", MADR_MINIMAL_TEMPLATE) } (TemplateFormat::Madr, TemplateVariant::Bare) => ("madr-bare", MADR_BARE_TEMPLATE), (TemplateFormat::Madr, TemplateVariant::BareMinimal) => { ("madr-bare-minimal", MADR_BARE_MINIMAL_TEMPLATE) } }; Self::from_string(name, content) } /// Render the template with the given ADR data. /// /// `link_titles` maps link target ADR numbers to `(title, filename)` pairs /// for generating functional markdown links. pub fn render( &self, adr: &Adr, config: &Config, link_titles: &std::collections::HashMap, ) -> Result { use crate::LinkKind; let mut env = Environment::new(); env.add_filter("pad", pad_filter); env.add_template(&self.name, &self.content) .map_err(|e| Error::TemplateError(e.to_string()))?; let template = env .get_template(&self.name) .map_err(|e| Error::TemplateError(e.to_string()))?; // Convert links to a format with display-friendly kind and resolved titles let links: Vec<_> = adr .links .iter() .map(|link| { let kind_display = match &link.kind { LinkKind::Supersedes => "Supersedes", LinkKind::SupersededBy => "Superseded by", LinkKind::Amends => "Amends", LinkKind::AmendedBy => "Amended by", LinkKind::RelatesTo => "Relates to", LinkKind::Custom(s) => s.as_str(), }; let (target_title, target_filename) = link_titles .get(&link.target) .cloned() .unwrap_or_else(|| ("...".to_string(), format!("{:04}-....md", link.target))); context! { target => link.target, kind => kind_display, description => &link.description, target_title => target_title, target_filename => target_filename, } }) .collect(); let output = template .render(context! { number => adr.number, title => &adr.title, date => crate::parse::format_date(adr.date), status => adr.status.to_string(), context => &adr.context, decision => &adr.decision, consequences => &adr.consequences, links => links, tags => &adr.tags, is_ng => config.is_next_gen(), // MADR 4.0.0 fields decision_makers => &adr.decision_makers, consulted => &adr.consulted, informed => &adr.informed, }) .map_err(|e| Error::TemplateError(e.to_string()))?; // Normalize the ending to exactly one newline (#320). minijinja strips // the template source's final newline, so without this the rendered // ending depends on the template and on whether its last expression is // empty: MADR full/minimal rendered with no trailing newline, Nygard // minimal with two when the last section was empty. Normalizing here // covers custom templates as well. let mut output = output.trim_end().to_string(); output.push('\n'); Ok(output) } } /// Template engine for managing and rendering templates. #[derive(Debug)] pub struct TemplateEngine { /// The default template format. default_format: TemplateFormat, /// The default template variant. default_variant: TemplateVariant, /// Custom template path (overrides built-in). custom_template: Option