quillmark_core/
templating.rs1use std::collections::HashMap;
87use std::error::Error as StdError;
88
89use minijinja::{Environment, Error as MjError};
90use serde_yaml;
91
92#[derive(thiserror::Error, Debug)]
94pub enum TemplateError {
95 #[error("{0}")]
97 RenderError(#[from] minijinja::Error),
98 #[error("{0}")]
100 InvalidTemplate(String, #[source] Box<dyn StdError + Send + Sync>),
101 #[error("{0}")]
103 FilterError(String),
104}
105
106pub mod filter_api {
108 pub use minijinja::value::{Kwargs, Value};
109 pub use minijinja::{Error, ErrorKind, State};
110
111 pub trait DynFilter: Send + Sync + 'static {}
113 impl<T> DynFilter for T where T: Send + Sync + 'static {}
114}
115
116type FilterFn = fn(
118 &filter_api::State,
119 filter_api::Value,
120 filter_api::Kwargs,
121) -> Result<filter_api::Value, MjError>;
122
123pub struct Glue {
125 template: String,
126 filters: HashMap<String, FilterFn>,
127}
128
129impl Glue {
130 pub fn new(template: String) -> Self {
132 Self {
133 template,
134 filters: HashMap::new(),
135 }
136 }
137
138 pub fn register_filter(&mut self, name: &str, func: FilterFn) {
140 self.filters.insert(name.to_string(), func);
141 }
142
143 pub fn compose(
145 &mut self,
146 context: HashMap<String, serde_yaml::Value>,
147 ) -> Result<String, TemplateError> {
148 let context = convert_yaml_to_minijinja(context)?;
150
151 let mut env = Environment::new();
153
154 for (name, filter_fn) in &self.filters {
156 let filter_fn = *filter_fn; 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 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 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
184fn 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
198fn 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 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 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 let template = "{{ content }}".to_string();
323 let mut glue = Glue::new(template);
324
325 let mut context = HashMap::new();
326 context.insert(
330 "content".to_string(),
331 serde_yaml::Value::String("test".to_string()),
332 );
333
334 let result = glue.compose(context);
335 assert!(result.is_ok());
337 }
338}