Skip to main content

greentic_flow/
template.rs

1use handlebars::{
2    Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError, RenderErrorReason,
3    Renderable,
4};
5use regex::Regex;
6use serde_json::{Map, Value};
7
8use crate::error::{FlowError, FlowErrorLocation, Result};
9
10const STATE_TOKEN_PREFIX: &str = "__STATE_TOKEN__";
11const STATE_TOKEN_SUFFIX: &str = "__";
12
13pub struct TemplateRenderer {
14    handlebars: Handlebars<'static>,
15    manifest_id: Option<String>,
16}
17
18impl TemplateRenderer {
19    pub fn new(manifest_id: Option<String>) -> Self {
20        let mut handlebars = Handlebars::new();
21        handlebars.register_escape_fn(|s| s.to_string());
22        handlebars.register_helper("json", Box::new(JsonHelper));
23        handlebars.register_helper("default", Box::new(DefaultHelper));
24        handlebars.register_helper("ifEq", Box::new(IfEqHelper));
25        Self {
26            handlebars,
27            manifest_id,
28        }
29    }
30
31    pub fn render_json(
32        &self,
33        template: &str,
34        state: &Map<String, Value>,
35        node_id: &str,
36    ) -> Result<Value> {
37        let preprocessed = preprocess_template(template);
38        let mut ctx = Map::new();
39        ctx.insert("state".to_string(), Value::Object(state.clone()));
40        let rendered = self
41            .handlebars
42            .render_template(&preprocessed, &ctx)
43            .map_err(|e| FlowError::Internal {
44                message: format!(
45                    "template render error in node '{node_id}'{}: {e}",
46                    manifest_label(self.manifest_id.as_deref())
47                ),
48                location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
49            })?;
50        let mut value: Value =
51            serde_json::from_str(&rendered).map_err(|e| FlowError::Internal {
52                message: format!(
53                    "template JSON parse error in node '{node_id}'{}: {e}",
54                    manifest_label(self.manifest_id.as_deref())
55                ),
56                location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
57            })?;
58        substitute_state_tokens(&mut value, state).map_err(|e| FlowError::Internal {
59            message: format!(
60                "{e} (node '{node_id}'{})",
61                manifest_label(self.manifest_id.as_deref())
62            ),
63            location: FlowErrorLocation::at_path(format!("nodes.{node_id}.template")),
64        })?;
65        Ok(value)
66    }
67}
68
69fn manifest_label(manifest_id: Option<&str>) -> String {
70    manifest_id
71        .map(|id| format!(" in manifest '{id}'"))
72        .unwrap_or_default()
73}
74
75fn preprocess_template(template: &str) -> String {
76    let re = Regex::new(r"\{\{\s*state\.([A-Za-z_]\w*)\s*\}\}").unwrap();
77    re.replace_all(template, |caps: &regex::Captures<'_>| {
78        state_token_value(caps.get(1).unwrap().as_str())
79    })
80    .to_string()
81}
82
83fn state_token_value(key: &str) -> String {
84    format!("{STATE_TOKEN_PREFIX}{key}{STATE_TOKEN_SUFFIX}")
85}
86
87fn substitute_state_tokens(
88    target: &mut Value,
89    state: &Map<String, Value>,
90) -> std::result::Result<(), String> {
91    match target {
92        Value::String(s) => {
93            if let Some(key) = s
94                .strip_prefix(STATE_TOKEN_PREFIX)
95                .and_then(|rest| rest.strip_suffix(STATE_TOKEN_SUFFIX))
96            {
97                let value = state
98                    .get(key)
99                    .ok_or_else(|| format!("state value for '{key}' not found"))?;
100                *target = value.clone();
101            }
102            Ok(())
103        }
104        Value::Array(items) => {
105            for item in items {
106                substitute_state_tokens(item, state)?;
107            }
108            Ok(())
109        }
110        Value::Object(map) => {
111            for value in map.values_mut() {
112                substitute_state_tokens(value, state)?;
113            }
114            Ok(())
115        }
116        _ => Ok(()),
117    }
118}
119
120struct JsonHelper;
121
122impl HelperDef for JsonHelper {
123    fn call<'reg: 'rc, 'rc>(
124        &self,
125        helper: &Helper<'rc>,
126        _: &'reg Handlebars<'reg>,
127        _: &'rc Context,
128        _: &mut RenderContext<'reg, 'rc>,
129        out: &mut dyn Output,
130    ) -> std::result::Result<(), RenderError> {
131        let value = helper
132            .param(0)
133            .map(|p| p.value().clone())
134            .ok_or_else(|| helper_error("json helper expects 1 parameter"))?;
135        let rendered = serde_json::to_string(&value)
136            .map_err(|e| helper_error(&format!("json helper: {e}")))?;
137        out.write(&rendered)?;
138        Ok(())
139    }
140}
141
142struct DefaultHelper;
143
144impl HelperDef for DefaultHelper {
145    fn call<'reg: 'rc, 'rc>(
146        &self,
147        helper: &Helper<'rc>,
148        _: &'reg Handlebars<'reg>,
149        _: &'rc Context,
150        _: &mut RenderContext<'reg, 'rc>,
151        out: &mut dyn Output,
152    ) -> std::result::Result<(), RenderError> {
153        let value = helper.param(0).map(|p| p.value().clone());
154        let fallback = helper
155            .param(1)
156            .map(|p| p.value().clone())
157            .ok_or_else(|| helper_error("default helper expects 2 parameters"))?;
158        let use_fallback = matches!(value.as_ref(), None | Some(Value::Null))
159            || matches!(value.as_ref(), Some(Value::String(s)) if s.is_empty());
160        let rendered_value = if use_fallback {
161            fallback
162        } else {
163            value.unwrap()
164        };
165        let rendered = serde_json::to_string(&rendered_value)
166            .map_err(|e| helper_error(&format!("default helper: {e}")))?;
167        out.write(&rendered)?;
168        Ok(())
169    }
170}
171
172struct IfEqHelper;
173
174impl HelperDef for IfEqHelper {
175    fn call<'reg: 'rc, 'rc>(
176        &self,
177        helper: &Helper<'rc>,
178        r: &'reg Handlebars<'reg>,
179        ctx: &'rc Context,
180        rc: &mut RenderContext<'reg, 'rc>,
181        out: &mut dyn Output,
182    ) -> std::result::Result<(), RenderError> {
183        let left = helper
184            .param(0)
185            .map(|p| p.value().clone())
186            .ok_or_else(|| helper_error("ifEq helper expects 2 parameters"))?;
187        let right = helper
188            .param(1)
189            .map(|p| p.value().clone())
190            .ok_or_else(|| helper_error("ifEq helper expects 2 parameters"))?;
191        let matches = left == right;
192        if matches {
193            if let Some(t) = helper.template() {
194                t.render(r, ctx, rc, out)?;
195            }
196        } else if let Some(t) = helper.inverse() {
197            t.render(r, ctx, rc, out)?;
198        }
199        Ok(())
200    }
201}
202
203fn helper_error(message: &str) -> RenderError {
204    RenderErrorReason::Other(message.to_string()).into()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use serde_json::json;
211
212    fn render(template: &str, state: Map<String, Value>) -> Value {
213        let renderer = TemplateRenderer::new(Some("manifest.test".to_string()));
214        renderer
215            .render_json(template, &state, "emit_config")
216            .unwrap()
217    }
218
219    #[test]
220    fn if_truthy_renders_block() {
221        let mut state = Map::new();
222        state.insert("needs_interaction".to_string(), Value::Bool(true));
223        let template = r#"{ "enabled": {{#if state.needs_interaction}}true{{/if}} }"#;
224        let value = render(template, state);
225        assert_eq!(value.get("enabled"), Some(&Value::Bool(true)));
226    }
227
228    #[test]
229    fn ifeq_matches_string_and_bool() {
230        let mut state = Map::new();
231        state.insert("mode".to_string(), Value::String("asset".to_string()));
232        state.insert("flag".to_string(), Value::Bool(false));
233        let template = r#"
234        {
235          "mode": {{#ifEq state.mode "asset"}} "asset" {{else}} "inline" {{/ifEq}},
236          "flagged": {{#ifEq state.flag false}} true {{else}} false {{/ifEq}}
237        }"#;
238        let value = render(template, state);
239        assert_eq!(value.get("mode"), Some(&Value::String("asset".to_string())));
240        assert_eq!(value.get("flagged"), Some(&Value::Bool(true)));
241    }
242
243    #[test]
244    fn json_helper_emits_raw_json() {
245        let mut state = Map::new();
246        state.insert("inline_json".to_string(), json!({"a": 1, "b": [true]}));
247        let template = r#"{ "inline": {{json state.inline_json}} }"#;
248        let value = render(template, state);
249        assert_eq!(value.get("inline"), Some(&json!({"a": 1, "b": [true]})));
250    }
251
252    #[test]
253    fn preserves_simple_state_interpolation() {
254        let mut state = Map::new();
255        state.insert("temperature".to_string(), json!(0.4));
256        let template = r#"{ "temperature": "{{state.temperature}}" }"#;
257        let value = render(template, state);
258        assert_eq!(value.get("temperature"), Some(&json!(0.4)));
259    }
260}