use handlebars::{Handlebars, no_escape};
use regex::Regex;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::core::types::{RuntimeSpec, StepExecutionResult};
use crate::template::helpers::resolve_helper;
pub(crate) fn resolve_template_variables(
value: &Value,
context: &HashMap<String, StepExecutionResult>,
specs: Option<&[RuntimeSpec]>,
) -> Value {
let template_context = build_template_context(context, specs);
resolve_template_variables_with_context(value, &template_context)
}
pub(crate) fn resolve_template_variables_with_context(
value: &Value,
template_context: &Value,
) -> Value {
match value {
Value::String(s) => {
let replaced = template_regex().replace_all(s, |caps: ®ex::Captures<'_>| {
let expr = caps.get(1).map(|m| m.as_str().trim()).unwrap_or_default();
resolve_expression(expr, template_context)
.unwrap_or_else(|| format!("{{{{{}}}}}", expr))
});
Value::String(replaced.into_owned())
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| resolve_template_variables_with_context(v, template_context))
.collect(),
),
Value::Object(obj) => {
let mut out = Map::new();
for (k, v) in obj {
out.insert(
k.clone(),
resolve_template_variables_with_context(v, template_context),
);
}
Value::Object(out)
}
_ => value.clone(),
}
}
pub(crate) fn resolve_expression(expression: &str, template_context: &Value) -> Option<String> {
if expression.starts_with("helpers.") {
let helper_expr = expression.trim_start_matches("helpers.");
return resolve_helper(helper_expr);
}
let normalized_expression = normalize_legacy_expression(expression)?;
let handlebars_expression = normalize_handlebars_expression(&normalized_expression);
let template = format!("{{{{{}}}}}", handlebars_expression);
render_handlebars_template(&template, template_context)
}
pub(crate) fn template_regex() -> &'static Regex {
static TEMPLATE_REGEX: OnceLock<Regex> = OnceLock::new();
TEMPLATE_REGEX.get_or_init(|| Regex::new(r"\{\{([^}]+)\}\}").expect("valid regex"))
}
pub(crate) fn handlebars_engine() -> &'static Handlebars<'static> {
static HANDLEBARS: OnceLock<Handlebars<'static>> = OnceLock::new();
HANDLEBARS.get_or_init(|| {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
handlebars.register_escape_fn(no_escape);
handlebars
})
}
pub(crate) fn render_handlebars_template(template: &str, context: &Value) -> Option<String> {
handlebars_engine().render_template(template, context).ok()
}
pub(crate) fn normalize_legacy_expression(expression: &str) -> Option<String> {
if let Some(rest) = expression.strip_prefix("url.") {
let parts: Vec<&str> = rest.split('.').collect();
if parts.len() >= 2 {
return Some(format!("specs.{}.url.{}", parts[0], parts[1]));
}
return None;
}
Some(expression.to_owned())
}
pub(crate) fn normalize_handlebars_expression(expression: &str) -> String {
expression
.split('.')
.map(|segment| {
if segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
segment.to_owned()
} else {
format!("[{}]", segment)
}
})
.collect::<Vec<String>>()
.join(".")
}
pub(crate) fn build_template_context(
steps: &HashMap<String, StepExecutionResult>,
specs: Option<&[RuntimeSpec]>,
) -> Value {
let mut root = Map::new();
let mut steps_map = Map::new();
for (step_id, result) in steps {
let step_body = result
.response
.as_ref()
.map(|response| response.body.clone())
.unwrap_or(Value::Null);
steps_map.insert(step_id.clone(), step_body);
}
root.insert("steps".to_owned(), Value::Object(steps_map));
let mut specs_map = Map::new();
if let Some(specs) = specs {
for spec in specs {
let slug = spec.slug.trim();
if slug.is_empty() {
continue;
}
let mut urls_map = Map::new();
for (name, url) in &spec.servers {
let name = name.trim();
let url = url.trim();
if name.is_empty() || url.is_empty() {
continue;
}
urls_map.insert(name.to_owned(), Value::String(url.to_owned()));
}
let mut spec_entry = Map::new();
spec_entry.insert("url".to_owned(), Value::Object(urls_map));
specs_map.insert(slug.to_owned(), Value::Object(spec_entry));
}
}
root.insert("specs".to_owned(), Value::Object(specs_map));
Value::Object(root)
}
pub(crate) fn value_to_string(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => Some(value.to_string()),
}
}