1use std::collections::HashMap;
19
20const BUILTIN_VARS: &[&str] = &[
25 "flow_name",
26 "persona_name",
27 "unit_index",
28 "result",
29 "step_name",
30 "step_type",
31 "step_index",
32];
33
34#[derive(Debug, Clone)]
36pub struct ExecContext {
37 vars: HashMap<String, String>,
38}
39
40impl ExecContext {
41 pub fn new(flow_name: &str, persona_name: &str, unit_index: usize) -> Self {
43 let mut vars = HashMap::new();
44 vars.insert("flow_name".to_string(), flow_name.to_string());
45 vars.insert("persona_name".to_string(), persona_name.to_string());
46 vars.insert("unit_index".to_string(), format!("{}", unit_index + 1));
47 vars.insert("result".to_string(), String::new());
48 ExecContext { vars }
49 }
50
51 pub fn set(&mut self, key: &str, value: &str) {
53 self.vars.insert(key.to_string(), value.to_string());
54 }
55
56 pub fn get(&self, key: &str) -> Option<&str> {
58 self.vars.get(key).map(|s| s.as_str())
59 }
60
61 pub fn vars(&self) -> &HashMap<String, String> {
65 &self.vars
66 }
67
68 pub fn set_step(&mut self, step_name: &str, step_type: &str, step_index: usize) {
70 self.vars.insert("step_name".to_string(), step_name.to_string());
71 self.vars.insert("step_type".to_string(), step_type.to_string());
72 self.vars.insert("step_index".to_string(), format!("{}", step_index + 1));
73 }
74
75 pub fn set_result(&mut self, step_name: &str, result: &str) {
77 self.vars.insert("result".to_string(), result.to_string());
78 self.vars.insert(step_name.to_string(), result.to_string());
79 }
80
81 pub fn interpolate(&self, text: &str) -> String {
88 interpolate_vars(text, &self.vars)
89 }
90
91 pub fn resolve_named_arg(&self, value: &str, value_kind: &str) -> String {
96 resolve_named_arg_value(value, value_kind, &self.vars)
97 }
98
99 pub fn var_count(&self) -> usize {
101 self.vars.len()
102 }
103
104 pub fn user_bindings(&self) -> Vec<(String, String)> {
110 let mut out: Vec<(String, String)> = self
111 .vars
112 .iter()
113 .filter(|(k, _)| !BUILTIN_VARS.contains(&k.as_str()))
114 .map(|(k, v)| (k.clone(), v.clone()))
115 .collect();
116 out.sort_by(|a, b| a.0.cmp(&b.0));
117 out
118 }
119}
120
121pub(crate) fn resolve_dotted_var(vars: &HashMap<String, String>, key: &str) -> Option<String> {
142 if let Some(val) = vars.get(key) {
143 return Some(val.clone());
144 }
145 let (base, rest) = key.split_once('.')?;
146 let base_val = vars.get(base)?;
147 let mut cur: serde_json::Value = serde_json::from_str(base_val).ok()?;
148 for field in rest.split('.') {
149 cur = match cur {
150 serde_json::Value::Object(mut m) => m.remove(field)?,
151 _ => return None,
152 };
153 }
154 Some(match cur {
155 serde_json::Value::String(s) => s,
156 other => other.to_string(),
157 })
158}
159
160pub fn interpolate_vars(text: &str, vars: &HashMap<String, String>) -> String {
161 let bytes = text.as_bytes();
162 let mut out = String::with_capacity(text.len());
163 let mut i = 0;
164
165 while i < bytes.len() {
166 if bytes[i] == b'$' && i + 1 < bytes.len() {
167 if bytes[i + 1] == b'{' {
168 if let Some(close) = text[i + 2..].find('}') {
170 let var_name = &text[i + 2..i + 2 + close];
171 if let Some(val) = resolve_dotted_var(vars, var_name) {
172 out.push_str(&val);
173 } else {
174 out.push_str(&text[i..i + 3 + close]);
176 }
177 i += 3 + close;
178 continue;
179 }
180 } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
181 let start = i + 1;
183 let mut end = start;
184 while end < bytes.len()
185 && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
186 {
187 end += 1;
188 }
189 let var_name = &text[start..end];
190 if let Some(val) = vars.get(var_name) {
191 out.push_str(val);
192 } else {
193 out.push_str(&text[i..end]);
194 }
195 i = end;
196 continue;
197 }
198 }
199 out.push(bytes[i] as char);
200 i += 1;
201 }
202
203 out
204}
205
206pub fn resolve_named_arg_value(
221 value: &str,
222 value_kind: &str,
223 vars: &HashMap<String, String>,
224) -> String {
225 if value_kind == "reference" {
226 vars.get(value)
227 .or_else(|| value.strip_suffix(".output").and_then(|step| vars.get(step)))
228 .cloned()
229 .unwrap_or_default()
230 } else {
231 interpolate_vars(value, vars)
232 }
233}
234
235pub fn resolve_value_reference(expr: &str, vars: &HashMap<String, String>) -> String {
255 if expr.contains('$') {
256 return interpolate_vars(expr, vars);
257 }
258 if let Some(v) = vars.get(expr) {
259 return v.clone();
260 }
261 if let Some(step) = expr.strip_suffix(".output") {
262 if let Some(v) = vars.get(step) {
263 return v.clone();
264 }
265 }
266 expr.to_string()
267}
268
269#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn bindings() -> HashMap<String, String> {
278 let mut m = HashMap::new();
279 m.insert("user_input".to_string(), "analiza https://acme.com".to_string());
280 m.insert("company".to_string(), "Acme".to_string());
281 m.insert("ExtractUrl".to_string(), "https://acme.com".to_string());
283 m
284 }
285
286 #[test]
287 fn reference_resolves_bare_flow_param() {
288 assert_eq!(
290 resolve_named_arg_value("company", "reference", &bindings()),
291 "Acme"
292 );
293 }
294
295 #[test]
296 fn reference_resolves_step_output_dotted_to_step_name_key() {
297 assert_eq!(
299 resolve_named_arg_value("ExtractUrl.output", "reference", &bindings()),
300 "https://acme.com"
301 );
302 }
303
304 #[test]
305 fn reference_resolves_bare_step_name() {
306 assert_eq!(
307 resolve_named_arg_value("ExtractUrl", "reference", &bindings()),
308 "https://acme.com"
309 );
310 }
311
312 #[test]
313 fn reference_unbound_is_empty_not_literal_name() {
314 assert_eq!(resolve_named_arg_value("nope", "reference", &bindings()), "");
316 }
317
318 #[test]
319 fn literal_keeps_interpolation_and_verbatim() {
320 assert_eq!(
322 resolve_named_arg_value("${company}", "literal", &bindings()),
323 "Acme"
324 );
325 assert_eq!(
327 resolve_named_arg_value("Acme", "literal", &bindings()),
328 "Acme"
329 );
330 }
331
332 #[test]
333 fn new_context_has_unit_vars() {
334 let ctx = ExecContext::new("Analyze", "Expert", 0);
335 assert_eq!(ctx.get("flow_name"), Some("Analyze"));
336 assert_eq!(ctx.get("persona_name"), Some("Expert"));
337 assert_eq!(ctx.get("unit_index"), Some("1"));
338 assert_eq!(ctx.get("result"), Some(""));
339 }
340
341 #[test]
342 fn set_step_updates_vars() {
343 let mut ctx = ExecContext::new("F", "P", 0);
344 ctx.set_step("Gather", "step", 0);
345 assert_eq!(ctx.get("step_name"), Some("Gather"));
346 assert_eq!(ctx.get("step_type"), Some("step"));
347 assert_eq!(ctx.get("step_index"), Some("1"));
348 }
349
350 #[test]
351 fn set_result_updates_both() {
352 let mut ctx = ExecContext::new("F", "P", 0);
353 ctx.set_result("Analyze", "The answer is 42");
354 assert_eq!(ctx.get("result"), Some("The answer is 42"));
355 assert_eq!(ctx.get("Analyze"), Some("The answer is 42"));
356 }
357
358 #[test]
359 fn interpolate_dollar_name() {
360 let mut ctx = ExecContext::new("F", "P", 0);
361 ctx.set_result("Analyze", "42");
362 let out = ctx.interpolate("The result is $result from step $step_name");
363 assert!(out.contains("The result is 42"));
365 }
366
367 #[test]
368 fn interpolate_braced() {
369 let mut ctx = ExecContext::new("F", "P", 0);
370 ctx.set_result("Analyze", "42");
371 let out = ctx.interpolate("Previous: ${Analyze}, flow: ${flow_name}");
372 assert_eq!(out, "Previous: 42, flow: F");
373 }
374
375 #[test]
376 fn interpolate_unknown_kept_literal() {
377 let ctx = ExecContext::new("F", "P", 0);
378 let out = ctx.interpolate("Value: $unknown and ${also_unknown}");
379 assert_eq!(out, "Value: $unknown and ${also_unknown}");
380 }
381
382 #[test]
383 fn interpolate_no_vars() {
384 let ctx = ExecContext::new("F", "P", 0);
385 let out = ctx.interpolate("No variables here.");
386 assert_eq!(out, "No variables here.");
387 }
388
389 #[test]
390 fn interpolate_adjacent_vars() {
391 let mut ctx = ExecContext::new("F", "P", 0);
392 ctx.set("a", "hello");
393 ctx.set("b", "world");
394 let out = ctx.interpolate("$a$b");
395 assert_eq!(out, "helloworld");
396 }
397
398 #[test]
399 fn interpolate_dollar_at_end() {
400 let ctx = ExecContext::new("F", "P", 0);
401 let out = ctx.interpolate("price is $");
402 assert_eq!(out, "price is $");
403 }
404
405 #[test]
406 fn interpolate_dollar_number() {
407 let ctx = ExecContext::new("F", "P", 0);
408 let out = ctx.interpolate("cost: $100");
409 assert_eq!(out, "cost: $100");
410 }
411
412 #[test]
413 fn set_and_get_custom() {
414 let mut ctx = ExecContext::new("F", "P", 0);
415 ctx.set("custom_key", "custom_value");
416 assert_eq!(ctx.get("custom_key"), Some("custom_value"));
417 }
418
419 #[test]
420 fn var_count() {
421 let ctx = ExecContext::new("F", "P", 0);
422 assert_eq!(ctx.var_count(), 4);
424 }
425
426 #[test]
427 fn user_bindings_excludes_builtins() {
428 let mut ctx = ExecContext::new("F", "P", 0);
429 ctx.set_step("Gather", "step", 0);
430 ctx.set_result("Gather", "data");
431 ctx.set("tenant_id", "acme");
432 let bindings = ctx.user_bindings();
436 assert_eq!(
437 bindings,
438 vec![
439 ("Gather".to_string(), "data".to_string()),
440 ("tenant_id".to_string(), "acme".to_string()),
441 ]
442 );
443 }
444
445 #[test]
446 fn user_bindings_empty_for_fresh_context() {
447 let ctx = ExecContext::new("F", "P", 0);
448 assert!(ctx.user_bindings().is_empty());
449 }
450
451 #[test]
454 fn interpolate_resolves_dotted_field_of_a_json_object_binding() {
455 let mut vars = HashMap::new();
459 vars.insert(
460 "e".to_string(),
461 r#"{"to_id":"abc-123","etype":"cite","weight":0.9}"#.to_string(),
462 );
463 assert_eq!(
464 interpolate_vars("${e.to_id}", &vars),
465 "abc-123",
466 "dotted field-access must resolve the JSON object's field"
467 );
468 assert_eq!(interpolate_vars("${e.etype}", &vars), "cite");
469 assert_eq!(interpolate_vars("${e.weight}", &vars), "0.9");
471 vars.insert("tid".to_string(), "T1".to_string());
473 assert_eq!(
474 interpolate_vars("row ${tid}/${e.to_id}", &vars),
475 "row T1/abc-123"
476 );
477 }
478
479 #[test]
480 fn interpolate_dotted_misses_stay_literal_and_exact_keys_win() {
481 let mut vars = HashMap::new();
482 vars.insert("e".to_string(), "not json".to_string());
484 assert_eq!(interpolate_vars("${e.to_id}", &vars), "${e.to_id}");
485 assert_eq!(interpolate_vars("${missing.x}", &vars), "${missing.x}");
487 vars.insert("o".to_string(), r#"{"a":"1"}"#.to_string());
489 assert_eq!(interpolate_vars("${o.b}", &vars), "${o.b}");
490 vars.insert("o.b".to_string(), "exact".to_string());
493 assert_eq!(interpolate_vars("${o.b}", &vars), "exact");
494 assert_eq!(interpolate_vars("${o}", &vars), r#"{"a":"1"}"#);
496 }
497
498 #[test]
501 fn resolve_value_reference_handles_step_output_and_interpolation() {
502 let mut vars = HashMap::new();
503 vars.insert("ClassifyEdges".to_string(), r#"[{"to_id":"x"}]"#.to_string());
505 vars.insert("Summarize".to_string(), "the summary".to_string());
506 vars.insert("q".to_string(), "hi".to_string());
507
508 assert_eq!(
511 resolve_value_reference("ClassifyEdges.output", &vars),
512 r#"[{"to_id":"x"}]"#
513 );
514 assert_eq!(
516 resolve_value_reference("${Summarize}", &vars),
517 "the summary"
518 );
519 assert_eq!(resolve_value_reference("q", &vars), "hi");
521 assert_eq!(resolve_value_reference("plain literal", &vars), "plain literal");
523 assert_eq!(resolve_value_reference("Missing.output", &vars), "Missing.output");
525 }
526}