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, QuillValue};
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(), QuillValue::from_json(serde_json::json!("My Doc")));
38//! context.insert("body".to_string(), QuillValue::from_json(serde_json::json!("Content")));
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};
90
91use crate::value::QuillValue;
92
93/// Error types for template rendering
94#[derive(thiserror::Error, Debug)]
95pub enum TemplateError {
96    /// Template rendering error from MiniJinja
97    #[error("{0}")]
98    RenderError(#[from] minijinja::Error),
99    /// Invalid template compilation error
100    #[error("{0}")]
101    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
102    /// Filter execution error
103    #[error("{0}")]
104    FilterError(String),
105}
106
107/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
108pub mod filter_api {
109    pub use minijinja::value::{Kwargs, Value};
110    pub use minijinja::{Error, ErrorKind, State};
111
112    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
113    pub trait DynFilter: Send + Sync + 'static {}
114    impl<T> DynFilter for T where T: Send + Sync + 'static {}
115}
116
117/// Type for filter functions that can be called via function pointers
118type FilterFn = fn(
119    &filter_api::State,
120    filter_api::Value,
121    filter_api::Kwargs,
122) -> Result<filter_api::Value, MjError>;
123
124/// Glue class for template rendering - provides interface for backends to interact with templates
125pub struct Glue {
126    template: String,
127    filters: HashMap<String, FilterFn>,
128}
129
130impl Glue {
131    /// Create a new Glue instance with a template string
132    pub fn new(template: String) -> Self {
133        Self {
134            template,
135            filters: HashMap::new(),
136        }
137    }
138
139    /// Register a filter with the template environment
140    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
141        self.filters.insert(name.to_string(), func);
142    }
143
144    /// Compose template with context from markdown decomposition
145    pub fn compose(
146        &mut self,
147        context: HashMap<String, QuillValue>,
148    ) -> Result<String, TemplateError> {
149        // Convert QuillValue to MiniJinja values
150        let context = convert_quillvalue_to_minijinja(context)?;
151
152        // Create a new environment for this render
153        let mut env = Environment::new();
154
155        // Register all filters
156        for (name, filter_fn) in &self.filters {
157            let filter_fn = *filter_fn; // Copy the function pointer
158            env.add_filter(name, filter_fn);
159        }
160
161        env.add_template("main", &self.template).map_err(|e| {
162            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
163        })?;
164
165        // Render the template
166        let tmpl = env.get_template("main").map_err(|e| {
167            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
168        })?;
169
170        let result = tmpl.render(&context)?;
171
172        // Check output size limit
173        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
174            return Err(TemplateError::FilterError(format!(
175                "Template output too large: {} bytes (max: {} bytes)",
176                result.len(),
177                crate::error::MAX_TEMPLATE_OUTPUT
178            )));
179        }
180
181        Ok(result)
182    }
183}
184
185/// Convert QuillValue map to MiniJinja values
186fn convert_quillvalue_to_minijinja(
187    fields: HashMap<String, QuillValue>,
188) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
189    let mut result = HashMap::new();
190
191    for (key, value) in fields {
192        let minijinja_value = value.to_minijinja().map_err(|e| {
193            TemplateError::FilterError(format!("Failed to convert QuillValue to MiniJinja: {}", e))
194        })?;
195        result.insert(key, minijinja_value);
196    }
197
198    Ok(result)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::collections::HashMap;
205
206    #[test]
207    fn test_glue_creation() {
208        let _glue = Glue::new("Hello {{ name }}".to_string());
209        assert!(true);
210    }
211
212    #[test]
213    fn test_compose_simple_template() {
214        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
215        let mut context = HashMap::new();
216        context.insert(
217            "name".to_string(),
218            QuillValue::from_json(serde_json::Value::String("World".to_string())),
219        );
220        context.insert(
221            "body".to_string(),
222            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
223        );
224
225        let result = glue.compose(context).unwrap();
226        assert!(result.contains("Hello World!"));
227        assert!(result.contains("Body: Hello content"));
228    }
229
230    #[test]
231    fn test_field_with_dash() {
232        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
233        let mut context = HashMap::new();
234        context.insert(
235            "letterhead_title".to_string(),
236            QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
237        );
238        context.insert(
239            "body".to_string(),
240            QuillValue::from_json(serde_json::Value::String("body".to_string())),
241        );
242
243        let result = glue.compose(context).unwrap();
244        assert!(result.contains("TEST VALUE"));
245    }
246
247    #[test]
248    fn test_compose_with_dash_in_template() {
249        // Templates must reference the exact key names provided by the context.
250        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
251        let mut context = HashMap::new();
252        context.insert(
253            "letterhead_title".to_string(),
254            QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
255        );
256        context.insert(
257            "body".to_string(),
258            QuillValue::from_json(serde_json::Value::String("body".to_string())),
259        );
260
261        let result = glue.compose(context).unwrap();
262        assert!(result.contains("DASHED"));
263    }
264
265    #[test]
266    fn test_template_output_size_limit() {
267        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
268        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
269        // that validates the check exists
270        let template = "{{ content }}".to_string();
271        let mut glue = Glue::new(template);
272
273        let mut context = HashMap::new();
274        // Create a large string (simulate large output)
275        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
276        // For testing purposes, we'll just ensure the mechanism works
277        context.insert(
278            "content".to_string(),
279            QuillValue::from_json(serde_json::Value::String("test".to_string())),
280        );
281
282        let result = glue.compose(context);
283        // This should succeed as it's well under the limit
284        assert!(result.is_ok());
285    }
286}