quillmark_core/
templating.rs

1use std::collections::HashMap;
2use std::error::Error as StdError;
3
4use minijinja::{Environment, Error as MjError};
5use serde_yaml;
6
7/// Error types for template rendering
8#[derive(thiserror::Error, Debug)]
9pub enum TemplateError {
10    #[error("{0}")]
11    RenderError(#[from] minijinja::Error),
12    #[error("{0}")]
13    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
14    #[error("{0}")]
15    FilterError(String),
16}
17
18/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
19pub mod filter_api {
20    pub use minijinja::value::{Kwargs, Value};
21    pub use minijinja::{Error, ErrorKind, State};
22
23    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
24    pub trait DynFilter: Send + Sync + 'static {}
25    impl<T> DynFilter for T where T: Send + Sync + 'static {}
26}
27
28/// Type for filter functions that can be called via function pointers
29type FilterFn = fn(
30    &filter_api::State,
31    filter_api::Value,
32    filter_api::Kwargs,
33) -> Result<filter_api::Value, MjError>;
34
35/// Glue class for template rendering - provides interface for backends to interact with templates
36pub struct Glue {
37    template: String,
38    filters: HashMap<String, FilterFn>,
39}
40
41impl Glue {
42    /// Create a new Glue instance with a template string
43    pub fn new(template: String) -> Self {
44        Self {
45            template,
46            filters: HashMap::new(),
47        }
48    }
49
50    /// Register a filter with the template environment
51    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
52        self.filters.insert(name.to_string(), func);
53    }
54
55    /// Compose template with context from markdown decomposition
56    pub fn compose(
57        &mut self,
58        context: HashMap<String, serde_yaml::Value>,
59    ) -> Result<String, TemplateError> {
60        // Convert YAML values to MiniJinja values
61        let context = convert_yaml_to_minijinja(context)?;
62
63        // Create a new environment for this render
64        let mut env = Environment::new();
65
66        // Register all filters
67        for (name, filter_fn) in &self.filters {
68            let filter_fn = *filter_fn; // Copy the function pointer
69            env.add_filter(name, filter_fn);
70        }
71
72        env.add_template("main", &self.template).map_err(|e| {
73            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
74        })?;
75
76        // Render the template
77        let tmpl = env.get_template("main").map_err(|e| {
78            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
79        })?;
80
81        let result = tmpl.render(&context)?;
82        Ok(result)
83    }
84}
85
86/// Convert YAML values to MiniJinja values
87fn convert_yaml_to_minijinja(
88    yaml: HashMap<String, serde_yaml::Value>,
89) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
90    let mut result = HashMap::new();
91
92    for (key, value) in yaml {
93        let minijinja_value = yaml_to_minijinja_value(value)?;
94        result.insert(key, minijinja_value);
95    }
96
97    Ok(result)
98}
99
100/// Convert a single YAML value to a MiniJinja value
101fn yaml_to_minijinja_value(
102    value: serde_yaml::Value,
103) -> Result<minijinja::value::Value, TemplateError> {
104    use minijinja::value::Value as MjValue;
105    use serde_yaml::Value as YamlValue;
106
107    let result = match value {
108        YamlValue::Null => MjValue::from(()),
109        YamlValue::Bool(b) => MjValue::from(b),
110        YamlValue::Number(n) => {
111            if let Some(i) = n.as_i64() {
112                MjValue::from(i)
113            } else if let Some(u) = n.as_u64() {
114                MjValue::from(u)
115            } else if let Some(f) = n.as_f64() {
116                MjValue::from(f)
117            } else {
118                return Err(TemplateError::FilterError(
119                    "Invalid number in YAML".to_string(),
120                ));
121            }
122        }
123        YamlValue::String(s) => MjValue::from(s),
124        YamlValue::Sequence(seq) => {
125            let mut vec = Vec::new();
126            for item in seq {
127                vec.push(yaml_to_minijinja_value(item)?);
128            }
129            MjValue::from(vec)
130        }
131        YamlValue::Mapping(map) => {
132            let mut obj = std::collections::BTreeMap::new();
133            for (k, v) in map {
134                let key = match k {
135                    YamlValue::String(s) => s,
136                    _ => {
137                        return Err(TemplateError::FilterError(
138                            "Non-string key in YAML mapping".to_string(),
139                        ))
140                    }
141                };
142                obj.insert(key, yaml_to_minijinja_value(v)?);
143            }
144            MjValue::from_object(obj)
145        }
146        YamlValue::Tagged(tagged) => {
147            // For tagged values, just use the value part
148            yaml_to_minijinja_value(tagged.value)?
149        }
150    };
151
152    Ok(result)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::collections::HashMap;
159
160    #[test]
161    fn test_glue_creation() {
162        let _glue = Glue::new("Hello {{ name }}".to_string());
163        assert!(true);
164    }
165
166    #[test]
167    fn test_compose_simple_template() {
168        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
169        let mut context = HashMap::new();
170        context.insert(
171            "name".to_string(),
172            serde_yaml::Value::String("World".to_string()),
173        );
174        context.insert(
175            "body".to_string(),
176            serde_yaml::Value::String("Hello content".to_string()),
177        );
178
179        let result = glue.compose(context).unwrap();
180        assert!(result.contains("Hello World!"));
181        assert!(result.contains("Body: Hello content"));
182    }
183
184    #[test]
185    fn test_field_with_dash() {
186        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
187        let mut context = HashMap::new();
188        context.insert(
189            "letterhead_title".to_string(),
190            serde_yaml::Value::String("TEST VALUE".to_string()),
191        );
192        context.insert(
193            "body".to_string(),
194            serde_yaml::Value::String("body".to_string()),
195        );
196
197        let result = glue.compose(context).unwrap();
198        assert!(result.contains("TEST VALUE"));
199    }
200
201    #[test]
202    fn test_compose_with_dash_in_template() {
203        // Templates must reference the exact key names provided by the context.
204        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
205        let mut context = HashMap::new();
206        context.insert(
207            "letterhead_title".to_string(),
208            serde_yaml::Value::String("DASHED".to_string()),
209        );
210        context.insert(
211            "body".to_string(),
212            serde_yaml::Value::String("body".to_string()),
213        );
214
215        let result = glue.compose(context).unwrap();
216        assert!(result.contains("DASHED"));
217    }
218}