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/// Trait for glue engines that compose context into output
125pub trait GlueEngine {
126    /// Register a filter with the engine
127    fn register_filter(&mut self, name: &str, func: FilterFn);
128
129    /// Compose context from markdown decomposition into output
130    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError>;
131}
132
133/// Template-based glue engine using MiniJinja
134pub struct TemplateGlue {
135    template: String,
136    filters: HashMap<String, FilterFn>,
137}
138
139/// Auto glue engine that outputs context as JSON
140pub struct AutoGlue {
141    filters: HashMap<String, FilterFn>,
142}
143
144/// Glue type that can be either template-based or auto
145pub enum Glue {
146    /// Template-based glue using MiniJinja
147    Template(TemplateGlue),
148    /// Auto glue that outputs context as JSON
149    Auto(AutoGlue),
150}
151
152impl TemplateGlue {
153    /// Create a new TemplateGlue instance with a template string
154    pub fn new(template: String) -> Self {
155        Self {
156            template,
157            filters: HashMap::new(),
158        }
159    }
160}
161
162impl GlueEngine for TemplateGlue {
163    /// Register a filter with the template environment
164    fn register_filter(&mut self, name: &str, func: FilterFn) {
165        self.filters.insert(name.to_string(), func);
166    }
167
168    /// Compose template with context from markdown decomposition
169    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
170        // Convert QuillValue to MiniJinja values
171        let context = convert_quillvalue_to_minijinja(context)?;
172
173        // Create a new environment for this render
174        let mut env = Environment::new();
175
176        // Register all filters
177        for (name, filter_fn) in &self.filters {
178            let filter_fn = *filter_fn; // Copy the function pointer
179            env.add_filter(name, filter_fn);
180        }
181
182        env.add_template("main", &self.template).map_err(|e| {
183            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
184        })?;
185
186        // Render the template
187        let tmpl = env.get_template("main").map_err(|e| {
188            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
189        })?;
190
191        let result = tmpl.render(&context)?;
192
193        // Check output size limit
194        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
195            return Err(TemplateError::FilterError(format!(
196                "Template output too large: {} bytes (max: {} bytes)",
197                result.len(),
198                crate::error::MAX_TEMPLATE_OUTPUT
199            )));
200        }
201
202        Ok(result)
203    }
204}
205
206impl AutoGlue {
207    /// Create a new AutoGlue instance
208    pub fn new() -> Self {
209        Self {
210            filters: HashMap::new(),
211        }
212    }
213}
214
215impl GlueEngine for AutoGlue {
216    /// Register a filter with the auto glue (ignored for JSON output)
217    fn register_filter(&mut self, name: &str, func: FilterFn) {
218        // Store filters even though they're not used for JSON output
219        // This maintains consistency with the trait interface
220        self.filters.insert(name.to_string(), func);
221    }
222
223    /// Compose context into JSON output
224    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
225        // Convert context to JSON
226        let mut json_map = serde_json::Map::new();
227        for (key, value) in context {
228            json_map.insert(key, value.as_json().clone());
229        }
230
231        let json_value = serde_json::Value::Object(json_map);
232        let result = serde_json::to_string_pretty(&json_value).map_err(|e| {
233            TemplateError::FilterError(format!("Failed to serialize to JSON: {}", e))
234        })?;
235
236        // Check output size limit
237        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
238            return Err(TemplateError::FilterError(format!(
239                "JSON output too large: {} bytes (max: {} bytes)",
240                result.len(),
241                crate::error::MAX_TEMPLATE_OUTPUT
242            )));
243        }
244
245        Ok(result)
246    }
247}
248
249impl Glue {
250    /// Create a new template-based Glue instance
251    pub fn new(template: String) -> Self {
252        Glue::Template(TemplateGlue::new(template))
253    }
254
255    /// Create a new auto glue instance
256    pub fn new_auto() -> Self {
257        Glue::Auto(AutoGlue::new())
258    }
259
260    /// Register a filter with the glue engine
261    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
262        match self {
263            Glue::Template(engine) => engine.register_filter(name, func),
264            Glue::Auto(engine) => engine.register_filter(name, func),
265        }
266    }
267
268    /// Compose context into output
269    pub fn compose(
270        &mut self,
271        context: HashMap<String, QuillValue>,
272    ) -> Result<String, TemplateError> {
273        match self {
274            Glue::Template(engine) => engine.compose(context),
275            Glue::Auto(engine) => engine.compose(context),
276        }
277    }
278}
279
280/// Convert QuillValue map to MiniJinja values
281fn convert_quillvalue_to_minijinja(
282    fields: HashMap<String, QuillValue>,
283) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
284    let mut result = HashMap::new();
285
286    for (key, value) in fields {
287        let minijinja_value = value.to_minijinja().map_err(|e| {
288            TemplateError::FilterError(format!("Failed to convert QuillValue to MiniJinja: {}", e))
289        })?;
290        result.insert(key, minijinja_value);
291    }
292
293    Ok(result)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::collections::HashMap;
300
301    #[test]
302    fn test_glue_creation() {
303        let _glue = Glue::new("Hello {{ name }}".to_string());
304        assert!(true);
305    }
306
307    #[test]
308    fn test_compose_simple_template() {
309        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
310        let mut context = HashMap::new();
311        context.insert(
312            "name".to_string(),
313            QuillValue::from_json(serde_json::Value::String("World".to_string())),
314        );
315        context.insert(
316            "body".to_string(),
317            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
318        );
319
320        let result = glue.compose(context).unwrap();
321        assert!(result.contains("Hello World!"));
322        assert!(result.contains("Body: Hello content"));
323    }
324
325    #[test]
326    fn test_field_with_dash() {
327        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
328        let mut context = HashMap::new();
329        context.insert(
330            "letterhead_title".to_string(),
331            QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
332        );
333        context.insert(
334            "body".to_string(),
335            QuillValue::from_json(serde_json::Value::String("body".to_string())),
336        );
337
338        let result = glue.compose(context).unwrap();
339        assert!(result.contains("TEST VALUE"));
340    }
341
342    #[test]
343    fn test_compose_with_dash_in_template() {
344        // Templates must reference the exact key names provided by the context.
345        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
346        let mut context = HashMap::new();
347        context.insert(
348            "letterhead_title".to_string(),
349            QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
350        );
351        context.insert(
352            "body".to_string(),
353            QuillValue::from_json(serde_json::Value::String("body".to_string())),
354        );
355
356        let result = glue.compose(context).unwrap();
357        assert!(result.contains("DASHED"));
358    }
359
360    #[test]
361    fn test_template_output_size_limit() {
362        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
363        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
364        // that validates the check exists
365        let template = "{{ content }}".to_string();
366        let mut glue = Glue::new(template);
367
368        let mut context = HashMap::new();
369        // Create a large string (simulate large output)
370        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
371        // For testing purposes, we'll just ensure the mechanism works
372        context.insert(
373            "content".to_string(),
374            QuillValue::from_json(serde_json::Value::String("test".to_string())),
375        );
376
377        let result = glue.compose(context);
378        // This should succeed as it's well under the limit
379        assert!(result.is_ok());
380    }
381
382    #[test]
383    fn test_auto_glue_basic() {
384        let mut glue = Glue::new_auto();
385        let mut context = HashMap::new();
386        context.insert(
387            "name".to_string(),
388            QuillValue::from_json(serde_json::Value::String("World".to_string())),
389        );
390        context.insert(
391            "body".to_string(),
392            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
393        );
394
395        let result = glue.compose(context).unwrap();
396
397        // Parse the result as JSON to verify it's valid
398        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
399        assert_eq!(json["name"], "World");
400        assert_eq!(json["body"], "Hello content");
401    }
402
403    #[test]
404    fn test_auto_glue_with_nested_data() {
405        let mut glue = Glue::new_auto();
406        let mut context = HashMap::new();
407
408        // Add nested object
409        let nested_obj = serde_json::json!({
410            "first": "John",
411            "last": "Doe"
412        });
413        context.insert("author".to_string(), QuillValue::from_json(nested_obj));
414
415        // Add array
416        let tags = serde_json::json!(["tag1", "tag2", "tag3"]);
417        context.insert("tags".to_string(), QuillValue::from_json(tags));
418
419        let result = glue.compose(context).unwrap();
420
421        // Parse the result as JSON to verify structure
422        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
423        assert_eq!(json["author"]["first"], "John");
424        assert_eq!(json["author"]["last"], "Doe");
425        assert_eq!(json["tags"][0], "tag1");
426        assert_eq!(json["tags"].as_array().unwrap().len(), 3);
427    }
428
429    #[test]
430    fn test_auto_glue_filter_registration() {
431        // Test that filters can be registered (even though they're not used)
432        let mut glue = Glue::new_auto();
433
434        fn dummy_filter(
435            _state: &filter_api::State,
436            value: filter_api::Value,
437            _kwargs: filter_api::Kwargs,
438        ) -> Result<filter_api::Value, MjError> {
439            Ok(value)
440        }
441
442        // Should not panic
443        glue.register_filter("dummy", dummy_filter);
444
445        let mut context = HashMap::new();
446        context.insert(
447            "test".to_string(),
448            QuillValue::from_json(serde_json::Value::String("value".to_string())),
449        );
450
451        let result = glue.compose(context).unwrap();
452        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
453        assert_eq!(json["test"], "value");
454    }
455}