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::{BTreeMap, HashMap};
87use std::error::Error as StdError;
88
89use minijinja::{Environment, Error as MjError};
90
91use crate::parse::BODY_FIELD;
92use crate::value::QuillValue;
93
94/// Error types for template rendering
95#[derive(thiserror::Error, Debug)]
96pub enum TemplateError {
97    /// Template rendering error from MiniJinja
98    #[error("{0}")]
99    RenderError(#[from] minijinja::Error),
100    /// Invalid template compilation error
101    #[error("{0}")]
102    InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
103    /// Filter execution error
104    #[error("{0}")]
105    FilterError(String),
106}
107
108/// Public filter ABI that external crates can depend on (no direct minijinja dep required)
109pub mod filter_api {
110    pub use minijinja::value::{Kwargs, Value};
111    pub use minijinja::{Error, ErrorKind, State};
112
113    /// Trait alias for closures/functions used as filters (thread-safe, 'static)
114    pub trait DynFilter: Send + Sync + 'static {}
115    impl<T> DynFilter for T where T: Send + Sync + 'static {}
116}
117
118/// Type for filter functions that can be called via function pointers
119type FilterFn = fn(
120    &filter_api::State,
121    filter_api::Value,
122    filter_api::Kwargs,
123) -> Result<filter_api::Value, MjError>;
124
125/// Trait for glue engines that compose context into output
126pub trait GlueEngine {
127    /// Register a filter with the engine
128    fn register_filter(&mut self, name: &str, func: FilterFn);
129
130    /// Compose context from markdown decomposition into output
131    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError>;
132}
133
134/// Template-based glue engine using MiniJinja
135pub struct TemplateGlue {
136    template: String,
137    filters: HashMap<String, FilterFn>,
138}
139
140/// Auto glue engine that outputs context as JSON
141pub struct AutoGlue {
142    filters: HashMap<String, FilterFn>,
143}
144
145/// Glue type that can be either template-based or auto
146pub enum Glue {
147    /// Template-based glue using MiniJinja
148    Template(TemplateGlue),
149    /// Auto glue that outputs context as JSON
150    Auto(AutoGlue),
151}
152
153impl TemplateGlue {
154    /// Create a new TemplateGlue instance with a template string
155    pub fn new(template: String) -> Self {
156        Self {
157            template,
158            filters: HashMap::new(),
159        }
160    }
161}
162
163impl GlueEngine for TemplateGlue {
164    /// Register a filter with the template environment
165    fn register_filter(&mut self, name: &str, func: FilterFn) {
166        self.filters.insert(name.to_string(), func);
167    }
168
169    /// Compose template with context from markdown decomposition
170    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
171        // Separate metadata from body using helper function
172        let metadata_fields = separate_metadata_fields(&context);
173
174        // Convert QuillValue to MiniJinja values
175        let mut minijinja_context = convert_quillvalue_to_minijinja(context)?;
176        let metadata_minijinja = convert_quillvalue_to_minijinja(metadata_fields)?;
177
178        // Add __metadata__ field as a MiniJinja object
179        // Convert HashMap to BTreeMap for from_object
180        let metadata_btree: BTreeMap<String, minijinja::value::Value> =
181            metadata_minijinja.into_iter().collect();
182        minijinja_context.insert(
183            "__metadata__".to_string(),
184            minijinja::value::Value::from_object(metadata_btree),
185        );
186
187        // Create a new environment for this render
188        let mut env = Environment::new();
189
190        // Register all filters
191        for (name, filter_fn) in &self.filters {
192            let filter_fn = *filter_fn; // Copy the function pointer
193            env.add_filter(name, filter_fn);
194        }
195
196        env.add_template("main", &self.template).map_err(|e| {
197            TemplateError::InvalidTemplate("Failed to add template".to_string(), Box::new(e))
198        })?;
199
200        // Render the template
201        let tmpl = env.get_template("main").map_err(|e| {
202            TemplateError::InvalidTemplate("Failed to get template".to_string(), Box::new(e))
203        })?;
204
205        let result = tmpl.render(&minijinja_context)?;
206
207        // Check output size limit
208        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
209            return Err(TemplateError::FilterError(format!(
210                "Template output too large: {} bytes (max: {} bytes)",
211                result.len(),
212                crate::error::MAX_TEMPLATE_OUTPUT
213            )));
214        }
215
216        Ok(result)
217    }
218}
219
220impl AutoGlue {
221    /// Create a new AutoGlue instance
222    pub fn new() -> Self {
223        Self {
224            filters: HashMap::new(),
225        }
226    }
227}
228
229impl GlueEngine for AutoGlue {
230    /// Register a filter with the auto glue (ignored for JSON output)
231    fn register_filter(&mut self, name: &str, func: FilterFn) {
232        // Store filters even though they're not used for JSON output
233        // This maintains consistency with the trait interface
234        self.filters.insert(name.to_string(), func);
235    }
236
237    /// Compose context into JSON output
238    fn compose(&mut self, context: HashMap<String, QuillValue>) -> Result<String, TemplateError> {
239        // Build both json_map and metadata_json in a single pass to avoid redundant iterations
240        let mut json_map = serde_json::Map::new();
241        let mut metadata_json = serde_json::Map::new();
242
243        for (key, value) in &context {
244            let json_value = value.as_json().clone();
245            json_map.insert(key.clone(), json_value.clone());
246
247            // Add to metadata if not the body field
248            if key.as_str() != BODY_FIELD {
249                metadata_json.insert(key.clone(), json_value);
250            }
251        }
252
253        // Add __metadata__ object to json_map
254        json_map.insert(
255            "__metadata__".to_string(),
256            serde_json::Value::Object(metadata_json),
257        );
258
259        let json_value = serde_json::Value::Object(json_map);
260        let result = serde_json::to_string_pretty(&json_value).map_err(|e| {
261            TemplateError::FilterError(format!("Failed to serialize to JSON: {}", e))
262        })?;
263
264        // Check output size limit
265        if result.len() > crate::error::MAX_TEMPLATE_OUTPUT {
266            return Err(TemplateError::FilterError(format!(
267                "JSON output too large: {} bytes (max: {} bytes)",
268                result.len(),
269                crate::error::MAX_TEMPLATE_OUTPUT
270            )));
271        }
272
273        Ok(result)
274    }
275}
276
277impl Glue {
278    /// Create a new template-based Glue instance
279    pub fn new(template: String) -> Self {
280        Glue::Template(TemplateGlue::new(template))
281    }
282
283    /// Create a new auto glue instance
284    pub fn new_auto() -> Self {
285        Glue::Auto(AutoGlue::new())
286    }
287
288    /// Register a filter with the glue engine
289    pub fn register_filter(&mut self, name: &str, func: FilterFn) {
290        match self {
291            Glue::Template(engine) => engine.register_filter(name, func),
292            Glue::Auto(engine) => engine.register_filter(name, func),
293        }
294    }
295
296    /// Compose context into output
297    pub fn compose(
298        &mut self,
299        context: HashMap<String, QuillValue>,
300    ) -> Result<String, TemplateError> {
301        match self {
302            Glue::Template(engine) => engine.compose(context),
303            Glue::Auto(engine) => engine.compose(context),
304        }
305    }
306}
307
308/// Separate metadata fields from body field
309fn separate_metadata_fields(context: &HashMap<String, QuillValue>) -> HashMap<String, QuillValue> {
310    context
311        .iter()
312        .filter(|(key, _)| key.as_str() != BODY_FIELD)
313        .map(|(k, v)| (k.clone(), v.clone()))
314        .collect()
315}
316
317/// Convert QuillValue map to MiniJinja values
318fn convert_quillvalue_to_minijinja(
319    fields: HashMap<String, QuillValue>,
320) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
321    let mut result = HashMap::new();
322
323    for (key, value) in fields {
324        let minijinja_value = value.to_minijinja().map_err(|e| {
325            TemplateError::FilterError(format!("Failed to convert QuillValue to MiniJinja: {}", e))
326        })?;
327        result.insert(key, minijinja_value);
328    }
329
330    Ok(result)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use std::collections::HashMap;
337
338    #[test]
339    fn test_glue_creation() {
340        let _glue = Glue::new("Hello {{ name }}".to_string());
341        assert!(true);
342    }
343
344    #[test]
345    fn test_compose_simple_template() {
346        let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
347        let mut context = HashMap::new();
348        context.insert(
349            "name".to_string(),
350            QuillValue::from_json(serde_json::Value::String("World".to_string())),
351        );
352        context.insert(
353            "body".to_string(),
354            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
355        );
356
357        let result = glue.compose(context).unwrap();
358        assert!(result.contains("Hello World!"));
359        assert!(result.contains("Body: Hello content"));
360    }
361
362    #[test]
363    fn test_field_with_dash() {
364        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
365        let mut context = HashMap::new();
366        context.insert(
367            "letterhead_title".to_string(),
368            QuillValue::from_json(serde_json::Value::String("TEST VALUE".to_string())),
369        );
370        context.insert(
371            "body".to_string(),
372            QuillValue::from_json(serde_json::Value::String("body".to_string())),
373        );
374
375        let result = glue.compose(context).unwrap();
376        assert!(result.contains("TEST VALUE"));
377    }
378
379    #[test]
380    fn test_compose_with_dash_in_template() {
381        // Templates must reference the exact key names provided by the context.
382        let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
383        let mut context = HashMap::new();
384        context.insert(
385            "letterhead_title".to_string(),
386            QuillValue::from_json(serde_json::Value::String("DASHED".to_string())),
387        );
388        context.insert(
389            "body".to_string(),
390            QuillValue::from_json(serde_json::Value::String("body".to_string())),
391        );
392
393        let result = glue.compose(context).unwrap();
394        assert!(result.contains("DASHED"));
395    }
396
397    #[test]
398    fn test_template_output_size_limit() {
399        // Create a template that generates output larger than MAX_TEMPLATE_OUTPUT
400        // We can't easily create 50MB+ output in a test, so we'll use a smaller test
401        // that validates the check exists
402        let template = "{{ content }}".to_string();
403        let mut glue = Glue::new(template);
404
405        let mut context = HashMap::new();
406        // Create a large string (simulate large output)
407        // Note: In practice, this would need to exceed MAX_TEMPLATE_OUTPUT (50 MB)
408        // For testing purposes, we'll just ensure the mechanism works
409        context.insert(
410            "content".to_string(),
411            QuillValue::from_json(serde_json::Value::String("test".to_string())),
412        );
413
414        let result = glue.compose(context);
415        // This should succeed as it's well under the limit
416        assert!(result.is_ok());
417    }
418
419    #[test]
420    fn test_auto_glue_basic() {
421        let mut glue = Glue::new_auto();
422        let mut context = HashMap::new();
423        context.insert(
424            "name".to_string(),
425            QuillValue::from_json(serde_json::Value::String("World".to_string())),
426        );
427        context.insert(
428            "body".to_string(),
429            QuillValue::from_json(serde_json::Value::String("Hello content".to_string())),
430        );
431
432        let result = glue.compose(context).unwrap();
433
434        // Parse the result as JSON to verify it's valid
435        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
436        assert_eq!(json["name"], "World");
437        assert_eq!(json["body"], "Hello content");
438    }
439
440    #[test]
441    fn test_auto_glue_with_nested_data() {
442        let mut glue = Glue::new_auto();
443        let mut context = HashMap::new();
444
445        // Add nested object
446        let nested_obj = serde_json::json!({
447            "first": "John",
448            "last": "Doe"
449        });
450        context.insert("author".to_string(), QuillValue::from_json(nested_obj));
451
452        // Add array
453        let tags = serde_json::json!(["tag1", "tag2", "tag3"]);
454        context.insert("tags".to_string(), QuillValue::from_json(tags));
455
456        let result = glue.compose(context).unwrap();
457
458        // Parse the result as JSON to verify structure
459        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
460        assert_eq!(json["author"]["first"], "John");
461        assert_eq!(json["author"]["last"], "Doe");
462        assert_eq!(json["tags"][0], "tag1");
463        assert_eq!(json["tags"].as_array().unwrap().len(), 3);
464    }
465
466    #[test]
467    fn test_auto_glue_filter_registration() {
468        // Test that filters can be registered (even though they're not used)
469        let mut glue = Glue::new_auto();
470
471        fn dummy_filter(
472            _state: &filter_api::State,
473            value: filter_api::Value,
474            _kwargs: filter_api::Kwargs,
475        ) -> Result<filter_api::Value, MjError> {
476            Ok(value)
477        }
478
479        // Should not panic
480        glue.register_filter("dummy", dummy_filter);
481
482        let mut context = HashMap::new();
483        context.insert(
484            "test".to_string(),
485            QuillValue::from_json(serde_json::Value::String("value".to_string())),
486        );
487
488        let result = glue.compose(context).unwrap();
489        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
490        assert_eq!(json["test"], "value");
491    }
492
493    #[test]
494    fn test_metadata_field_excludes_body() {
495        let template = "{% for key in __metadata__ %}{{ key }},{% endfor %}";
496        let mut glue = Glue::new(template.to_string());
497
498        let mut context = HashMap::new();
499        context.insert(
500            "title".to_string(),
501            QuillValue::from_json(serde_json::json!("Test")),
502        );
503        context.insert(
504            "author".to_string(),
505            QuillValue::from_json(serde_json::json!("John")),
506        );
507        context.insert(
508            "body".to_string(),
509            QuillValue::from_json(serde_json::json!("Body content")),
510        );
511
512        let result = glue.compose(context).unwrap();
513
514        // Should contain title and author, but not body
515        assert!(result.contains("title"));
516        assert!(result.contains("author"));
517        assert!(!result.contains("body"));
518    }
519
520    #[test]
521    fn test_metadata_field_includes_frontmatter() {
522        let template = r#"
523{%- for key in __metadata__ -%}
524{{ key }}
525{% endfor -%}
526"#;
527        let mut glue = Glue::new(template.to_string());
528
529        let mut context = HashMap::new();
530        context.insert(
531            "title".to_string(),
532            QuillValue::from_json(serde_json::json!("Test Document")),
533        );
534        context.insert(
535            "author".to_string(),
536            QuillValue::from_json(serde_json::json!("Jane Doe")),
537        );
538        context.insert(
539            "date".to_string(),
540            QuillValue::from_json(serde_json::json!("2024-01-01")),
541        );
542        context.insert(
543            "body".to_string(),
544            QuillValue::from_json(serde_json::json!("Document body")),
545        );
546
547        let result = glue.compose(context).unwrap();
548
549        // All metadata fields should be present as keys
550        assert!(result.contains("title"));
551        assert!(result.contains("author"));
552        assert!(result.contains("date"));
553        // Body should not be in metadata iteration
554        assert!(!result.contains("body"));
555    }
556
557    #[test]
558    fn test_metadata_field_empty_when_only_body() {
559        let template = "Metadata count: {{ __metadata__ | length }}";
560        let mut glue = Glue::new(template.to_string());
561
562        let mut context = HashMap::new();
563        context.insert(
564            "body".to_string(),
565            QuillValue::from_json(serde_json::json!("Only body content")),
566        );
567
568        let result = glue.compose(context).unwrap();
569
570        // Should have 0 metadata fields when only body is present
571        assert!(result.contains("Metadata count: 0"));
572    }
573
574    #[test]
575    fn test_backward_compatibility_top_level_access() {
576        let template = "Title: {{ title }}, Author: {{ author }}, Body: {{ body }}";
577        let mut glue = Glue::new(template.to_string());
578
579        let mut context = HashMap::new();
580        context.insert(
581            "title".to_string(),
582            QuillValue::from_json(serde_json::json!("My Title")),
583        );
584        context.insert(
585            "author".to_string(),
586            QuillValue::from_json(serde_json::json!("Author Name")),
587        );
588        context.insert(
589            "body".to_string(),
590            QuillValue::from_json(serde_json::json!("Body text")),
591        );
592
593        let result = glue.compose(context).unwrap();
594
595        // Top-level access should still work
596        assert!(result.contains("Title: My Title"));
597        assert!(result.contains("Author: Author Name"));
598        assert!(result.contains("Body: Body text"));
599    }
600
601    #[test]
602    fn test_metadata_iteration_in_template() {
603        let template = r#"
604{%- set metadata_count = __metadata__ | length -%}
605Metadata fields: {{ metadata_count }}
606{%- for key in __metadata__ %}
607- {{ key }}: {{ __metadata__[key] }}
608{%- endfor %}
609Body present: {{ body | length > 0 }}
610"#;
611        let mut glue = Glue::new(template.to_string());
612
613        let mut context = HashMap::new();
614        context.insert(
615            "title".to_string(),
616            QuillValue::from_json(serde_json::json!("Test")),
617        );
618        context.insert(
619            "version".to_string(),
620            QuillValue::from_json(serde_json::json!("1.0")),
621        );
622        context.insert(
623            "body".to_string(),
624            QuillValue::from_json(serde_json::json!("Content")),
625        );
626
627        let result = glue.compose(context).unwrap();
628
629        // Should have exactly 2 metadata fields
630        assert!(result.contains("Metadata fields: 2"));
631        // Body should still be accessible directly
632        assert!(result.contains("Body present: true"));
633    }
634
635    #[test]
636    fn test_auto_glue_metadata_field() {
637        let mut glue = Glue::new_auto();
638
639        let mut context = HashMap::new();
640        context.insert(
641            "title".to_string(),
642            QuillValue::from_json(serde_json::json!("Document")),
643        );
644        context.insert(
645            "author".to_string(),
646            QuillValue::from_json(serde_json::json!("Writer")),
647        );
648        context.insert(
649            "body".to_string(),
650            QuillValue::from_json(serde_json::json!("Content here")),
651        );
652
653        let result = glue.compose(context).unwrap();
654
655        // Parse as JSON
656        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
657
658        // Verify __metadata__ field exists and contains correct fields
659        assert!(json["__metadata__"].is_object());
660        assert_eq!(json["__metadata__"]["title"], "Document");
661        assert_eq!(json["__metadata__"]["author"], "Writer");
662
663        // Body should not be in metadata
664        assert!(json["__metadata__"]["body"].is_null());
665
666        // But body should be at top level
667        assert_eq!(json["body"], "Content here");
668    }
669
670    #[test]
671    fn test_metadata_with_nested_objects() {
672        let template = "{{ __metadata__.author.name }}";
673        let mut glue = Glue::new(template.to_string());
674
675        let mut context = HashMap::new();
676        context.insert(
677            "author".to_string(),
678            QuillValue::from_json(serde_json::json!({
679                "name": "John Doe",
680                "email": "john@example.com"
681            })),
682        );
683        context.insert(
684            "body".to_string(),
685            QuillValue::from_json(serde_json::json!("Text")),
686        );
687
688        let result = glue.compose(context).unwrap();
689
690        // Should access nested metadata via __metadata__
691        assert!(result.contains("John Doe"));
692    }
693
694    #[test]
695    fn test_metadata_with_arrays() {
696        let template = "Tags: {{ __metadata__.tags | length }}";
697        let mut glue = Glue::new(template.to_string());
698
699        let mut context = HashMap::new();
700        context.insert(
701            "tags".to_string(),
702            QuillValue::from_json(serde_json::json!(["rust", "markdown", "template"])),
703        );
704        context.insert(
705            "body".to_string(),
706            QuillValue::from_json(serde_json::json!("Content")),
707        );
708
709        let result = glue.compose(context).unwrap();
710
711        // Should show 3 tags
712        assert!(result.contains("Tags: 3"));
713    }
714}