use std::collections::HashMap;
const BUILTIN_VARS: &[&str] = &[
"flow_name",
"persona_name",
"unit_index",
"result",
"step_name",
"step_type",
"step_index",
];
#[derive(Debug, Clone)]
pub struct ExecContext {
vars: HashMap<String, String>,
}
impl ExecContext {
pub fn new(flow_name: &str, persona_name: &str, unit_index: usize) -> Self {
let mut vars = HashMap::new();
vars.insert("flow_name".to_string(), flow_name.to_string());
vars.insert("persona_name".to_string(), persona_name.to_string());
vars.insert("unit_index".to_string(), format!("{}", unit_index + 1));
vars.insert("result".to_string(), String::new());
ExecContext { vars }
}
pub fn set(&mut self, key: &str, value: &str) {
self.vars.insert(key.to_string(), value.to_string());
}
pub fn get(&self, key: &str) -> Option<&str> {
self.vars.get(key).map(|s| s.as_str())
}
pub fn vars(&self) -> &HashMap<String, String> {
&self.vars
}
pub fn set_step(&mut self, step_name: &str, step_type: &str, step_index: usize) {
self.vars.insert("step_name".to_string(), step_name.to_string());
self.vars.insert("step_type".to_string(), step_type.to_string());
self.vars.insert("step_index".to_string(), format!("{}", step_index + 1));
}
pub fn set_result(&mut self, step_name: &str, result: &str) {
self.vars.insert("result".to_string(), result.to_string());
self.vars.insert(step_name.to_string(), result.to_string());
}
pub fn interpolate(&self, text: &str) -> String {
interpolate_vars(text, &self.vars)
}
pub fn resolve_named_arg(&self, value: &str, value_kind: &str) -> String {
resolve_named_arg_value(value, value_kind, &self.vars)
}
pub fn var_count(&self) -> usize {
self.vars.len()
}
pub fn user_bindings(&self) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = self
.vars
.iter()
.filter(|(k, _)| !BUILTIN_VARS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
}
pub(crate) fn resolve_dotted_var(vars: &HashMap<String, String>, key: &str) -> Option<String> {
if let Some(val) = vars.get(key) {
return Some(val.clone());
}
let (base, rest) = key.split_once('.')?;
let base_val = vars.get(base)?;
let mut cur: serde_json::Value = serde_json::from_str(base_val).ok()?;
for field in rest.split('.') {
if let serde_json::Value::String(s) = &cur {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
if parsed.is_object() {
cur = parsed;
}
}
}
cur = match cur {
serde_json::Value::Object(mut m) => m.remove(field)?,
_ => return None,
};
}
Some(match cur {
serde_json::Value::String(s) => s,
other => other.to_string(),
})
}
pub fn interpolate_vars(text: &str, vars: &HashMap<String, String>) -> String {
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() {
if bytes[i + 1] == b'{' {
if let Some(close) = text[i + 2..].find('}') {
let var_name = &text[i + 2..i + 2 + close];
if let Some(val) = resolve_dotted_var(vars, var_name) {
out.push_str(&val);
} else {
out.push_str(&text[i..i + 3 + close]);
}
i += 3 + close;
continue;
}
} else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
let start = i + 1;
let mut end = start;
while end < bytes.len()
&& (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
{
end += 1;
}
let var_name = &text[start..end];
if let Some(val) = vars.get(var_name) {
out.push_str(val);
} else {
out.push_str(&text[i..end]);
}
i = end;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
pub fn resolve_named_arg_value(
value: &str,
value_kind: &str,
vars: &HashMap<String, String>,
) -> String {
if value_kind == "reference" {
vars.get(value)
.or_else(|| value.strip_suffix(".output").and_then(|step| vars.get(step)))
.cloned()
.unwrap_or_default()
} else {
interpolate_vars(value, vars)
}
}
pub fn resolve_value_reference(expr: &str, vars: &HashMap<String, String>) -> String {
if expr.contains('$') {
return interpolate_vars(expr, vars);
}
if let Some(v) = vars.get(expr) {
return v.clone();
}
if let Some(step) = expr.strip_suffix(".output") {
if let Some(v) = vars.get(step) {
return v.clone();
}
}
expr.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn bindings() -> HashMap<String, String> {
let mut m = HashMap::new();
m.insert("user_input".to_string(), "analiza https://acme.com".to_string());
m.insert("company".to_string(), "Acme".to_string());
m.insert("ExtractUrl".to_string(), "https://acme.com".to_string());
m
}
#[test]
fn reference_resolves_bare_flow_param() {
assert_eq!(
resolve_named_arg_value("company", "reference", &bindings()),
"Acme"
);
}
#[test]
fn reference_resolves_step_output_dotted_to_step_name_key() {
assert_eq!(
resolve_named_arg_value("ExtractUrl.output", "reference", &bindings()),
"https://acme.com"
);
}
#[test]
fn reference_resolves_bare_step_name() {
assert_eq!(
resolve_named_arg_value("ExtractUrl", "reference", &bindings()),
"https://acme.com"
);
}
#[test]
fn reference_unbound_is_empty_not_literal_name() {
assert_eq!(resolve_named_arg_value("nope", "reference", &bindings()), "");
}
#[test]
fn literal_keeps_interpolation_and_verbatim() {
assert_eq!(
resolve_named_arg_value("${company}", "literal", &bindings()),
"Acme"
);
assert_eq!(
resolve_named_arg_value("Acme", "literal", &bindings()),
"Acme"
);
}
#[test]
fn new_context_has_unit_vars() {
let ctx = ExecContext::new("Analyze", "Expert", 0);
assert_eq!(ctx.get("flow_name"), Some("Analyze"));
assert_eq!(ctx.get("persona_name"), Some("Expert"));
assert_eq!(ctx.get("unit_index"), Some("1"));
assert_eq!(ctx.get("result"), Some(""));
}
#[test]
fn set_step_updates_vars() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set_step("Gather", "step", 0);
assert_eq!(ctx.get("step_name"), Some("Gather"));
assert_eq!(ctx.get("step_type"), Some("step"));
assert_eq!(ctx.get("step_index"), Some("1"));
}
#[test]
fn set_result_updates_both() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set_result("Analyze", "The answer is 42");
assert_eq!(ctx.get("result"), Some("The answer is 42"));
assert_eq!(ctx.get("Analyze"), Some("The answer is 42"));
}
#[test]
fn interpolate_dollar_name() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set_result("Analyze", "42");
let out = ctx.interpolate("The result is $result from step $step_name");
assert!(out.contains("The result is 42"));
}
#[test]
fn interpolate_braced() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set_result("Analyze", "42");
let out = ctx.interpolate("Previous: ${Analyze}, flow: ${flow_name}");
assert_eq!(out, "Previous: 42, flow: F");
}
#[test]
fn interpolate_unknown_kept_literal() {
let ctx = ExecContext::new("F", "P", 0);
let out = ctx.interpolate("Value: $unknown and ${also_unknown}");
assert_eq!(out, "Value: $unknown and ${also_unknown}");
}
#[test]
fn interpolate_no_vars() {
let ctx = ExecContext::new("F", "P", 0);
let out = ctx.interpolate("No variables here.");
assert_eq!(out, "No variables here.");
}
#[test]
fn interpolate_adjacent_vars() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set("a", "hello");
ctx.set("b", "world");
let out = ctx.interpolate("$a$b");
assert_eq!(out, "helloworld");
}
#[test]
fn interpolate_dollar_at_end() {
let ctx = ExecContext::new("F", "P", 0);
let out = ctx.interpolate("price is $");
assert_eq!(out, "price is $");
}
#[test]
fn interpolate_dollar_number() {
let ctx = ExecContext::new("F", "P", 0);
let out = ctx.interpolate("cost: $100");
assert_eq!(out, "cost: $100");
}
#[test]
fn set_and_get_custom() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set("custom_key", "custom_value");
assert_eq!(ctx.get("custom_key"), Some("custom_value"));
}
#[test]
fn var_count() {
let ctx = ExecContext::new("F", "P", 0);
assert_eq!(ctx.var_count(), 4);
}
#[test]
fn user_bindings_excludes_builtins() {
let mut ctx = ExecContext::new("F", "P", 0);
ctx.set_step("Gather", "step", 0);
ctx.set_result("Gather", "data");
ctx.set("tenant_id", "acme");
let bindings = ctx.user_bindings();
assert_eq!(
bindings,
vec![
("Gather".to_string(), "data".to_string()),
("tenant_id".to_string(), "acme".to_string()),
]
);
}
#[test]
fn user_bindings_empty_for_fresh_context() {
let ctx = ExecContext::new("F", "P", 0);
assert!(ctx.user_bindings().is_empty());
}
#[test]
fn interpolate_resolves_dotted_field_of_a_json_object_binding() {
let mut vars = HashMap::new();
vars.insert(
"e".to_string(),
r#"{"to_id":"abc-123","etype":"cite","weight":0.9}"#.to_string(),
);
assert_eq!(
interpolate_vars("${e.to_id}", &vars),
"abc-123",
"dotted field-access must resolve the JSON object's field"
);
assert_eq!(interpolate_vars("${e.etype}", &vars), "cite");
assert_eq!(interpolate_vars("${e.weight}", &vars), "0.9");
vars.insert("tid".to_string(), "T1".to_string());
assert_eq!(
interpolate_vars("row ${tid}/${e.to_id}", &vars),
"row T1/abc-123"
);
}
#[test]
fn interpolate_navigates_into_a_string_encoded_jsonb_column() {
let mut vars = HashMap::new();
vars.insert(
"s".to_string(),
r#"{"id":"r1","payload":"{\"city\":\"Bogotá\",\"zip\":\"110111\"}"}"#.to_string(),
);
assert_eq!(interpolate_vars("${s.payload.city}", &vars), "Bogotá");
assert_eq!(interpolate_vars("${s.payload.zip}", &vars), "110111");
}
#[test]
fn interpolate_navigates_into_a_live_nested_jsonb_column() {
let mut vars = HashMap::new();
vars.insert(
"s".to_string(),
r#"{"id":"r1","payload":{"city":"Medellín"}}"#.to_string(),
);
assert_eq!(interpolate_vars("${s.payload.city}", &vars), "Medellín");
}
#[test]
fn interpolate_jsonb_navigation_miss_stays_literal() {
let mut vars = HashMap::new();
vars.insert(
"s".to_string(),
r#"{"payload":"{\"city\":\"X\"}"}"#.to_string(),
);
assert_eq!(interpolate_vars("${s.payload.absent}", &vars), "${s.payload.absent}");
}
#[test]
fn interpolate_dotted_misses_stay_literal_and_exact_keys_win() {
let mut vars = HashMap::new();
vars.insert("e".to_string(), "not json".to_string());
assert_eq!(interpolate_vars("${e.to_id}", &vars), "${e.to_id}");
assert_eq!(interpolate_vars("${missing.x}", &vars), "${missing.x}");
vars.insert("o".to_string(), r#"{"a":"1"}"#.to_string());
assert_eq!(interpolate_vars("${o.b}", &vars), "${o.b}");
vars.insert("o.b".to_string(), "exact".to_string());
assert_eq!(interpolate_vars("${o.b}", &vars), "exact");
assert_eq!(interpolate_vars("${o}", &vars), r#"{"a":"1"}"#);
}
#[test]
fn resolve_value_reference_handles_step_output_and_interpolation() {
let mut vars = HashMap::new();
vars.insert("ClassifyEdges".to_string(), r#"[{"to_id":"x"}]"#.to_string());
vars.insert("Summarize".to_string(), "the summary".to_string());
vars.insert("q".to_string(), "hi".to_string());
assert_eq!(
resolve_value_reference("ClassifyEdges.output", &vars),
r#"[{"to_id":"x"}]"#
);
assert_eq!(
resolve_value_reference("${Summarize}", &vars),
"the summary"
);
assert_eq!(resolve_value_reference("q", &vars), "hi");
assert_eq!(resolve_value_reference("plain literal", &vars), "plain literal");
assert_eq!(resolve_value_reference("Missing.output", &vars), "Missing.output");
}
}