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 Ok(result)
171 }
172}
173
174fn convert_yaml_to_minijinja(
176 yaml: HashMap<String, serde_yaml::Value>,
177) -> Result<HashMap<String, minijinja::value::Value>, TemplateError> {
178 let mut result = HashMap::new();
179
180 for (key, value) in yaml {
181 let minijinja_value = yaml_to_minijinja_value(value)?;
182 result.insert(key, minijinja_value);
183 }
184
185 Ok(result)
186}
187
188fn yaml_to_minijinja_value(
190 value: serde_yaml::Value,
191) -> Result<minijinja::value::Value, TemplateError> {
192 use minijinja::value::Value as MjValue;
193 use serde_yaml::Value as YamlValue;
194
195 let result = match value {
196 YamlValue::Null => MjValue::from(()),
197 YamlValue::Bool(b) => MjValue::from(b),
198 YamlValue::Number(n) => {
199 if let Some(i) = n.as_i64() {
200 MjValue::from(i)
201 } else if let Some(u) = n.as_u64() {
202 MjValue::from(u)
203 } else if let Some(f) = n.as_f64() {
204 MjValue::from(f)
205 } else {
206 return Err(TemplateError::FilterError(
207 "Invalid number in YAML".to_string(),
208 ));
209 }
210 }
211 YamlValue::String(s) => MjValue::from(s),
212 YamlValue::Sequence(seq) => {
213 let mut vec = Vec::new();
214 for item in seq {
215 vec.push(yaml_to_minijinja_value(item)?);
216 }
217 MjValue::from(vec)
218 }
219 YamlValue::Mapping(map) => {
220 let mut obj = std::collections::BTreeMap::new();
221 for (k, v) in map {
222 let key = match k {
223 YamlValue::String(s) => s,
224 _ => {
225 return Err(TemplateError::FilterError(
226 "Non-string key in YAML mapping".to_string(),
227 ))
228 }
229 };
230 obj.insert(key, yaml_to_minijinja_value(v)?);
231 }
232 MjValue::from_object(obj)
233 }
234 YamlValue::Tagged(tagged) => {
235 yaml_to_minijinja_value(tagged.value)?
237 }
238 };
239
240 Ok(result)
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use std::collections::HashMap;
247
248 #[test]
249 fn test_glue_creation() {
250 let _glue = Glue::new("Hello {{ name }}".to_string());
251 assert!(true);
252 }
253
254 #[test]
255 fn test_compose_simple_template() {
256 let mut glue = Glue::new("Hello {{ name }}! Body: {{ body }}".to_string());
257 let mut context = HashMap::new();
258 context.insert(
259 "name".to_string(),
260 serde_yaml::Value::String("World".to_string()),
261 );
262 context.insert(
263 "body".to_string(),
264 serde_yaml::Value::String("Hello content".to_string()),
265 );
266
267 let result = glue.compose(context).unwrap();
268 assert!(result.contains("Hello World!"));
269 assert!(result.contains("Body: Hello content"));
270 }
271
272 #[test]
273 fn test_field_with_dash() {
274 let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
275 let mut context = HashMap::new();
276 context.insert(
277 "letterhead_title".to_string(),
278 serde_yaml::Value::String("TEST VALUE".to_string()),
279 );
280 context.insert(
281 "body".to_string(),
282 serde_yaml::Value::String("body".to_string()),
283 );
284
285 let result = glue.compose(context).unwrap();
286 assert!(result.contains("TEST VALUE"));
287 }
288
289 #[test]
290 fn test_compose_with_dash_in_template() {
291 let mut glue = Glue::new("Field: {{ letterhead_title }}".to_string());
293 let mut context = HashMap::new();
294 context.insert(
295 "letterhead_title".to_string(),
296 serde_yaml::Value::String("DASHED".to_string()),
297 );
298 context.insert(
299 "body".to_string(),
300 serde_yaml::Value::String("body".to_string()),
301 );
302
303 let result = glue.compose(context).unwrap();
304 assert!(result.contains("DASHED"));
305 }
306}