quillmark_core/
templating.rs

1//! # Templating Module
2//!
3//! MiniJinja-based template composition with stable filter API.
4//!
5//! ## Overview
6//!
7//! The `templating` module provides the [`Glue`] type for template rendering and a stable
8//! filter API for backends to register custom filters.
9//!
10//! ## Key Types
11//!
12//! - [`Glue`]: Template rendering engine wrapper
13//! - [`TemplateError`]: Template-specific error types
14//! - [`filter_api`]: Stable API for filter registration (no direct minijinja dependency)
15//!
16//! ## Examples
17//!
18//! ### Basic Template Rendering
19//!
20//! ```no_run
21//! use quillmark_core::Glue;
22//! use std::collections::HashMap;
23//!
24//! let template = r#"
25//! #set document(title: {{ title | String }})
26//!
27//! {{ body | Content }}
28//! "#;
29//!
30//! let mut glue = Glue::new(template.to_string());
31//!
32//! // Register filters (done by backends)
33//! // glue.register_filter("String", string_filter);
34//! // glue.register_filter("Content", content_filter);
35//!
36//! let mut context = HashMap::new();
37//! context.insert("title".to_string(), serde_yaml::Value::String("My Doc".into()));
38//! context.insert("body".to_string(), serde_yaml::Value::String("Content".into()));
39//!
40//! let output = glue.compose(context).unwrap();
41//! ```
42//!
43//! ### Custom Filter Implementation
44//!
45//! ```no_run
46//! use quillmark_core::templating::filter_api::{State, Value, Kwargs, Error, ErrorKind};
47//! # use quillmark_core::Glue;
48//! # let mut glue = Glue::new("template".to_string());
49//!
50//! fn uppercase_filter(
51//!     _state: &State,
52//!     value: Value,
53//!     _kwargs: Kwargs,
54//! ) -> Result<Value, Error> {
55//!     let s = value.as_str().ok_or_else(|| {
56//!         Error::new(ErrorKind::InvalidOperation, "Expected string")
57//!     })?;
58//!     Ok(Value::from(s.to_uppercase()))
59//! }
60//!
61//! // Register with glue
62//! glue.register_filter("uppercase", uppercase_filter);
63//! ```
64//!
65//! ## Filter API
66//!
67//! The [`filter_api`] module provides a stable ABI that external crates can depend on
68//! without requiring a direct minijinja dependency.
69//!
70//! ### Filter Function Signature
71//!
72//! ```rust,ignore
73//! type FilterFn = fn(
74//!     &filter_api::State,
75//!     filter_api::Value,
76//!     filter_api::Kwargs,
77//! ) -> Result<filter_api::Value, minijinja::Error>;
78//! ```
79//!
80//! ## Error Types
81//!
82//! - [`TemplateError::RenderError`]: Template rendering error from MiniJinja
83//! - [`TemplateError::InvalidTemplate`]: Template compilation failed
84//! - [`TemplateError::FilterError`]: Filter execution error
85
86use std::collections::HashMap;
87use std::error::Error as StdError;
88
89use minijinja::{Environment, Error as MjError};
90use serde_yaml;
91
92/// Error types for template rendering
93#[derive(thiserror::Error, Debug)]
94pub enum TemplateError {
95    /// Template rendering error from MiniJinja
96    #[error("{0}")]
97    RenderError(#[from] minijinja::Error),
98    /// Invalid template compilation error
99    #[error("{0}")]
100    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
101    /// Filter execution error
102    #[error("{0}")]
103    FilterError(String),
104}
105
106/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
107pub mod filter_api {
108    pub use minijinja::value::{Kwargs, Value};
109    pub use minijinja::{Error, ErrorKind, State};
110
111    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
112    pub trait DynFilter: Send + Sync + 'static {}
113    impl<T> DynFilter for T where T: Send + Sync + 'static {}
114}
115
116/// Type for filter functions that can be called via function pointers
117type FilterFn = fn(
118    &filter_api::State,
119    filter_api::Value,
120    filter_api::Kwargs,
121) -> Result<filter_api::Value, MjError>;
122
123/// Glue class for template rendering - provides interface for backends to interact with templates
124pub struct Glue {
125    template: String,
126    filters: HashMap<String, FilterFn>,
127}
128
129impl Glue {
130    /// Create a new Glue instance with a template string
131    pub fn new(template: String) -> Self {
132        Self {
133            template,
134            filters: HashMap::new(),
135        }
136    }
137
138    /// Register a filter with the template environment
139    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
140        self.filters.insert(name.to_string(), func);
141    }
142
143    /// Compose template with context from markdown decomposition
144    pub fn compose(
145        &mut self,
146        context: HashMap<String, serde_yaml::Value>,
147    ) -> Result<String, TemplateError> {
148        // Convert YAML values to MiniJinja values
149        let context = convert_yaml_to_minijinja(context)?;
150
151        // Create a new environment for this render
152        let mut env = Environment::new();
153
154        // Register all filters
155        for (name, filter_fn) in &self.filters {
156            let filter_fn = *filter_fn; // Copy the function pointer
157            env.add_filter(name, filter_fn);
158        }
159
160        env.add_template("main", &self.template).map_err(|e| {
161            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
162        })?;
163
164        // Render the template
165        let tmpl = env.get_template("main").map_err(|e| {
166            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
167        })?;
168
169        let result = tmpl.render(&context)?;
170
171        // Check output size limit
172        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
173            return Err(TemplateError::FilterError(format!(
174                "Template output too large: {} bytes (max: {} bytes)",
175                result.len(),
176                crate::error::MAX_TEMPLATE_OUTPUT
177            )));
178        }
179
180        Ok(result)
181    }
182}
183
184/// Convert YAML values to MiniJinja values
185fn convert_yaml_to_minijinja(
186    yaml: HashMap<String, serde_yaml::Value>,
187) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
188    let mut result = HashMap::new();
189
190    for (key, value) in yaml {
191        let minijinja_value = yaml_to_minijinja_value(value)?;
192        result.insert(key, minijinja_value);
193    }
194
195    Ok(result)
196}
197
198/// Convert a single YAML value to a MiniJinja value
199fn yaml_to_minijinja_value(
200    value: serde_yaml::Value,
201) -> Result<minijinja::value::Value, TemplateError> {
202    use minijinja::value::Value as MjValue;
203    use serde_yaml::Value as YamlValue;
204
205    let result = match value {
206        YamlValue::Null => MjValue::from(()),
207        YamlValue::Bool(b) => MjValue::from(b),
208        YamlValue::Number(n) => {
209            if let Some(i) = n.as_i64() {
210                MjValue::from(i)
211            } else if let Some(u) = n.as_u64() {
212                MjValue::from(u)
213            } else if let Some(f) = n.as_f64() {
214                MjValue::from(f)
215            } else {
216                return Err(TemplateError::FilterError(
217                    "Invalid number in YAML".to_string(),
218                ));
219            }
220        }
221        YamlValue::String(s) => MjValue::from(s),
222        YamlValue::Sequence(seq) => {
223            let mut vec = Vec::new();
224            for item in seq {
225                vec.push(yaml_to_minijinja_value(item)?);
226            }
227            MjValue::from(vec)
228        }
229        YamlValue::Mapping(map) => {
230            let mut obj = std::collections::BTreeMap::new();
231            for (k, v) in map {
232                let key = match k {
233                    YamlValue::String(s) => s,
234                    _ => {
235                        return Err(TemplateError::FilterError(
236                            "Non-string key in YAML mapping".to_string(),
237                        ))
238                    }
239                };
240                obj.insert(key, yaml_to_minijinja_value(v)?);
241            }
242            MjValue::from_object(obj)
243        }
244        YamlValue::Tagged(tagged) => {
245            // For tagged values, just use the value part
246            yaml_to_minijinja_value(tagged.value)?
247        }
248    };
249
250    Ok(result)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use std::collections::HashMap;
257
258    #[test]
259    fn test_glue_creation() {
260        let _glue = Glue::new("Hello {{ name }}".to_string());
261        assert!(true);
262    }
263
264    #[test]
265    fn test_compose_simple_template() {
266        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
267        let mut context = HashMap::new();
268        context.insert(
269            "name".to_string(),
270            serde_yaml::Value::String("World".to_string()),
271        );
272        context.insert(
273            "body".to_string(),
274            serde_yaml::Value::String("Hello content".to_string()),
275        );
276
277        let result = glue.compose(context).unwrap();
278        assert!(result.contains("Hello World!"));
279        assert!(result.contains("Body: Hello content"));
280    }
281
282    #[test]
283    fn test_field_with_dash() {
284        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
285        let mut context = HashMap::new();
286        context.insert(
287            "letterhead_title".to_string(),
288            serde_yaml::Value::String("TEST VALUE".to_string()),
289        );
290        context.insert(
291            "body".to_string(),
292            serde_yaml::Value::String("body".to_string()),
293        );
294
295        let result = glue.compose(context).unwrap();
296        assert!(result.contains("TEST VALUE"));
297    }
298
299    #[test]
300    fn test_compose_with_dash_in_template() {
301        // Templates must reference the exact key names provided by the context.
302        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
303        let mut context = HashMap::new();
304        context.insert(
305            "letterhead_title".to_string(),
306            serde_yaml::Value::String("DASHED".to_string()),
307        );
308        context.insert(
309            "body".to_string(),
310            serde_yaml::Value::String("body".to_string()),
311        );
312
313        let result = glue.compose(context).unwrap();
314        assert!(result.contains("DASHED"));
315    }
316
317    #[test]
318    fn test_template_output_size_limit() {
319        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
320        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
321        // that validates the check exists
322        let template = "{{ content }}".to_string();
323        let mut glue = Glue::new(template);
324
325        let mut context = HashMap::new();
326        // Create a large string (simulate large output)
327        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
328        // For testing purposes, we'll just ensure the mechanism works
329        context.insert(
330            "content".to_string(),
331            serde_yaml::Value::String("test".to_string()),
332        );
333
334        let result = glue.compose(context);
335        // This should succeed as it's well under the limit
336        assert!(result.is_ok());
337    }
338}