noetl_executor/
template.rs1use anyhow::Result;
15use rhai::Dynamic;
16use std::collections::HashMap;
17
18pub 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 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: ®ex::Captures| {
35 let var_name = &caps[1];
36 let filter_name = &caps[2];
37
38 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 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 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 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 for (step_name, value) in step_results {
80 let placeholder = format!("{{{{ {}.result }}}}", step_name);
81 result = result.replace(&placeholder, value);
82 }
83
84 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
93pub 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 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: ®ex::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 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 if let Some(f) = filter {
130 if f == "default" || f.starts_with("default(") {
131 if value_str.is_empty() || value_str == "null" {
132 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 render_template(&output, variables, step_results)
156}
157
158pub 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 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
187pub 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
221pub 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 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 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 assert!(stringified.starts_with('{'));
310 assert!(stringified.ends_with('}'));
311 assert!(stringified.contains("\"key\""));
312 assert!(stringified.contains("\"value\""));
313 }
314}