quillmark_core/
templating.rs1use std::collections::HashMap;
2use std::error::Error as StdError;
3
4use minijinja::{Environment, Error as MjError};
5use serde_yaml;
6
7#[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
18pub mod filter_api {
20 pub use minijinja::value::{Kwargs, Value};
21 pub use minijinja::{Error, ErrorKind, State};
22
23 pub trait DynFilter: Send + Sync + 'static {}
25 impl<T> DynFilter for T where T: Send + Sync + 'static {}
26}
27
28type FilterFn = fn(
30 &filter_api::State,
31 filter_api::Value,
32 filter_api::Kwargs,
33) -> Result<filter_api::Value, MjError>;
34
35pub struct Glue {
37 template: String,
38 filters: HashMap<String, FilterFn>,
39}
40
41impl Glue {
42 pub fn new(template: String) -> Self {
44 Self {
45 template,
46 filters: HashMap::new(),
47 }
48 }
49
50 pub fn register_filter(&mut self, name: &str, func: FilterFn) {
52 self.filters.insert(name.to_string(), func);
53 }
54
55 pub fn compose(
57 &mut self,
58 context: HashMap<String, serde_yaml::Value>,
59 ) -> Result<String, TemplateError> {
60 let context = convert_yaml_to_minijinja(context)?;
62
63 let mut env = Environment::new();
65
66 for (name, filter_fn) in &self.filters {
68 let filter_fn = *filter_fn; 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 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
86fn 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
100fn 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 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 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}