use std::collections::HashMap;
use chrono::Utc;
use handlebars::{Handlebars, RenderError};
use serde_json::Value;
use crate::{Run, RunError, Step, SwarmId};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolutionError {
#[error("Missing variable: {path}")]
MissingVariable {
path: String,
},
#[error("Invalid path: {path} - {reason}")]
InvalidPath {
path: String,
reason: String,
},
#[error("Type mismatch at {path}: expected {expected}, got {actual}")]
TypeMismatch {
path: String,
expected: String,
actual: String,
},
#[error("Invalid syntax: {message}")]
InvalidSyntax {
message: String,
},
#[error("Circular dependency detected: {chain}")]
CircularDependency {
chain: String,
},
#[error("Environment variable '{var_name}' not allowed by policy")]
EnvNotAllowed {
var_name: String,
},
#[error("Schema validation failed for field '{field}': expected {expected}, got {actual}")]
SchemaValidation {
field: String,
expected: String,
actual: Value,
},
#[error("Template error: {message}")]
TemplateError {
message: String,
},
}
impl ResolutionError {
pub fn missing_variable(path: impl Into<String>) -> Self {
ResolutionError::MissingVariable { path: path.into() }
}
pub fn invalid_path(path: impl Into<String>, reason: impl Into<String>) -> Self {
ResolutionError::InvalidPath {
path: path.into(),
reason: reason.into(),
}
}
pub fn invalid_syntax(message: impl Into<String>) -> Self {
ResolutionError::InvalidSyntax {
message: message.into(),
}
}
pub fn template_error(message: impl Into<String>) -> Self {
ResolutionError::TemplateError {
message: message.into(),
}
}
pub fn to_run_error(&self) -> RunError {
RunError::PatternError {
pattern: "template".into(),
step: "resolution".into(),
message: self.to_string(),
}
}
}
impl From<RenderError> for ResolutionError {
fn from(e: RenderError) -> Self {
ResolutionError::template_error(e.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StepOutput {
pub name: String,
pub output: Value,
}
impl StepOutput {
pub fn new(name: impl Into<String>, output: Value) -> Self {
StepOutput {
name: name.into(),
output,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EnvAllowlist {
#[serde(default)]
pub allowed: Vec<String>,
#[serde(default)]
pub mode: EnvAllowlistMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EnvAllowlistMode {
#[default]
Strict,
Lenient,
}
impl EnvAllowlist {
pub fn strict(allowed: Vec<String>) -> Self {
EnvAllowlist {
allowed,
mode: EnvAllowlistMode::Strict,
}
}
pub fn lenient() -> Self {
EnvAllowlist {
allowed: Vec::new(),
mode: EnvAllowlistMode::Lenient,
}
}
pub fn is_allowed(&self, var_name: &str) -> bool {
match self.mode {
EnvAllowlistMode::Strict => self.allowed.contains(&var_name.to_string()),
EnvAllowlistMode::Lenient => true,
}
}
}
impl Default for EnvAllowlist {
fn default() -> Self {
EnvAllowlist::strict(Vec::new())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemVariables {
pub run_id: String,
pub step_id: String,
pub timestamp: String,
pub swarm_id: String,
pub iteration_index: Option<u32>,
pub iteration_value: Option<Value>,
}
impl SystemVariables {
pub fn from_run(run: &Run, swarm_id: &SwarmId) -> Self {
SystemVariables {
run_id: run.id.as_str().to_string(),
step_id: String::new(), timestamp: Utc::now().to_rfc3339(),
swarm_id: swarm_id.as_str().to_string(),
iteration_index: None,
iteration_value: None,
}
}
pub fn with_step(&mut self, step: &Step) {
self.step_id = step.id.as_str().to_string();
}
pub fn with_iteration(&mut self, index: u32, value: Value) {
self.iteration_index = Some(index);
self.iteration_value = Some(value);
}
pub fn to_json(&self) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("run_id".to_string(), Value::String(self.run_id.clone()));
obj.insert("step_id".to_string(), Value::String(self.step_id.clone()));
obj.insert(
"timestamp".to_string(),
Value::String(self.timestamp.clone()),
);
obj.insert("swarm_id".to_string(), Value::String(self.swarm_id.clone()));
if let Some(index) = self.iteration_index {
obj.insert("iteration_index".to_string(), Value::Number(index.into()));
}
if let Some(value) = &self.iteration_value {
obj.insert("iteration_value".to_string(), value.clone());
}
Value::Object(obj)
}
}
#[derive(Debug, Clone)]
pub struct Scope {
pub input: Value,
pub steps: HashMap<String, StepOutput>,
pub env: HashMap<String, String>,
pub sys: SystemVariables,
}
impl Scope {
pub fn empty() -> Self {
Scope {
input: Value::Object(serde_json::Map::new()),
steps: HashMap::new(),
env: HashMap::new(),
sys: SystemVariables {
run_id: String::new(),
step_id: String::new(),
timestamp: Utc::now().to_rfc3339(),
swarm_id: String::new(),
iteration_index: None,
iteration_value: None,
},
}
}
pub fn with_input(input: Value) -> Self {
Scope {
input,
steps: HashMap::new(),
env: HashMap::new(),
sys: SystemVariables {
run_id: String::new(),
step_id: String::new(),
timestamp: Utc::now().to_rfc3339(),
swarm_id: String::new(),
iteration_index: None,
iteration_value: None,
},
}
}
pub fn add_step_output(&mut self, name: String, output: Value) {
self.steps
.insert(name.clone(), StepOutput::new(name, output));
}
pub fn set_sys(&mut self, sys: SystemVariables) {
self.sys = sys;
}
pub fn load_env(&mut self, allowlist: &EnvAllowlist) {
for (key, value) in std::env::vars() {
if allowlist.is_allowed(&key) {
self.env.insert(key, value);
}
}
}
pub fn to_json(&self) -> Value {
let mut obj = serde_json::Map::new();
obj.insert("input".to_string(), self.input.clone());
let steps_obj = serde_json::Map::from_iter(self.steps.iter().map(|(name, output)| {
let mut step_obj = serde_json::Map::new();
step_obj.insert("output".to_string(), output.output.clone());
(name.clone(), Value::Object(step_obj))
}));
obj.insert("steps".to_string(), Value::Object(steps_obj));
let env_obj = serde_json::Map::from_iter(
self.env
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone()))),
);
obj.insert("env".to_string(), Value::Object(env_obj));
obj.insert("sys".to_string(), self.sys.to_json());
Value::Object(obj)
}
}
pub trait ExpressionResolver: Send + Sync {
fn resolve(&self, template: &str, scope: &Scope) -> Result<String, ResolutionError>;
fn resolve_value(&self, value: &Value, scope: &Scope) -> Result<Value, ResolutionError>;
fn validate(&self, template: &str) -> Result<(), ResolutionError>;
}
pub struct HandlebarsResolver {
registry: Handlebars<'static>,
#[allow(dead_code)]
env_policy: EnvAllowlist,
}
impl HandlebarsResolver {
pub fn new() -> Self {
HandlebarsResolver {
registry: Handlebars::new(),
env_policy: EnvAllowlist::default(),
}
}
pub fn with_env_policy(env_policy: EnvAllowlist) -> Self {
HandlebarsResolver {
registry: Handlebars::new(),
env_policy,
}
}
fn render_template(&self, template: &str, data: &Value) -> Result<String, ResolutionError> {
self.registry
.render_template(template, data)
.map_err(ResolutionError::from)
}
}
impl Default for HandlebarsResolver {
fn default() -> Self {
Self::new()
}
}
impl ExpressionResolver for HandlebarsResolver {
fn resolve(&self, template: &str, scope: &Scope) -> Result<String, ResolutionError> {
if !template.contains("{{") {
return Ok(template.to_string());
}
self.validate(template)?;
let data = scope.to_json();
self.render_template(template, &data)
}
fn resolve_value(&self, value: &Value, scope: &Scope) -> Result<Value, ResolutionError> {
match value {
Value::String(s) => {
if s.contains("{{") {
let resolved = self.resolve(s, scope)?;
if resolved.starts_with('{') || resolved.starts_with('[') {
Ok(serde_json::from_str(&resolved).unwrap_or(Value::String(resolved)))
} else if resolved == "true" {
Ok(Value::Bool(true))
} else if resolved == "false" {
Ok(Value::Bool(false))
} else if resolved == "null" {
Ok(Value::Null)
} else if let Ok(n) = resolved.parse::<i64>() {
Ok(Value::Number(n.into()))
} else if let Ok(n) = resolved.parse::<f64>() {
Ok(Value::Number(
serde_json::Number::from_f64(n)
.unwrap_or_else(|| serde_json::Number::from(0)),
))
} else {
Ok(Value::String(resolved))
}
} else {
Ok(Value::String(s.clone()))
}
}
Value::Object(obj) => {
let resolved_obj = serde_json::Map::from_iter(obj.iter().map(|(k, v)| {
let resolved_v = self.resolve_value(v, scope);
(k.clone(), resolved_v.unwrap_or_else(|_| v.clone()))
}));
Ok(Value::Object(resolved_obj))
}
Value::Array(arr) => {
let resolved_arr: Vec<Value> = arr
.iter()
.map(|v| self.resolve_value(v, scope))
.collect::<Result<Vec<_>, _>>()?;
Ok(Value::Array(resolved_arr))
}
other => Ok(other.clone()),
}
}
fn validate(&self, template: &str) -> Result<(), ResolutionError> {
let open_count = template.matches("{{").count();
let close_count = template.matches("}}").count();
if open_count != close_count {
return Err(ResolutionError::invalid_syntax(
"Unclosed braces in expression",
));
}
if template.contains("{{}}") {
return Err(ResolutionError::invalid_syntax("Empty expression"));
}
let empty_data = Value::Object(serde_json::Map::new());
self.registry
.render_template(template, &empty_data)
.map_err(ResolutionError::from)?;
Ok(())
}
}
pub fn resolve_worker_input(
input: &HashMap<String, Value>,
scope: &Scope,
) -> Result<HashMap<String, Value>, ResolutionError> {
let resolver = HandlebarsResolver::new();
let mut resolved = HashMap::new();
for (key, value) in input {
let resolved_value = resolver.resolve_value(value, scope)?;
resolved.insert(key.clone(), resolved_value);
}
Ok(resolved)
}
pub fn resolve_worker_output(
output_mapping: &HashMap<String, Value>,
step_output: &Value,
scope: &Scope,
) -> Result<HashMap<String, Value>, ResolutionError> {
let resolver = HandlebarsResolver::new();
let mut resolved = HashMap::new();
let mut extended_scope = scope.clone();
extended_scope.add_step_output("_raw".to_string(), step_output.clone());
for (key, value) in output_mapping {
let resolved_value = resolver.resolve_value(value, &extended_scope)?;
resolved.insert(key.clone(), resolved_value);
}
Ok(resolved)
}
pub fn resolve_expose(
expose: &[crate::ExposeMapping],
scope: &Scope,
) -> Result<Value, ResolutionError> {
let resolver = HandlebarsResolver::new();
let mut output = serde_json::Map::new();
for entry in expose {
let value = resolve_expose_from(&entry.from, scope, &resolver)?;
output.insert(entry.name.clone(), value);
}
Ok(Value::Object(output))
}
fn resolve_expose_from(
from: &str,
scope: &Scope,
_resolver: &HandlebarsResolver,
) -> Result<Value, ResolutionError> {
let path = if from.starts_with("{{") && from.ends_with("}}") {
&from[2..from.len() - 2]
} else {
from
};
traverse_scope_path(path, scope)
}
pub fn resolve_path_value(path: &str, scope: &Scope) -> Result<Value, ResolutionError> {
let normalized_path = if path.starts_with("{{") && path.ends_with("}}") {
&path[2..path.len() - 2]
} else {
path
};
traverse_scope_path(normalized_path, scope)
}
fn traverse_scope_path(path: &str, scope: &Scope) -> Result<Value, ResolutionError> {
let parts: Vec<&str> = path.split('.').collect();
if parts.is_empty() {
return Err(ResolutionError::invalid_path(path, "Empty path"));
}
let scope_json = scope.to_json();
let mut current = &scope_json;
for part in &parts {
match current {
Value::Object(obj) => {
current = obj
.get(*part)
.ok_or_else(|| ResolutionError::missing_variable(path))?;
}
Value::Array(arr) => {
let idx_str = part.trim_start_matches('[').trim_end_matches(']');
if let Ok(idx) = idx_str.parse::<usize>() {
current = arr.get(idx).ok_or_else(|| {
ResolutionError::invalid_path(
path,
format!("Array index {} out of bounds", idx),
)
})?;
} else {
return Err(ResolutionError::invalid_path(
path,
format!("Invalid array index: {}", part),
));
}
}
_ => {
return Err(ResolutionError::invalid_path(
path,
format!("Cannot traverse {} on non-object/array", part),
));
}
}
}
Ok(current.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_scope_empty() {
let scope = Scope::empty();
assert!(scope.input.is_object());
assert!(scope.steps.is_empty());
assert!(scope.env.is_empty());
}
#[test]
fn test_scope_with_input() {
let scope = Scope::with_input(json!({ "user_id": "123", "items": ["a", "b", "c"] }));
assert_eq!(scope.input["user_id"], "123");
assert_eq!(scope.input["items"].as_array().unwrap().len(), 3);
}
#[test]
fn test_scope_add_step_output() {
let mut scope = Scope::empty();
scope.add_step_output("fetcher".to_string(), json!({ "result": "data" }));
assert!(scope.steps.contains_key("fetcher"));
assert_eq!(scope.steps["fetcher"].output["result"], "data");
}
#[test]
fn test_system_variables_to_json() {
let sys = SystemVariables {
run_id: "run-123".to_string(),
step_id: "step-456".to_string(),
timestamp: "2026-04-06T12:00:00Z".to_string(),
swarm_id: "swarm-789".to_string(),
iteration_index: Some(5),
iteration_value: Some(json!("item-value")),
};
let json_val = sys.to_json();
assert_eq!(json_val["run_id"], "run-123");
assert_eq!(json_val["step_id"], "step-456");
assert_eq!(json_val["iteration_index"], 5);
assert_eq!(json_val["iteration_value"], "item-value");
}
#[test]
fn test_scope_to_json() {
let mut scope = Scope::with_input(json!({ "x": 1 }));
scope.add_step_output("worker".to_string(), json!({ "y": 2 }));
scope
.env
.insert("HOME".to_string(), "/home/user".to_string());
let json_val = scope.to_json();
assert_eq!(json_val["input"]["x"], 1);
assert_eq!(json_val["steps"]["worker"]["output"]["y"], 2);
assert_eq!(json_val["env"]["HOME"], "/home/user");
}
#[test]
fn test_resolver_simple_input() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "user_id": "123" }));
let result = resolver.resolve("{{input.user_id}}", &scope).unwrap();
assert_eq!(result, "123");
}
#[test]
fn test_resolver_nested_input() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "user": { "name": "Alice" } }));
let result = resolver.resolve("{{input.user.name}}", &scope).unwrap();
assert_eq!(result, "Alice");
}
#[test]
fn test_resolver_step_output() {
let resolver = HandlebarsResolver::new();
let mut scope = Scope::empty();
scope.add_step_output("fetcher".to_string(), json!({ "body": "response_data" }));
let result = resolver
.resolve("{{steps.fetcher.output.body}}", &scope)
.unwrap();
assert_eq!(result, "response_data");
}
#[test]
fn test_resolver_string_interpolation() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "id": "123" }));
let result = resolver
.resolve("prefix-{{input.id}}-suffix", &scope)
.unwrap();
assert_eq!(result, "prefix-123-suffix");
}
#[test]
fn test_resolver_no_expression() {
let resolver = HandlebarsResolver::new();
let scope = Scope::empty();
let result = resolver.resolve("static string", &scope).unwrap();
assert_eq!(result, "static string");
}
#[test]
fn test_resolver_invalid_syntax_unclosed() {
let resolver = HandlebarsResolver::new();
let scope = Scope::empty();
let result = resolver.resolve("{{input.x", &scope);
assert!(matches!(result, Err(ResolutionError::InvalidSyntax { .. })));
}
#[test]
fn test_resolver_validate_success() {
let resolver = HandlebarsResolver::new();
assert!(resolver.validate("{{input.x}}").is_ok());
assert!(resolver.validate("prefix-{{input.x}}-suffix").is_ok());
}
#[test]
fn test_resolver_validate_failure() {
let resolver = HandlebarsResolver::new();
assert!(resolver.validate("{{input.x").is_err());
assert!(resolver.validate("{{}}").is_err());
}
#[test]
fn test_resolve_value_string() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "x": "hello" }));
let result = resolver
.resolve_value(&Value::String("{{input.x}}".to_string()), &scope)
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn test_resolve_value_number_preserved() {
let resolver = HandlebarsResolver::new();
let scope = Scope::empty();
let result = resolver
.resolve_value(&Value::Number(42.into()), &scope)
.unwrap();
assert_eq!(result, 42);
}
#[test]
fn test_resolve_value_bool_preserved() {
let resolver = HandlebarsResolver::new();
let scope = Scope::empty();
let result = resolver.resolve_value(&Value::Bool(true), &scope).unwrap();
assert_eq!(result, true);
}
#[test]
fn test_resolve_value_array() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "items": ["a", "b"] }));
let input = json!(["{{input.items.[0]}}", "{{input.items.[1]}}"]);
let result = resolver.resolve_value(&input, &scope).unwrap();
assert_eq!(result, json!(["a", "b"]));
}
#[test]
fn test_resolve_value_object() {
let resolver = HandlebarsResolver::new();
let scope = Scope::with_input(json!({ "user": { "name": "Bob" } }));
let input = json!({
"username": "{{input.user.name}}",
"count": 5
});
let result = resolver.resolve_value(&input, &scope).unwrap();
assert_eq!(result["username"], "Bob");
assert_eq!(result["count"], 5);
}
#[test]
fn test_resolve_worker_input() {
let mut scope = Scope::with_input(json!({ "endpoint": "https://api.example.com" }));
scope.add_step_output("fetcher".to_string(), json!({ "body": "raw_data" }));
let input = HashMap::from([
(
"url".to_string(),
Value::String("{{input.endpoint}}".to_string()),
),
(
"data".to_string(),
Value::String("{{steps.fetcher.output.body}}".to_string()),
),
]);
let resolved = resolve_worker_input(&input, &scope).unwrap();
assert_eq!(resolved["url"], "https://api.example.com");
assert_eq!(resolved["data"], "raw_data");
}
#[test]
fn test_env_allowlist_strict() {
let policy = EnvAllowlist::strict(vec!["HOME".to_string(), "PATH".to_string()]);
assert!(policy.is_allowed("HOME"));
assert!(policy.is_allowed("PATH"));
assert!(!policy.is_allowed("AWS_SECRET"));
}
#[test]
fn test_env_allowlist_lenient() {
let policy = EnvAllowlist::lenient();
assert!(policy.is_allowed("HOME"));
assert!(policy.is_allowed("AWS_SECRET"));
}
#[test]
fn test_env_allowlist_default() {
let policy = EnvAllowlist::default();
assert!(!policy.is_allowed("HOME"));
}
#[test]
fn test_resolution_error_to_run_error() {
let err = ResolutionError::missing_variable("input.x");
let run_err = err.to_run_error();
assert!(matches!(run_err, RunError::PatternError { .. }));
}
#[test]
fn test_system_variables_from_run() {
use crate::{RunTarget, RuntimeKind};
use std::path::PathBuf;
let run = Run::new(
RunTarget::Agent {
spec_path: PathBuf::from("agent.yaml"),
},
RuntimeKind::Local,
);
let swarm_id = SwarmId::new("test-swarm");
let sys = SystemVariables::from_run(&run, &swarm_id);
assert_eq!(sys.run_id, run.id.as_str());
assert_eq!(sys.swarm_id, swarm_id.as_str());
assert!(sys.iteration_index.is_none());
}
#[test]
fn test_system_variables_with_iteration() {
let mut sys = SystemVariables {
run_id: "run-1".to_string(),
step_id: "step-1".to_string(),
timestamp: "2026-04-06T12:00:00Z".to_string(),
swarm_id: "swarm-1".to_string(),
iteration_index: None,
iteration_value: None,
};
sys.with_iteration(3, json!("item-3"));
assert_eq!(sys.iteration_index, Some(3));
assert_eq!(sys.iteration_value, Some(json!("item-3")));
}
#[test]
fn test_sys_variable_resolution() {
let resolver = HandlebarsResolver::new();
let mut scope = Scope::empty();
scope.sys = SystemVariables {
run_id: "run-123".to_string(),
step_id: "step-456".to_string(),
timestamp: "2026-04-06T12:00:00Z".to_string(),
swarm_id: "swarm-789".to_string(),
iteration_index: Some(0),
iteration_value: Some(json!("first-item")),
};
let result = resolver.resolve("{{sys.run_id}}", &scope).unwrap();
assert_eq!(result, "run-123");
let result = resolver.resolve("{{sys.swarm_id}}", &scope).unwrap();
assert_eq!(result, "swarm-789");
let result = resolver.resolve("{{sys.iteration_index}}", &scope).unwrap();
assert_eq!(result, "0");
}
#[test]
fn test_resolve_expose_simplified_notation() {
use crate::ExposeMapping;
let mut scope = Scope::empty();
scope.add_step_output(
"parser".to_string(),
json!({ "items": ["a", "b", "c"], "total": 3 }),
);
scope.add_step_output("counter".to_string(), json!({ "count": 42 }));
let exposes = vec![
ExposeMapping::new("results", "steps.parser.output.items"),
ExposeMapping::new("total", "steps.parser.output.total"),
];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["results"], json!(["a", "b", "c"]));
assert_eq!(output["total"], 3);
}
#[test]
fn test_resolve_expose_full_expression() {
use crate::ExposeMapping;
let mut scope = Scope::empty();
scope.add_step_output("worker".to_string(), json!({ "value": "test_result" }));
let exposes = vec![ExposeMapping::new(
"output",
"{{steps.worker.output.value}}",
)];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["output"], "test_result");
}
#[test]
fn test_resolve_expose_empty() {
let scope = Scope::empty();
let exposes: Vec<crate::ExposeMapping> = vec![];
let output = resolve_expose(&exposes, &scope).unwrap();
assert!(output.is_object());
assert_eq!(output.as_object().unwrap().len(), 0);
}
#[test]
fn test_resolve_expose_multiple_fields() {
use crate::ExposeMapping;
let mut scope = Scope::empty();
scope.add_step_output(
"fetcher".to_string(),
json!({ "data": { "id": 123, "name": "test" } }),
);
scope.add_step_output("processor".to_string(), json!({ "result": "processed" }));
scope.add_step_output("counter".to_string(), json!({ "count": 5 }));
let exposes = vec![
ExposeMapping::new("id", "steps.fetcher.output.data.id"),
ExposeMapping::new("name", "steps.fetcher.output.data.name"),
ExposeMapping::new("result", "steps.processor.output.result"),
ExposeMapping::new("count", "steps.counter.output.count"),
];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["id"], 123);
assert_eq!(output["name"], "test");
assert_eq!(output["result"], "processed");
assert_eq!(output["count"], 5);
}
#[test]
fn test_resolve_expose_with_input() {
use crate::ExposeMapping;
let mut scope = Scope::with_input(json!({ "query": "search_term", "limit": 10 }));
scope.add_step_output("searcher".to_string(), json!({ "hits": ["hit1", "hit2"] }));
let exposes = vec![
ExposeMapping::new("query_used", "input.query"),
ExposeMapping::new("results", "steps.searcher.output.hits"),
];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["query_used"], "search_term");
assert_eq!(output["results"], json!(["hit1", "hit2"]));
}
#[test]
fn test_resolve_expose_boolean_value() {
use crate::ExposeMapping;
let mut scope = Scope::empty();
scope.add_step_output(
"validator".to_string(),
json!({ "valid": true, "invalid": false }),
);
let exposes = vec![
ExposeMapping::new("is_valid", "steps.validator.output.valid"),
ExposeMapping::new("is_invalid", "steps.validator.output.invalid"),
];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["is_valid"], true);
assert_eq!(output["is_invalid"], false);
}
#[test]
fn test_resolve_expose_number_value() {
use crate::ExposeMapping;
let mut scope = Scope::empty();
scope.add_step_output(
"calculator".to_string(),
json!({ "integer": 42, "float": 2.71 }),
);
let exposes = vec![
ExposeMapping::new("int_val", "steps.calculator.output.integer"),
ExposeMapping::new("float_val", "steps.calculator.output.float"),
];
let output = resolve_expose(&exposes, &scope).unwrap();
assert_eq!(output["int_val"], 42);
assert!(output["float_val"].as_f64().unwrap() > 2.0);
assert!(output["float_val"].as_f64().unwrap() < 3.0);
}
#[test]
fn test_resolve_expose_missing_variable() {
use crate::ExposeMapping;
let scope = Scope::empty();
let exposes = vec![ExposeMapping::new(
"missing",
"steps.nonexistent.output.value",
)];
assert!(resolve_expose(&exposes, &scope).is_err());
}
}