use anyhow::Result;
use rhai::Dynamic;
use std::collections::HashMap;
pub fn render_template(
template: &str,
variables: &HashMap<String, String>,
step_results: &HashMap<String, String>,
) -> Result<String> {
let mut result = template.to_string();
let filter_regex =
regex::Regex::new(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\|\s*([a-zA-Z_]+)\s*\}\}").unwrap();
result = filter_regex
.replace_all(&result, |caps: ®ex::Captures| {
let var_name = &caps[1];
let filter_name = &caps[2];
let value = variables
.get(var_name)
.or_else(|| variables.get(&format!("workload.{}", var_name)))
.map(|s| s.as_str())
.unwrap_or("");
match filter_name {
"lower" => value.to_lowercase(),
"upper" => value.to_uppercase(),
"trim" => value.trim().to_string(),
"default" => {
if value.is_empty() {
"".to_string()
} else {
value.to_string()
}
}
_ => value.to_string(),
}
})
.to_string();
for (key, value) in variables {
if key.starts_with("workload.") {
let placeholder = format!("{{{{ {} }}}}", key);
result = result.replace(&placeholder, value);
}
}
for (key, value) in variables {
if key.starts_with("vars.") {
let placeholder = format!("{{{{ {} }}}}", key);
result = result.replace(&placeholder, value);
}
}
for (step_name, value) in step_results {
let placeholder = format!("{{{{ {}.result }}}}", step_name);
result = result.replace(&placeholder, value);
}
for (key, value) in variables {
let placeholder = format!("{{{{ {} }}}}", key);
result = result.replace(&placeholder, value);
}
Ok(result.trim().to_string())
}
pub fn render_template_with_result(
template: &str,
variables: &HashMap<String, String>,
step_results: &HashMap<String, String>,
result_json: Option<&serde_json::Value>,
) -> Result<String> {
let mut output = template.to_string();
let result_regex = regex::Regex::new(
r"\{\{\s*result\.([a-zA-Z0-9_.\[\]]+)\s*(?:\|\s*([a-zA-Z_]+(?:\([^)]*\))?))?\s*\}\}",
)
.unwrap();
output = result_regex
.replace_all(&output, |caps: ®ex::Captures| {
let path = &caps[1];
let filter = caps.get(2).map(|m| m.as_str());
if let Some(json) = result_json {
let value = get_json_path(json, path);
let value_str = match &value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Null => "".to_string(),
other => other.to_string(),
};
if let Some(f) = filter {
if f == "default" || f.starts_with("default(") {
if value_str.is_empty() || value_str == "null" {
if let Some(start) = f.find('(') {
let inner = &f[start + 1..f.len() - 1];
inner.trim_matches(|c| c == '\'' || c == '"').to_string()
} else {
"".to_string()
}
} else {
value_str
}
} else {
value_str
}
} else {
value_str
}
} else {
"".to_string()
}
})
.to_string();
render_template(&output, variables, step_results)
}
pub fn get_json_path(json: &serde_json::Value, path: &str) -> serde_json::Value {
let parts: Vec<&str> = path.split('.').collect();
let mut current = json.clone();
for part in parts {
if part.contains('[') {
if let Some(bracket_pos) = part.find('[') {
let key = &part[..bracket_pos];
let idx_str = &part[bracket_pos + 1..part.len() - 1];
if !key.is_empty() {
current = current.get(key).cloned().unwrap_or(serde_json::Value::Null);
}
if let Ok(idx) = idx_str.parse::<usize>() {
current = current.get(idx).cloned().unwrap_or(serde_json::Value::Null);
}
}
} else {
current = current.get(part).cloned().unwrap_or(serde_json::Value::Null);
}
}
current
}
pub fn json_to_rhai(value: &serde_json::Value) -> Dynamic {
use rhai::{Array, Map};
match value {
serde_json::Value::Null => Dynamic::UNIT,
serde_json::Value::Bool(b) => Dynamic::from(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Dynamic::from(i)
} else if let Some(f) = n.as_f64() {
Dynamic::from(f)
} else {
Dynamic::from(n.to_string())
}
}
serde_json::Value::String(s) => Dynamic::from(s.clone()),
serde_json::Value::Array(arr) => {
let mut rhai_array = Array::new();
for item in arr {
rhai_array.push(json_to_rhai(item));
}
Dynamic::from(rhai_array)
}
serde_json::Value::Object(obj) => {
let mut rhai_map = Map::new();
for (k, v) in obj {
rhai_map.insert(k.to_string().into(), json_to_rhai(v));
}
Dynamic::from(rhai_map)
}
}
}
pub fn rhai_to_json_string(value: &Dynamic) -> String {
if value.is_unit() {
"null".to_string()
} else if let Some(b) = value.clone().try_cast::<bool>() {
b.to_string()
} else if let Some(i) = value.clone().try_cast::<i64>() {
i.to_string()
} else if let Some(f) = value.clone().try_cast::<f64>() {
f.to_string()
} else if let Some(s) = value.clone().try_cast::<String>() {
format!("\"{}\"", s)
} else if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
let items: Vec<String> = arr.iter().map(rhai_to_json_string).collect();
format!("[{}]", items.join(","))
} else if let Some(map) = value.clone().try_cast::<rhai::Map>() {
let pairs: Vec<String> = map
.iter()
.map(|(k, v)| format!("\"{}\":{}", k, rhai_to_json_string(v)))
.collect();
format!("{{{}}}", pairs.join(","))
} else {
format!("{:?}", value)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn render_template_substitutes_workload_var() {
let variables = vars(&[("workload.cluster", "noetl")]);
let step_results = HashMap::new();
let result = render_template(
"kind load docker-image noetl:latest --name {{ workload.cluster }}",
&variables,
&step_results,
)
.unwrap();
assert_eq!(result, "kind load docker-image noetl:latest --name noetl");
}
#[test]
fn render_template_applies_lower_filter() {
let variables = vars(&[("workload.NAME", "NOETL")]);
let step_results = HashMap::new();
let result = render_template(
"{{ workload.NAME | lower }}",
&variables,
&step_results,
)
.unwrap();
assert_eq!(result, "noetl");
}
#[test]
fn render_template_with_result_navigates_json() {
let variables = HashMap::new();
let step_results = HashMap::new();
let json = serde_json::json!({"body": {"name": "noetl"}});
let result =
render_template_with_result("name = {{ result.body.name }}", &variables, &step_results, Some(&json))
.unwrap();
assert_eq!(result, "name = noetl");
}
#[test]
fn get_json_path_handles_array_index() {
let json = serde_json::json!({"items": [{"id": 42}, {"id": 99}]});
assert_eq!(get_json_path(&json, "items[0].id"), serde_json::json!(42));
assert_eq!(get_json_path(&json, "items[1].id"), serde_json::json!(99));
}
#[test]
fn json_to_rhai_round_trip_through_rhai_to_json_string() {
let original = serde_json::json!({"key": "value"});
let rhai = json_to_rhai(&original);
let stringified = rhai_to_json_string(&rhai);
assert!(stringified.starts_with('{'));
assert!(stringified.ends_with('}'));
assert!(stringified.contains("\"key\""));
assert!(stringified.contains("\"value\""));
}
}