Skip to main content

noetl_executor/
template.rs

1//! Template rendering — `{{ workload.var }}` / `{{ step.result }}` /
2//! `{{ result.path }}` substitution with a minimal filter set.
3//!
4//! Extracted from `repos/cli/src/playbook_runner.rs` lines 1448-2006
5//! in R-1.1 PR-2b per § H.10.3 of Appendix H of the global hybrid
6//! cloud blueprint.  The CLI's tree walker (`playbook_runner.rs`)
7//! and the worker's NATS-mode runner both render templates the same
8//! way; this module is the shared implementation they call into.
9//!
10//! Functions take `&HashMap<String, String>` slices of the
11//! per-execution variables and step results so each binary owns its
12//! own context shape but feeds the same data into rendering.
13
14use anyhow::Result;
15use rhai::Dynamic;
16use std::collections::HashMap;
17
18/// Render a template by substituting `{{ var }}`, `{{ var | filter }}`,
19/// `{{ workload.var }}`, `{{ vars.var }}`, and `{{ step.result }}`
20/// references against the supplied variable + step-result maps.
21///
22/// Supported filters: `lower`, `upper`, `trim`, `default`.
23pub fn render_template(
24    template: &str,
25    variables: &HashMap<String, String>,
26    step_results: &HashMap<String, String>,
27) -> Result<String> {
28    let mut result = template.to_string();
29
30    // First, handle templates with filters (e.g., {{ workload.var | lower }}).
31    let filter_regex =
32        regex::Regex::new(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\|\s*([a-zA-Z_]+)\s*\}\}").unwrap();
33    result = filter_regex
34        .replace_all(&result, |caps: &regex::Captures| {
35            let var_name = &caps[1];
36            let filter_name = &caps[2];
37
38            // Try to find the variable value.
39            let value = variables
40                .get(var_name)
41                .or_else(|| variables.get(&format!("workload.{}", var_name)))
42                .map(|s| s.as_str())
43                .unwrap_or("");
44
45            // Apply the filter.
46            match filter_name {
47                "lower" => value.to_lowercase(),
48                "upper" => value.to_uppercase(),
49                "trim" => value.trim().to_string(),
50                "default" => {
51                    if value.is_empty() {
52                        "".to_string()
53                    } else {
54                        value.to_string()
55                    }
56                }
57                _ => value.to_string(),
58            }
59        })
60        .to_string();
61
62    // Handle workload.* variables.
63    for (key, value) in variables {
64        if key.starts_with("workload.") {
65            let placeholder = format!("{{{{ {} }}}}", key);
66            result = result.replace(&placeholder, value);
67        }
68    }
69
70    // Handle vars.* variables.
71    for (key, value) in variables {
72        if key.starts_with("vars.") {
73            let placeholder = format!("{{{{ {} }}}}", key);
74            result = result.replace(&placeholder, value);
75        }
76    }
77
78    // Handle step_name.result variables.
79    for (step_name, value) in step_results {
80        let placeholder = format!("{{{{ {}.result }}}}", step_name);
81        result = result.replace(&placeholder, value);
82    }
83
84    // Also support direct {{ variable }} lookups.
85    for (key, value) in variables {
86        let placeholder = format!("{{{{ {} }}}}", key);
87        result = result.replace(&placeholder, value);
88    }
89
90    Ok(result.trim().to_string())
91}
92
93/// Render a template that may reference `{{ result.path }}` against the
94/// supplied JSON result value (e.g. the previous step's HTTP response
95/// body), then apply the regular [`render_template`] pass for the
96/// rest of the references.
97pub fn render_template_with_result(
98    template: &str,
99    variables: &HashMap<String, String>,
100    step_results: &HashMap<String, String>,
101    result_json: Option<&serde_json::Value>,
102) -> Result<String> {
103    let mut output = template.to_string();
104
105    // Handle result.path expressions like {{ result.status }},
106    // {{ result.body.name }}.
107    let result_regex = regex::Regex::new(
108        r"\{\{\s*result\.([a-zA-Z0-9_.\[\]]+)\s*(?:\|\s*([a-zA-Z_]+(?:\([^)]*\))?))?\s*\}\}",
109    )
110    .unwrap();
111
112    output = result_regex
113        .replace_all(&output, |caps: &regex::Captures| {
114            let path = &caps[1];
115            let filter = caps.get(2).map(|m| m.as_str());
116
117            if let Some(json) = result_json {
118                // Navigate the JSON path.
119                let value = get_json_path(json, path);
120                let value_str = match &value {
121                    serde_json::Value::String(s) => s.clone(),
122                    serde_json::Value::Number(n) => n.to_string(),
123                    serde_json::Value::Bool(b) => b.to_string(),
124                    serde_json::Value::Null => "".to_string(),
125                    other => other.to_string(),
126                };
127
128                // Apply filter if present.
129                if let Some(f) = filter {
130                    if f == "default" || f.starts_with("default(") {
131                        if value_str.is_empty() || value_str == "null" {
132                            // Extract default value from default('value') or default("").
133                            if let Some(start) = f.find('(') {
134                                let inner = &f[start + 1..f.len() - 1];
135                                inner.trim_matches(|c| c == '\'' || c == '"').to_string()
136                            } else {
137                                "".to_string()
138                            }
139                        } else {
140                            value_str
141                        }
142                    } else {
143                        value_str
144                    }
145                } else {
146                    value_str
147                }
148            } else {
149                "".to_string()
150            }
151        })
152        .to_string();
153
154    // Then apply normal template rendering for other variables.
155    render_template(&output, variables, step_results)
156}
157
158/// Get a value from JSON using a path like `"status"`, `"body.name"`,
159/// or `"items[0].id"`.
160pub fn get_json_path(json: &serde_json::Value, path: &str) -> serde_json::Value {
161    let parts: Vec<&str> = path.split('.').collect();
162    let mut current = json.clone();
163
164    for part in parts {
165        // Handle array index notation like items[0].
166        if part.contains('[') {
167            if let Some(bracket_pos) = part.find('[') {
168                let key = &part[..bracket_pos];
169                let idx_str = &part[bracket_pos + 1..part.len() - 1];
170
171                if !key.is_empty() {
172                    current = current.get(key).cloned().unwrap_or(serde_json::Value::Null);
173                }
174
175                if let Ok(idx) = idx_str.parse::<usize>() {
176                    current = current.get(idx).cloned().unwrap_or(serde_json::Value::Null);
177                }
178            }
179        } else {
180            current = current.get(part).cloned().unwrap_or(serde_json::Value::Null);
181        }
182    }
183
184    current
185}
186
187/// Convert a `serde_json::Value` into a `rhai::Dynamic` for scripting.
188pub fn json_to_rhai(value: &serde_json::Value) -> Dynamic {
189    use rhai::{Array, Map};
190
191    match value {
192        serde_json::Value::Null => Dynamic::UNIT,
193        serde_json::Value::Bool(b) => Dynamic::from(*b),
194        serde_json::Value::Number(n) => {
195            if let Some(i) = n.as_i64() {
196                Dynamic::from(i)
197            } else if let Some(f) = n.as_f64() {
198                Dynamic::from(f)
199            } else {
200                Dynamic::from(n.to_string())
201            }
202        }
203        serde_json::Value::String(s) => Dynamic::from(s.clone()),
204        serde_json::Value::Array(arr) => {
205            let mut rhai_array = Array::new();
206            for item in arr {
207                rhai_array.push(json_to_rhai(item));
208            }
209            Dynamic::from(rhai_array)
210        }
211        serde_json::Value::Object(obj) => {
212            let mut rhai_map = Map::new();
213            for (k, v) in obj {
214                rhai_map.insert(k.to_string().into(), json_to_rhai(v));
215            }
216            Dynamic::from(rhai_map)
217        }
218    }
219}
220
221/// Stringify a `rhai::Dynamic` value into a JSON-shaped string for
222/// embedding into rendered output.
223pub fn rhai_to_json_string(value: &Dynamic) -> String {
224    if value.is_unit() {
225        "null".to_string()
226    } else if let Some(b) = value.clone().try_cast::<bool>() {
227        b.to_string()
228    } else if let Some(i) = value.clone().try_cast::<i64>() {
229        i.to_string()
230    } else if let Some(f) = value.clone().try_cast::<f64>() {
231        f.to_string()
232    } else if let Some(s) = value.clone().try_cast::<String>() {
233        // Quote string values.
234        format!("\"{}\"", s)
235    } else if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
236        let items: Vec<String> = arr.iter().map(rhai_to_json_string).collect();
237        format!("[{}]", items.join(","))
238    } else if let Some(map) = value.clone().try_cast::<rhai::Map>() {
239        let pairs: Vec<String> = map
240            .iter()
241            .map(|(k, v)| format!("\"{}\":{}", k, rhai_to_json_string(v)))
242            .collect();
243        format!("{{{}}}", pairs.join(","))
244    } else {
245        // Fallback to debug representation.
246        format!("{:?}", value)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
255        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
256    }
257
258    #[test]
259    fn render_template_substitutes_workload_var() {
260        let variables = vars(&[("workload.cluster", "noetl")]);
261        let step_results = HashMap::new();
262        let result = render_template(
263            "kind load docker-image noetl:latest --name {{ workload.cluster }}",
264            &variables,
265            &step_results,
266        )
267        .unwrap();
268        assert_eq!(result, "kind load docker-image noetl:latest --name noetl");
269    }
270
271    #[test]
272    fn render_template_applies_lower_filter() {
273        let variables = vars(&[("workload.NAME", "NOETL")]);
274        let step_results = HashMap::new();
275        let result = render_template(
276            "{{ workload.NAME | lower }}",
277            &variables,
278            &step_results,
279        )
280        .unwrap();
281        assert_eq!(result, "noetl");
282    }
283
284    #[test]
285    fn render_template_with_result_navigates_json() {
286        let variables = HashMap::new();
287        let step_results = HashMap::new();
288        let json = serde_json::json!({"body": {"name": "noetl"}});
289        let result =
290            render_template_with_result("name = {{ result.body.name }}", &variables, &step_results, Some(&json))
291                .unwrap();
292        assert_eq!(result, "name = noetl");
293    }
294
295    #[test]
296    fn get_json_path_handles_array_index() {
297        let json = serde_json::json!({"items": [{"id": 42}, {"id": 99}]});
298        assert_eq!(get_json_path(&json, "items[0].id"), serde_json::json!(42));
299        assert_eq!(get_json_path(&json, "items[1].id"), serde_json::json!(99));
300    }
301
302    #[test]
303    fn json_to_rhai_round_trip_through_rhai_to_json_string() {
304        let original = serde_json::json!({"key": "value"});
305        let rhai = json_to_rhai(&original);
306        let stringified = rhai_to_json_string(&rhai);
307        // Map ordering isn't guaranteed by rhai::Map, just confirm
308        // structural shape (one key, one quoted value).
309        assert!(stringified.starts_with('{'));
310        assert!(stringified.ends_with('}'));
311        assert!(stringified.contains("\"key\""));
312        assert!(stringified.contains("\"value\""));
313    }
314}