use std::borrow::Cow;
use std::sync::LazyLock;
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};
use serde_json::Value;
use smallvec::SmallVec;
use crate::error::NikaError;
use crate::store::RunContext;
use super::resolve::ResolvedBindings;
use super::transform::TransformExpr;
const MAX_TEMPLATE_VARS: usize = 256;
const MAX_PATH_DEPTH: usize = 32;
static USE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\{\{\s*with\.(\w+(?:\.\w+)*)(?:\s*\|\s*(shell))?\s*\}\}").unwrap()
});
static BRACKET_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[(\d+)\]").unwrap());
static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{(.*?)\}\}").unwrap());
#[derive(Debug, Clone, PartialEq)]
pub enum TemplateExpr {
Alias {
path: String,
transforms: Vec<String>,
},
Context {
path: String,
transforms: Vec<String>,
},
Input {
path: String,
transforms: Vec<String>,
},
}
pub fn parse_template_expr(content: &str) -> Result<TemplateExpr, NikaError> {
let trimmed = content.trim();
if trimmed.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty template expression in '{}'", content),
});
}
if let Some(rest) = trimmed.strip_prefix("context.") {
if rest.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty context path after 'context.' in '{}'", content),
});
}
let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
let path = parts[0].to_string();
let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
if path.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty context path after 'context.' in '{}'", content),
});
}
return Ok(TemplateExpr::Context { path, transforms });
}
if let Some(rest) = trimmed.strip_prefix("inputs.") {
if rest.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty input path after 'inputs.' in '{}'", content),
});
}
let parts: Vec<&str> = rest.split('|').map(str::trim).collect();
let path = parts[0].to_string();
let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
if path.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty input path after 'inputs.' in '{}'", content),
});
}
return Ok(TemplateExpr::Input { path, transforms });
}
let effective = trimmed.strip_prefix("with.").unwrap_or(trimmed);
let parts: Vec<&str> = effective.split('|').map(str::trim).collect();
let path = parts[0].to_string();
let transforms: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
if path.is_empty() {
return Err(NikaError::TemplateParse {
position: 0,
details: format!("Empty alias path in '{}'", content),
});
}
Ok(TemplateExpr::Alias { path, transforms })
}
fn value_to_display(value: &Value) -> Cow<'_, str> {
match value {
Value::String(s) => Cow::Borrowed(s.as_str()),
Value::Null => Cow::Borrowed(""),
Value::Bool(b) => Cow::Owned(b.to_string()),
Value::Number(n) => Cow::Owned(n.to_string()),
other => Cow::Owned(other.to_string()), }
}
fn resolve_alias_path<'a>(
path: &str,
with_values: &'a FxHashMap<String, Value>,
) -> Result<&'a Value, NikaError> {
let segment_count = path.split('.').count();
if segment_count > MAX_PATH_DEPTH {
return Err(NikaError::TemplateError {
template: path.to_string(),
reason: format!(
"Path depth {} exceeds maximum of {} segments",
segment_count, MAX_PATH_DEPTH
),
});
}
let mut segments = path.split('.');
let alias = segments.next().ok_or_else(|| NikaError::TemplateError {
template: path.to_string(),
reason: "Empty alias path (no segments)".to_string(),
})?;
let base = with_values
.get(alias)
.ok_or_else(|| NikaError::TemplateError {
template: alias.to_string(),
reason: format!(
"Alias '{}' not found in 'with:' block. Available: [{}]",
alias,
with_values.keys().cloned().collect::<Vec<_>>().join(", ")
),
})?;
let mut current = base;
let mut traversed: SmallVec<[&str; 8]> = SmallVec::new();
traversed.push(alias);
for segment in segments {
let next = if let Ok(idx) = segment.parse::<usize>() {
current.get(idx)
} else {
current.get(segment)
};
match next {
Some(v) => {
traversed.push(segment);
current = v;
}
None => {
if matches!(current, Value::Object(_) | Value::Array(_)) {
let traversed_path = traversed.join(".");
return Err(NikaError::PathNotFound {
path: format!("{}.{}", traversed_path, segment),
});
} else {
let value_type = match current {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
_ => unreachable!(),
};
return Err(NikaError::InvalidTraversal {
segment: segment.to_string(),
value_type: value_type.to_string(),
full_path: path.to_string(),
});
}
}
}
}
Ok(current)
}
pub fn resolve_with<'a>(
template: &'a str,
with_values: &FxHashMap<String, Value>,
datastore: &RunContext,
) -> Result<Cow<'a, str>, NikaError> {
if !template.contains("{{") {
return Ok(Cow::Borrowed(template));
}
let var_count = template.matches("{{").count();
if var_count > MAX_TEMPLATE_VARS {
return Err(NikaError::TemplateError {
template: format!("(template with {} variables)", var_count),
reason: format!(
"Template contains {} variable references, exceeding the maximum of {}",
var_count, MAX_TEMPLATE_VARS
),
});
}
let normalized = normalize_bracket_notation(template);
let template_str: &str = normalized.as_ref();
let mut result = String::with_capacity(template_str.len() + 64);
let mut last_end = 0;
let mut errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(template_str) {
let m = cap.get(0).unwrap();
let content = &cap[1];
result.push_str(&template_str[last_end..m.start()]);
match parse_template_expr(content) {
Ok(TemplateExpr::Alias {
ref path,
ref transforms,
}) => {
match resolve_alias_path(path, with_values) {
Ok(value) => {
let has_shell = transforms.iter().any(|t| t == "shell");
let display = if has_shell {
let non_shell: Vec<String> = transforms
.iter()
.filter(|t| *t != "shell")
.cloned()
.collect();
let pre_shell_value = if non_shell.is_empty() {
value.clone()
} else {
let transform_str = non_shell.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!(
"Transform parse error in '{{{{{}}}}}': {}",
content, e
),
}
})?;
expr.apply(value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!(
"Transform apply error in '{{{{{}}}}}': {}",
content, e
),
})?
};
escape_for_shell(&value_to_display(&pre_shell_value))
} else {
let final_value = if transforms.is_empty() {
value.clone()
} else {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!(
"Transform parse error in '{{{{{}}}}}': {}",
content, e
),
}
})?;
expr.apply(value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!(
"Transform apply error in '{{{{{}}}}}': {}",
content, e
),
})?
};
if is_in_json_context(template_str, m.start()) {
escape_for_json(&value_to_display(&final_value)).into_owned()
} else {
value_to_display(&final_value).into_owned()
}
};
result.push_str(&display);
}
Err(e) => {
let msg = format!("{}", e);
if msg.contains("exceeds maximum") || msg.contains("Empty alias path") {
return Err(e);
}
errors.push(path.clone());
}
}
}
Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
result.push_str(&format!("{{{{{}}}}}", content.trim()));
}
Err(_) => {
result.push_str(m.as_str());
}
}
last_end = m.end();
}
if !errors.is_empty() {
return Err(NikaError::TemplateError {
template: errors.join(", "),
reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
});
}
result.push_str(&template_str[last_end..]);
let has_context = template.contains("context.");
let has_inputs = template.contains("inputs.");
if !has_context && !has_inputs {
return Ok(Cow::Owned(result));
}
if has_context && result.contains("{{") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("context.{}", path);
match datastore.resolve_context_path(&full_path) {
Some(value) => {
let replacement = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&value_to_display(&transformed)).into_owned()
} else {
value_to_display(&transformed).into_owned()
}
} else {
let s = context_value_to_string(&value, &full_path)?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&s).into_owned()
} else {
s.into_owned()
}
};
result.push_str(&replacement);
}
None => {
context_errors.push(full_path);
}
}
last_end = m.end();
}
if !context_errors.is_empty() {
return Err(NikaError::TemplateError {
template: context_errors.join(", "),
reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
.to_string(),
});
}
result.push_str(&intermediate[last_end..]);
}
if has_inputs && result.contains("{{") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("inputs.{}", path);
match datastore.resolve_input_path(&full_path) {
Some(value) => {
let replacement = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&value_to_display(&transformed)).into_owned()
} else {
value_to_display(&transformed).into_owned()
}
} else {
let s = input_value_to_string(&value, &full_path)?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&s).into_owned()
} else {
s.into_owned()
}
};
result.push_str(&replacement);
}
None => {
input_errors.push(full_path);
}
}
last_end = m.end();
}
if !input_errors.is_empty() {
return Err(NikaError::TemplateError {
template: input_errors.join(", "),
reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
});
}
result.push_str(&intermediate[last_end..]);
}
Ok(Cow::Owned(result))
}
pub fn extract_with_refs(template: &str) -> Vec<String> {
if !template.contains("{{") {
return Vec::new();
}
let mut aliases = Vec::new();
for cap in TEMPLATE_RE.captures_iter(template) {
let content = &cap[1];
if let Ok(TemplateExpr::Alias { path, .. }) = parse_template_expr(content) {
let alias = path.split('.').next().unwrap().to_string();
aliases.push(alias);
}
}
aliases
}
pub fn validate_with_refs(
template: &str,
declared_aliases: &FxHashSet<String>,
task_id: &str,
) -> Result<(), NikaError> {
for alias in extract_with_refs(template) {
if !declared_aliases.contains(&alias) {
return Err(NikaError::UnknownAlias {
alias,
task_id: task_id.to_string(),
});
}
}
Ok(())
}
fn escape_for_json(s: &str) -> Cow<'_, str> {
let needs_escape = s
.chars()
.any(|c| matches!(c, '"' | '\\' | '\n' | '\r' | '\t') || c.is_control());
if !needs_escape {
return Cow::Borrowed(s);
}
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
c => result.push(c),
}
}
Cow::Owned(result)
}
pub fn escape_for_shell(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
let mut result = String::with_capacity(s.len() + 10);
result.push('\'');
for ch in s.chars() {
if ch == '\'' {
result.push_str("'\\''");
} else {
result.push(ch);
}
}
result.push('\'');
result
}
fn normalize_bracket_notation(template: &str) -> Cow<'_, str> {
if !template.contains('[') {
return Cow::Borrowed(template);
}
let mut has_bracket_in_template = false;
let mut search_start = 0;
while let Some(open) = template[search_start..].find("{{") {
let abs_open = search_start + open;
if let Some(close) = template[abs_open..].find("}}") {
let block = &template[abs_open..abs_open + close + 2];
if block.contains('[') {
has_bracket_in_template = true;
break;
}
search_start = abs_open + close + 2;
} else {
break;
}
}
if !has_bracket_in_template {
return Cow::Borrowed(template);
}
let mut result = String::with_capacity(template.len());
let mut pos = 0;
while pos < template.len() {
if let Some(open) = template[pos..].find("{{") {
let abs_open = pos + open;
result.push_str(&template[pos..abs_open]);
if let Some(close) = template[abs_open..].find("}}") {
let abs_close = abs_open + close + 2;
let block = &template[abs_open..abs_close];
let normalized_block = BRACKET_RE.replace_all(block, ".$1");
result.push_str(&normalized_block);
pos = abs_close;
} else {
result.push_str(&template[abs_open..]);
pos = template.len();
}
} else {
result.push_str(&template[pos..]);
break;
}
}
Cow::Owned(result)
}
pub fn resolve<'a>(
template: &'a str,
bindings: &ResolvedBindings,
datastore: &RunContext,
) -> Result<Cow<'a, str>, NikaError> {
if !template.contains("{{") {
return Ok(Cow::Borrowed(template));
}
let has_with = template.contains("with.");
let has_context = template.contains("context.");
let has_inputs = template.contains("inputs.");
if !has_with && !has_context && !has_inputs {
return Ok(Cow::Borrowed(template));
}
let var_count = template.matches("{{").count();
if var_count > MAX_TEMPLATE_VARS {
return Err(NikaError::TemplateError {
template: format!("(template with {} variables)", var_count),
reason: format!(
"Template contains {} variable references, exceeding the maximum of {}",
var_count, MAX_TEMPLATE_VARS
),
});
}
let normalized = normalize_bracket_notation(template);
let template_str: &str = normalized.as_ref();
let mut result = String::with_capacity(template_str.len() + 64);
let mut last_end = 0;
let mut errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(template_str) {
let m = cap.get(0).unwrap();
let content = &cap[1];
result.push_str(&template_str[last_end..m.start()]);
match parse_template_expr(content) {
Ok(TemplateExpr::Alias {
ref path,
ref transforms,
}) => {
let segment_count = path.split('.').count();
if segment_count > MAX_PATH_DEPTH {
return Err(NikaError::TemplateError {
template: path.to_string(),
reason: format!(
"Path depth {} exceeds maximum of {} segments",
segment_count, MAX_PATH_DEPTH
),
});
}
let mut parts = path.split('.');
let alias = parts.next().unwrap();
match bindings.get_resolved(alias, datastore) {
Ok(base_value) => {
let mut value_ref: &Value = &base_value;
let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
traversed_segments.push(alias);
for segment in parts {
let next = if let Ok(idx) = segment.parse::<usize>() {
value_ref.get(idx)
} else {
value_ref.get(segment)
};
match next {
Some(v) => {
traversed_segments.push(segment);
value_ref = v;
}
None => {
let value_type = match value_ref {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
let traversed_path = traversed_segments.join(".");
return Err(NikaError::PathNotFound {
path: format!("{}.{}", traversed_path, segment),
});
} else {
return Err(NikaError::InvalidTraversal {
segment: segment.to_string(),
value_type: value_type.to_string(),
full_path: path.to_string(),
});
}
}
}
}
let has_shell = transforms.iter().any(|t| t == "shell");
let display = if has_shell {
let non_shell: Vec<&String> =
transforms.iter().filter(|t| *t != "shell").collect();
let pre_shell_value = if non_shell.is_empty() {
value_ref.clone()
} else {
let transform_str = non_shell
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" | ");
let expr =
crate::binding::transform::TransformExpr::parse(&transform_str)
.map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
})?;
expr.apply(value_ref)
.map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?
};
escape_for_shell(&value_to_display(&pre_shell_value))
} else if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr =
crate::binding::transform::TransformExpr::parse(&transform_str)
.map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
})?;
let final_value =
expr.apply(value_ref)
.map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
if is_in_json_context(template_str, m.start()) {
escape_for_json(&value_to_display(&final_value)).into_owned()
} else {
value_to_display(&final_value).into_owned()
}
} else {
let replacement = value_to_string(value_ref, path, alias)?;
if is_in_json_context(template_str, m.start()) {
escape_for_json(&replacement).into_owned()
} else {
replacement.into_owned()
}
};
result.push_str(&display);
}
Err(_) => {
errors.push(alias.to_string());
}
}
}
Ok(TemplateExpr::Context { .. } | TemplateExpr::Input { .. }) => {
result.push_str(&format!("{{{{{}}}}}", content.trim()));
}
Err(_) => {
result.push_str(m.as_str());
}
}
last_end = m.end();
}
if !errors.is_empty() {
return Err(NikaError::TemplateError {
template: errors.join(", "),
reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
});
}
result.push_str(&template_str[last_end..]);
if has_context && result.contains("context.") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("context.{}", path);
match datastore.resolve_context_path(&full_path) {
Some(value) => {
let replacement = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&value_to_display(&transformed)).into_owned()
} else {
value_to_display(&transformed).into_owned()
}
} else {
let s = context_value_to_string(&value, &full_path)?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&s).into_owned()
} else {
s.into_owned()
}
};
result.push_str(&replacement);
}
None => {
context_errors.push(full_path);
}
}
last_end = m.end();
}
if !context_errors.is_empty() {
return Err(NikaError::TemplateError {
template: context_errors.join(", "),
reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
.to_string(),
});
}
result.push_str(&intermediate[last_end..]);
if !has_inputs || !result.contains("inputs.") {
return Ok(Cow::Owned(result));
}
}
if has_inputs && result.contains("inputs.") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("inputs.{}", path);
match datastore.resolve_input_path(&full_path) {
Some(value) => {
let replacement = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&value_to_display(&transformed)).into_owned()
} else {
value_to_display(&transformed).into_owned()
}
} else {
let s = input_value_to_string(&value, &full_path)?;
if is_in_json_context(&intermediate, m.start()) {
escape_for_json(&s).into_owned()
} else {
s.into_owned()
}
};
result.push_str(&replacement);
}
None => {
input_errors.push(full_path);
}
}
last_end = m.end();
}
if !input_errors.is_empty() {
return Err(NikaError::TemplateError {
template: input_errors.join(", "),
reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
});
}
result.push_str(&intermediate[last_end..]);
return Ok(Cow::Owned(result));
}
Ok(Cow::Owned(result))
}
pub fn resolve_for_shell<'a>(
template: &'a str,
bindings: &ResolvedBindings,
datastore: &RunContext,
) -> Result<Cow<'a, str>, NikaError> {
if !template.contains("{{") {
return Ok(Cow::Borrowed(template));
}
let has_with = template.contains("with.");
let has_context = template.contains("context.");
let has_inputs = template.contains("inputs.");
if !has_with && !has_context && !has_inputs {
return Ok(Cow::Borrowed(template));
}
let mut result = String::with_capacity(template.len() + 64);
let mut last_end = 0;
let mut errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in USE_RE.captures_iter(template) {
let m = cap.get(0).unwrap();
let path = &cap[1];
result.push_str(&template[last_end..m.start()]);
let mut parts = path.split('.');
let alias = parts.next().unwrap();
match bindings.get_resolved(alias, datastore) {
Ok(base_value) => {
let mut value_ref: &Value = &base_value;
let mut traversed_segments: SmallVec<[&str; 8]> = SmallVec::new();
traversed_segments.push(alias);
for segment in parts {
let next = if let Ok(idx) = segment.parse::<usize>() {
value_ref.get(idx)
} else {
value_ref.get(segment)
};
match next {
Some(v) => {
traversed_segments.push(segment);
value_ref = v;
}
None => {
let value_type = match value_ref {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
};
if matches!(value_ref, Value::Object(_) | Value::Array(_)) {
let traversed_path = traversed_segments.join(".");
return Err(NikaError::PathNotFound {
path: format!("{}.{}", traversed_path, segment),
});
} else {
return Err(NikaError::InvalidTraversal {
segment: segment.to_string(),
value_type: value_type.to_string(),
full_path: path.to_string(),
});
}
}
}
}
let raw_value = value_to_string(value_ref, path, alias)?;
let escaped = escape_for_shell(&raw_value);
result.push_str(&escaped);
}
Err(_) => {
errors.push(alias.to_string());
}
}
last_end = m.end();
}
if !errors.is_empty() {
return Err(NikaError::TemplateError {
template: errors.join(", "),
reason: "Alias(es) not resolved. Did you declare them in 'with:'?".to_string(),
});
}
result.push_str(&template[last_end..]);
if has_context && result.contains("context.") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut context_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Context { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("context.{}", path);
match datastore.resolve_context_path(&full_path) {
Some(value) => {
let raw_value = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
value_to_display(&transformed).into_owned()
} else {
context_value_to_string(&value, &full_path)?.into_owned()
};
let escaped = escape_for_shell(&raw_value);
result.push_str(&escaped);
}
None => {
context_errors.push(full_path);
}
}
last_end = m.end();
}
if !context_errors.is_empty() {
return Err(NikaError::TemplateError {
template: context_errors.join(", "),
reason: "Context binding(s) not resolved. Check your 'context:' block in workflow."
.to_string(),
});
}
result.push_str(&intermediate[last_end..]);
}
if has_inputs && result.contains("inputs.") {
let intermediate = std::mem::take(&mut result);
result = String::with_capacity(intermediate.len() + 64);
let mut last_end = 0;
let mut input_errors: SmallVec<[String; 4]> = SmallVec::new();
for cap in TEMPLATE_RE.captures_iter(&intermediate) {
let m = cap.get(0).unwrap();
let inner = cap[1].trim();
let (path, transforms) = match parse_template_expr(inner) {
Ok(TemplateExpr::Input { path, transforms }) => (path, transforms),
_ => continue,
};
result.push_str(&intermediate[last_end..m.start()]);
let full_path = format!("inputs.{}", path);
match datastore.resolve_input_path(&full_path) {
Some(value) => {
let raw_value = if !transforms.is_empty() {
let transform_str = transforms.join(" | ");
let expr = TransformExpr::parse(&transform_str).map_err(|e| {
NikaError::TemplateParse {
position: m.start(),
details: format!("Transform parse error: {}", e),
}
})?;
let transformed =
expr.apply(&value).map_err(|e| NikaError::TemplateParse {
position: m.start(),
details: format!("Transform apply error: {}", e),
})?;
value_to_display(&transformed).into_owned()
} else {
input_value_to_string(&value, &full_path)?.into_owned()
};
let escaped = escape_for_shell(&raw_value);
result.push_str(&escaped);
}
None => {
input_errors.push(full_path);
}
}
last_end = m.end();
}
if !input_errors.is_empty() {
return Err(NikaError::TemplateError {
template: input_errors.join(", "),
reason: "Input binding(s) not resolved. Check your 'inputs:' block in workflow or provide defaults.".to_string(),
});
}
result.push_str(&intermediate[last_end..]);
}
Ok(Cow::Owned(result))
}
fn value_to_string<'a>(
value: &'a Value,
path: &str,
alias: &str,
) -> Result<Cow<'a, str>, NikaError> {
match value {
Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
Value::Null => Err(NikaError::NullValue {
path: path.to_string(),
alias: alias.to_string(),
}),
Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
Value::Number(n) => Ok(Cow::Owned(n.to_string())),
other => Ok(Cow::Owned(other.to_string())),
}
}
fn context_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
match value {
Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
Value::Null => Err(NikaError::TemplateError {
template: path.to_string(),
reason: "Context binding resolved to null".to_string(),
}),
Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
Value::Number(n) => Ok(Cow::Owned(n.to_string())),
other => Ok(Cow::Owned(other.to_string())),
}
}
fn input_value_to_string<'a>(value: &'a Value, path: &str) -> Result<Cow<'a, str>, NikaError> {
match value {
Value::String(s) => Ok(Cow::Borrowed(s.as_str())),
Value::Null => Err(NikaError::TemplateError {
template: path.to_string(),
reason: "Input binding resolved to null. Provide a 'default' value in your inputs definition.".to_string(),
}),
Value::Bool(b) => Ok(Cow::Owned(b.to_string())),
Value::Number(n) => Ok(Cow::Owned(n.to_string())),
other => Ok(Cow::Owned(other.to_string())),
}
}
fn is_in_json_context(template: &str, pos: usize) -> bool {
let trimmed = template.trim_start();
let looks_like_json = trimmed.starts_with('{') || trimmed.starts_with('[');
if !looks_like_json {
return false;
}
let before = &template[..pos];
let mut in_string = false;
let mut escaped = false;
for ch in before.chars() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'"' => in_string = !in_string,
_ => {}
}
}
in_string
}
pub fn extract_refs(template: &str) -> Vec<(String, String)> {
USE_RE
.captures_iter(template)
.map(|cap| {
let full_path = cap[1].to_string();
let alias = full_path.split('.').next().unwrap().to_string();
(alias, full_path)
})
.collect()
}
pub fn validate_refs(
template: &str,
declared_aliases: &FxHashSet<String>,
task_id: &str,
) -> Result<(), NikaError> {
for (alias, _full_path) in extract_refs(template) {
if !declared_aliases.contains(&alias) {
return Err(NikaError::UnknownAlias {
alias,
task_id: task_id.to_string(),
});
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::borrow::Cow;
fn empty_datastore() -> RunContext {
RunContext::new()
}
#[test]
fn resolve_simple() {
let mut bindings = ResolvedBindings::new();
bindings.set("forecast", json!("Sunny 25C"));
let ds = empty_datastore();
let result = resolve("Weather: {{with.forecast}}", &bindings, &ds).unwrap();
assert_eq!(result, "Weather: Sunny 25C");
}
#[test]
fn resolve_number() {
let mut bindings = ResolvedBindings::new();
bindings.set("price", json!(89));
let ds = empty_datastore();
let result = resolve("Price: ${{with.price}}", &bindings, &ds).unwrap();
assert_eq!(result, "Price: $89");
}
#[test]
fn resolve_nested() {
let mut bindings = ResolvedBindings::new();
bindings.set("flight_info", json!({"departure": "10:30", "gate": "A12"}));
let ds = empty_datastore();
let result = resolve("Depart at {{with.flight_info.departure}}", &bindings, &ds).unwrap();
assert_eq!(result, "Depart at 10:30");
}
#[test]
fn resolve_multiple() {
let mut bindings = ResolvedBindings::new();
bindings.set("a", json!("first"));
bindings.set("b", json!("second"));
let ds = empty_datastore();
let result = resolve("{{with.a}} and {{with.b}}", &bindings, &ds).unwrap();
assert_eq!(result, "first and second");
}
#[test]
fn resolve_object() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"x": 1, "y": 2}));
let ds = empty_datastore();
let result = resolve("Full: {{with.data}}", &bindings, &ds).unwrap();
assert!(result.contains("\"x\":1") || result.contains("\"x\": 1"));
}
#[test]
fn resolve_alias_not_found() {
let mut bindings = ResolvedBindings::new();
bindings.set("known", json!("value"));
let ds = empty_datastore();
let result = resolve("{{with.unknown}}", &bindings, &ds);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown"));
}
#[test]
fn resolve_path_not_found() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"a": 1}));
let ds = empty_datastore();
let result = resolve("{{with.data.nonexistent}}", &bindings, &ds);
assert!(result.is_err());
}
#[test]
fn resolve_no_templates() {
let bindings = ResolvedBindings::new();
let ds = empty_datastore();
let result = resolve("No templates here", &bindings, &ds).unwrap();
assert_eq!(result, "No templates here");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn resolve_with_templates_is_owned() {
let mut bindings = ResolvedBindings::new();
bindings.set("x", json!("value"));
let ds = empty_datastore();
let result = resolve("Has {{with.x}} template", &bindings, &ds).unwrap();
assert_eq!(result, "Has value template");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn resolve_array_index() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["first", "second", "third"]));
let ds = empty_datastore();
let result = resolve("Item: {{with.items.0}}", &bindings, &ds).unwrap();
assert_eq!(result, "Item: first");
}
#[test]
fn resolve_bracket_notation_simple() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["first", "second", "third"]));
let ds = empty_datastore();
let result = resolve("Item: {{with.items[0]}}", &bindings, &ds).unwrap();
assert_eq!(result, "Item: first");
}
#[test]
fn resolve_bracket_notation_second_element() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["first", "second", "third"]));
let ds = empty_datastore();
let result = resolve("Item: {{with.items[1]}}", &bindings, &ds).unwrap();
assert_eq!(result, "Item: second");
}
#[test]
fn resolve_bracket_notation_nested() {
let mut bindings = ResolvedBindings::new();
bindings.set(
"data",
json!({
"user": {"name": "Alice", "address": {"city": "Paris"}},
"items": ["one", "two", "three"]
}),
);
let ds = empty_datastore();
let result = resolve("First item: {{with.data.items[0]}}", &bindings, &ds).unwrap();
assert_eq!(result, "First item: one");
}
#[test]
fn resolve_bracket_notation_mixed_syntax() {
let mut bindings = ResolvedBindings::new();
bindings.set(
"data",
json!({"users": [{"name": "Alice"}, {"name": "Bob"}]}),
);
let ds = empty_datastore();
let result = resolve("User: {{with.data.users[0].name}}", &bindings, &ds).unwrap();
assert_eq!(result, "User: Alice");
}
#[test]
fn resolve_bracket_notation_multiple() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["a", "b", "c"]));
let ds = empty_datastore();
let result = resolve("{{with.items[0]}} and {{with.items[2]}}", &bindings, &ds).unwrap();
assert_eq!(result, "a and c");
}
#[test]
fn normalize_bracket_notation_unit() {
assert_eq!(
normalize_bracket_notation("{{with.items[0]}}"),
"{{with.items.0}}"
);
assert_eq!(
normalize_bracket_notation("{{with.data.items[1].name}}"),
"{{with.data.items.1.name}}"
);
assert_eq!(
normalize_bracket_notation("no brackets here"),
"no brackets here"
);
assert_eq!(
normalize_bracket_notation("{{with.a[0]}} and {{with.b[2]}}"),
"{{with.a.0}} and {{with.b.2}}"
);
}
#[test]
fn resolve_null_is_error() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!(null));
let ds = empty_datastore();
let result = resolve("Value: {{with.data}}", &bindings, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-072"));
assert!(err.to_string().contains("Null value"));
}
#[test]
fn resolve_nested_null_is_error() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"value": null}));
let ds = empty_datastore();
let result = resolve("Value: {{with.data.value}}", &bindings, &ds);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-072"));
}
#[test]
fn resolve_invalid_traversal_on_string() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!("just a string"));
let ds = empty_datastore();
let result = resolve("{{with.data.field}}", &bindings, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-073"));
assert!(err.to_string().contains("string"));
}
#[test]
fn resolve_invalid_traversal_on_number() {
let mut bindings = ResolvedBindings::new();
bindings.set("price", json!(42));
let ds = empty_datastore();
let result = resolve("{{with.price.currency}}", &bindings, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-073"));
assert!(err.to_string().contains("number"));
}
#[test]
fn extract_refs_simple() {
let refs = extract_refs("Hello {{with.weather}}!");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], ("weather".to_string(), "weather".to_string()));
}
#[test]
fn extract_refs_nested() {
let refs = extract_refs("{{with.data.field.sub}}");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], ("data".to_string(), "data.field.sub".to_string()));
}
#[test]
fn extract_refs_multiple() {
let refs = extract_refs("{{with.a}} and {{with.b.c}}");
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].0, "a");
assert_eq!(refs[1].0, "b");
}
#[test]
fn extract_refs_none() {
let refs = extract_refs("No templates here");
assert!(refs.is_empty());
}
#[test]
fn validate_refs_success() {
let declared: FxHashSet<String> =
["weather", "price"].iter().map(|s| s.to_string()).collect();
let result = validate_refs("{{with.weather}} costs {{with.price}}", &declared, "task1");
assert!(result.is_ok());
}
#[test]
fn validate_refs_unknown_alias() {
let declared: FxHashSet<String> = ["weather"].iter().map(|s| s.to_string()).collect();
let result = validate_refs("{{with.weather}} and {{with.unknown}}", &declared, "task1");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-071"));
assert!(err.to_string().contains("unknown"));
}
use crate::store::LoadedContext;
fn datastore_with_context() -> RunContext {
let store = RunContext::new();
let mut context = LoadedContext::new();
context.files.insert(
"brand".to_string(),
json!("# QR Code AI\nTagline: Scan smarter"),
);
context
.files
.insert("config".to_string(), json!({"theme": "dark", "version": 2}));
context.session = Some(json!({"focus": "rust", "level": 3}));
store.set_context(context);
store
}
#[test]
fn resolve_context_files_simple() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Brand: {{context.files.brand}}", &bindings, &ds).unwrap();
assert_eq!(result, "Brand: # QR Code AI\nTagline: Scan smarter");
}
#[test]
fn resolve_context_files_nested() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Theme: {{context.files.config.theme}}", &bindings, &ds).unwrap();
assert_eq!(result, "Theme: dark");
}
#[test]
fn resolve_context_session() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Focus: {{context.session.focus}}", &bindings, &ds).unwrap();
assert_eq!(result, "Focus: rust");
}
#[test]
fn resolve_context_session_number() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Level: {{context.session.level}}", &bindings, &ds).unwrap();
assert_eq!(result, "Level: 3");
}
#[test]
fn resolve_context_with_use_bindings() {
let mut bindings = ResolvedBindings::new();
bindings.set("greeting", json!("Hello"));
let ds = datastore_with_context();
let result = resolve(
"{{with.greeting}}! Brand: {{context.files.brand}}",
&bindings,
&ds,
)
.unwrap();
assert_eq!(result, "Hello! Brand: # QR Code AI\nTagline: Scan smarter");
}
#[test]
fn resolve_context_not_found() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("{{context.files.nonexistent}}", &bindings, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Context binding"));
assert!(err.to_string().contains("nonexistent"));
}
#[test]
fn resolve_context_no_context_loaded() {
let bindings = ResolvedBindings::new();
let ds = empty_datastore();
let result = resolve("{{context.files.brand}}", &bindings, &ds);
assert!(result.is_err());
}
#[test]
fn resolve_only_context_no_use() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Theme is {{context.files.config.theme}}", &bindings, &ds).unwrap();
assert_eq!(result, "Theme is dark");
}
#[test]
fn resolve_context_preserves_no_template() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_context();
let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
assert_eq!(result, "Plain text without templates");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn escape_for_shell_simple() {
assert_eq!(escape_for_shell("hello"), "'hello'");
}
#[test]
fn escape_for_shell_empty() {
assert_eq!(escape_for_shell(""), "''");
}
#[test]
fn escape_for_shell_with_single_quote() {
assert_eq!(escape_for_shell("Nika's"), "'Nika'\\''s'");
}
#[test]
fn escape_for_shell_with_multiple_quotes() {
assert_eq!(escape_for_shell("don't won't"), "'don'\\''t won'\\''t'");
}
#[test]
fn escape_for_shell_with_special_chars() {
assert_eq!(escape_for_shell("$HOME;rm -rf /"), "'$HOME;rm -rf /'");
}
#[test]
fn escape_for_shell_with_backticks() {
assert_eq!(escape_for_shell("`whoami`"), "'`whoami`'");
}
#[test]
fn escape_for_shell_with_newlines() {
assert_eq!(escape_for_shell("line1\nline2"), "'line1\nline2'");
}
#[test]
fn resolve_shell_modifier_simple() {
let mut bindings = ResolvedBindings::new();
bindings.set("msg", json!("hello world"));
let ds = empty_datastore();
let result = resolve("echo {{with.msg|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo 'hello world'");
}
#[test]
fn resolve_shell_modifier_with_quote() {
let mut bindings = ResolvedBindings::new();
bindings.set("response", json!("Hello from Nika's v0.5.1!"));
let ds = empty_datastore();
let result = resolve("echo {{with.response|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo 'Hello from Nika'\\''s v0.5.1!'");
}
#[test]
fn resolve_shell_modifier_with_special_chars() {
let mut bindings = ResolvedBindings::new();
bindings.set("content", json!("Hello; echo pwned"));
let ds = empty_datastore();
let result = resolve("echo {{with.content|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo 'Hello; echo pwned'");
}
#[test]
fn resolve_without_modifier_no_escape() {
let mut bindings = ResolvedBindings::new();
bindings.set("msg", json!("hello world"));
let ds = empty_datastore();
let result = resolve("echo {{with.msg}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo hello world");
}
#[test]
fn resolve_shell_modifier_multiple() {
let mut bindings = ResolvedBindings::new();
bindings.set("file", json!("test.txt"));
bindings.set("content", json!("Hello 'world'"));
let ds = empty_datastore();
let result = resolve(
"cat {{with.file|shell}} && echo {{with.content|shell}}",
&bindings,
&ds,
)
.unwrap();
assert_eq!(result, "cat 'test.txt' && echo 'Hello '\\''world'\\'''");
}
#[test]
fn resolve_for_shell_simple() {
let mut bindings = ResolvedBindings::new();
bindings.set("msg", json!("hello world"));
let ds = empty_datastore();
let result = resolve_for_shell("echo {{with.msg}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo 'hello world'");
}
#[test]
fn resolve_for_shell_with_quote() {
let mut bindings = ResolvedBindings::new();
bindings.set("response", json!("Hello from Nika's v0.5.1!"));
let ds = empty_datastore();
let result =
resolve_for_shell("echo 'Claude said: {{with.response}}'", &bindings, &ds).unwrap();
assert_eq!(
result,
"echo 'Claude said: 'Hello from Nika'\\''s v0.5.1!''"
);
}
#[test]
fn resolve_for_shell_no_templates() {
let bindings = ResolvedBindings::new();
let ds = empty_datastore();
let result = resolve_for_shell("echo hello", &bindings, &ds).unwrap();
assert_eq!(result, "echo hello");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn resolve_for_shell_preserves_command_structure() {
let mut bindings = ResolvedBindings::new();
bindings.set("file", json!("test.txt"));
bindings.set("content", json!("Hello; echo pwned"));
let ds = empty_datastore();
let result =
resolve_for_shell("cat {{with.file}} && echo {{with.content}}", &bindings, &ds)
.unwrap();
assert_eq!(result, "cat 'test.txt' && echo 'Hello; echo pwned'");
}
use rustc_hash::FxHashMap;
fn datastore_with_inputs() -> RunContext {
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"default": "AI QR code generation"
}),
);
inputs.insert(
"depth".to_string(),
json!({
"type": "string",
"default": "comprehensive"
}),
);
inputs.insert(
"config".to_string(),
json!({
"type": "object",
"default": {
"theme": "dark",
"count": 5
}
}),
);
store.set_inputs(inputs);
store
}
#[test]
fn resolve_inputs_simple() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve("Topic: {{inputs.topic}}", &bindings, &ds).unwrap();
assert_eq!(result, "Topic: AI QR code generation");
}
#[test]
fn resolve_inputs_multiple() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve(
"Research {{inputs.topic}} at {{inputs.depth}} depth",
&bindings,
&ds,
)
.unwrap();
assert_eq!(
result,
"Research AI QR code generation at comprehensive depth"
);
}
#[test]
fn resolve_inputs_nested() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve("Theme: {{inputs.config.theme}}", &bindings, &ds).unwrap();
assert_eq!(result, "Theme: dark");
}
#[test]
fn resolve_inputs_with_use_bindings() {
let mut bindings = ResolvedBindings::new();
bindings.set("greeting", json!("Hello"));
let ds = datastore_with_inputs();
let result = resolve(
"{{with.greeting}}! Research {{inputs.topic}}",
&bindings,
&ds,
)
.unwrap();
assert_eq!(result, "Hello! Research AI QR code generation");
}
#[test]
fn resolve_inputs_with_context() {
let mut bindings = ResolvedBindings::new();
bindings.set("msg", json!("Test"));
let store = RunContext::new();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("QR Code AI"));
store.set_context(context);
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"default": "AI trends"
}),
);
store.set_inputs(inputs);
let result = resolve(
"{{with.msg}}: {{context.files.brand}} - {{inputs.topic}}",
&bindings,
&store,
)
.unwrap();
assert_eq!(result, "Test: QR Code AI - AI trends");
}
#[test]
fn resolve_inputs_not_found() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve("{{inputs.nonexistent}}", &bindings, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Input binding"));
assert!(err.to_string().contains("nonexistent"));
}
#[test]
fn resolve_inputs_no_inputs_loaded() {
let bindings = ResolvedBindings::new();
let ds = empty_datastore();
let result = resolve("{{inputs.topic}}", &bindings, &ds);
assert!(result.is_err());
}
#[test]
fn resolve_only_inputs_no_use() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve("Topic is {{inputs.topic}}", &bindings, &ds).unwrap();
assert_eq!(result, "Topic is AI QR code generation");
}
#[test]
fn resolve_inputs_preserves_no_template() {
let bindings = ResolvedBindings::new();
let ds = datastore_with_inputs();
let result = resolve("Plain text without templates", &bindings, &ds).unwrap();
assert_eq!(result, "Plain text without templates");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn injection_template_syntax_not_reevaluated() {
let mut bindings = ResolvedBindings::new();
bindings.set("user_input", json!("{{with.secret}}"));
bindings.set("secret", json!("TOP_SECRET"));
let ds = empty_datastore();
let result = resolve("User said: {{with.user_input}}", &bindings, &ds).unwrap();
assert_eq!(result, "User said: {{with.secret}}");
assert!(!result.contains("TOP_SECRET"));
}
#[test]
fn injection_nested_template_attack() {
let mut bindings = ResolvedBindings::new();
bindings.set("left", json!("{{with."));
bindings.set("right", json!("secret}}"));
bindings.set("secret", json!("LEAKED"));
let ds = empty_datastore();
let result = resolve("{{with.left}}{{with.right}}", &bindings, &ds).unwrap();
assert_eq!(result, "{{with.secret}}");
assert!(!result.contains("LEAKED"));
}
#[test]
fn injection_json_context_quotes_escaped() {
let mut bindings = ResolvedBindings::new();
bindings.set("name", json!(r#"Alice", "admin": true, "x": "#));
let ds = empty_datastore();
let template = r#"{"user": "{{with.name}}"}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(
result.contains(r#"\""#),
"Quotes should be escaped: {}",
result
);
assert_eq!(
result, r#"{"user": "Alice\", \"admin\": true, \"x\": "}"#,
"Quotes should be escaped to prevent JSON structure injection"
);
assert!(result.contains(r#"\"admin\""#), "admin should be escaped");
}
#[test]
fn injection_json_context_backslash_escaped() {
let mut bindings = ResolvedBindings::new();
bindings.set("path", json!(r#"C:\Users\admin"#));
let ds = empty_datastore();
let template = r#"{"path": "{{with.path}}"}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(
result.contains(r#"\\"#),
"Backslashes should be escaped: {}",
result
);
}
#[test]
fn injection_json_context_newline_escaped() {
let mut bindings = ResolvedBindings::new();
bindings.set("text", json!("line1\nline2"));
let ds = empty_datastore();
let template = r#"{"text": "{{with.text}}"}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(
result.contains(r#"\n"#),
"Newlines should be escaped: {}",
result
);
assert!(
!result.contains('\n') || result.matches('\n').count() == 0 || result.contains("\\n"),
"Raw newlines should be escaped"
);
}
#[test]
fn injection_shell_modifier_escapes_semicolon() {
let mut bindings = ResolvedBindings::new();
bindings.set("filename", json!("file.txt; rm -rf /"));
let ds = empty_datastore();
let result = resolve("cat {{with.filename|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "cat 'file.txt; rm -rf /'");
assert!(result.starts_with("cat '") && result.ends_with("'"));
}
#[test]
fn injection_shell_modifier_escapes_backticks() {
let mut bindings = ResolvedBindings::new();
bindings.set("input", json!("`whoami`"));
let ds = empty_datastore();
let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo '`whoami`'");
}
#[test]
fn injection_shell_modifier_escapes_dollar_parens() {
let mut bindings = ResolvedBindings::new();
bindings.set("input", json!("$(cat /etc/passwd)"));
let ds = empty_datastore();
let result = resolve("echo {{with.input|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "echo '$(cat /etc/passwd)'");
}
#[test]
fn injection_shell_modifier_escapes_env_vars() {
let mut bindings = ResolvedBindings::new();
bindings.set("input", json!("$HOME/.ssh/id_rsa"));
let ds = empty_datastore();
let result = resolve("cat {{with.input|shell}}", &bindings, &ds).unwrap();
assert_eq!(result, "cat '$HOME/.ssh/id_rsa'");
}
#[test]
fn injection_resolve_for_shell_escapes_all() {
let mut bindings = ResolvedBindings::new();
bindings.set("cmd", json!("echo 'pwned'; rm -rf /"));
let ds = empty_datastore();
let result = resolve_for_shell("{{with.cmd}}", &bindings, &ds).unwrap();
assert_eq!(result, "'echo '\\''pwned'\\''; rm -rf /'");
}
#[test]
fn injection_control_characters_json() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!("a\tb\rc\x0c"));
let ds = empty_datastore();
let template = r#"{"data": "{{with.data}}"}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(result.contains(r#"\t"#) || !result.contains('\t'));
assert!(result.contains(r#"\r"#) || !result.contains('\r'));
}
#[test]
fn injection_unicode_escape_sequences() {
let mut bindings = ResolvedBindings::new();
bindings.set("text", json!(r#"\u0000"#)); let ds = empty_datastore();
let result = resolve("Text: {{with.text}}", &bindings, &ds).unwrap();
assert_eq!(result, r#"Text: \u0000"#);
}
#[test]
fn injection_null_byte_in_value() {
let mut bindings = ResolvedBindings::new();
bindings.set("normal", json!("safe"));
let ds = empty_datastore();
let result = resolve("{{with.normal}}", &bindings, &ds).unwrap();
assert_eq!(result, "safe");
}
#[test]
fn injection_very_long_value() {
let mut bindings = ResolvedBindings::new();
let long_string = "A".repeat(100_000);
bindings.set("big", json!(long_string.clone()));
let ds = empty_datastore();
let result = resolve("Data: {{with.big}}", &bindings, &ds).unwrap();
assert!(result.starts_with("Data: AAAA"));
assert_eq!(result.len(), 6 + 100_000); }
#[test]
fn injection_deeply_nested_json_value() {
let mut bindings = ResolvedBindings::new();
bindings.set("nested", json!({"a": {"b": {"c": {"d": "deep"}}}}));
let ds = empty_datastore();
let result = resolve("{{with.nested}}", &bindings, &ds).unwrap();
assert!(result.contains("deep"));
}
#[test]
fn injection_template_markers_in_context_path() {
let bindings = ResolvedBindings::new();
let store = RunContext::new();
let mut context = LoadedContext::new();
context
.files
.insert("normal".to_string(), json!("safe content"));
store.set_context(context);
let result = resolve("{{context.files.normal}}", &bindings, &store).unwrap();
assert_eq!(result, "safe content");
}
#[test]
fn injection_context_value_with_template_syntax() {
let bindings = ResolvedBindings::new();
let store = RunContext::new();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("Brand: {{with.secret}}"));
store.set_context(context);
let result = resolve("{{context.files.brand}}", &bindings, &store).unwrap();
assert_eq!(result, "Brand: {{with.secret}}");
}
#[test]
fn injection_input_value_with_template_syntax() {
let bindings = ResolvedBindings::new();
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert(
"topic".to_string(),
json!({
"type": "string",
"default": "Learn about {{with.secret}}"
}),
);
store.set_inputs(inputs);
let result = resolve("{{inputs.topic}}", &bindings, &store).unwrap();
assert_eq!(result, "Learn about {{with.secret}}");
}
#[test]
fn injection_3pass_no_cross_contamination() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!("{{context.files.secret}}"));
let store = RunContext::new();
let mut context = LoadedContext::new();
context
.files
.insert("secret".to_string(), json!("CONFIDENTIAL"));
store.set_context(context);
let result = resolve("Result: {{with.data}}", &bindings, &store).unwrap();
assert_eq!(result, "Result: {{context.files.secret}}");
assert!(!result.contains("CONFIDENTIAL"));
}
#[test]
fn injection_html_script_tags() {
let mut bindings = ResolvedBindings::new();
bindings.set("content", json!("<script>alert('xss')</script>"));
let ds = empty_datastore();
let result = resolve("{{with.content}}", &bindings, &ds).unwrap();
assert_eq!(result, "<script>alert('xss')</script>");
}
#[test]
fn injection_sql_like_content() {
let mut bindings = ResolvedBindings::new();
bindings.set("query", json!("'; DROP TABLE users; --"));
let ds = empty_datastore();
let result = resolve(
"SELECT * FROM x WHERE name='{{with.query}}'",
&bindings,
&ds,
)
.unwrap();
assert!(result.contains("DROP TABLE"));
}
}
#[cfg(test)]
mod v028_template_tests {
use super::*;
use crate::store::{LoadedContext, RunContext};
use serde_json::json;
fn empty_datastore() -> RunContext {
RunContext::new()
}
fn make_with(entries: &[(&str, Value)]) -> FxHashMap<String, Value> {
entries
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
#[test]
fn parse_expr_simple_alias() {
let result = parse_template_expr("title").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "title".to_string(),
transforms: vec![],
}
);
}
#[test]
fn parse_expr_alias_with_path() {
let result = parse_template_expr("data.items").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "data.items".to_string(),
transforms: vec![],
}
);
}
#[test]
fn parse_expr_alias_single_transform() {
let result = parse_template_expr("title | upper").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "title".to_string(),
transforms: vec!["upper".to_string()],
}
);
}
#[test]
fn parse_expr_alias_multi_transform() {
let result = parse_template_expr("x | sort | unique | first(3)").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "x".to_string(),
transforms: vec![
"sort".to_string(),
"unique".to_string(),
"first(3)".to_string(),
],
}
);
}
#[test]
fn parse_expr_context_files() {
let result = parse_template_expr("context.files.brand").unwrap();
assert_eq!(
result,
TemplateExpr::Context {
path: "files.brand".to_string(),
transforms: vec![]
}
);
}
#[test]
fn parse_expr_context_session() {
let result = parse_template_expr("context.session.key").unwrap();
assert_eq!(
result,
TemplateExpr::Context {
path: "session.key".to_string(),
transforms: vec![]
}
);
}
#[test]
fn parse_expr_inputs() {
let result = parse_template_expr("inputs.locale").unwrap();
assert_eq!(
result,
TemplateExpr::Input {
path: "locale".to_string(),
transforms: vec![]
}
);
}
#[test]
fn parse_expr_inputs_nested() {
let result = parse_template_expr("inputs.config.theme").unwrap();
assert_eq!(
result,
TemplateExpr::Input {
path: "config.theme".to_string(),
transforms: vec![]
}
);
}
#[test]
fn parse_expr_context_with_transforms() {
let result = parse_template_expr("context.files.brand | upper").unwrap();
assert_eq!(
result,
TemplateExpr::Context {
path: "files.brand".to_string(),
transforms: vec!["upper".to_string()]
}
);
}
#[test]
fn parse_expr_inputs_with_transforms() {
let result = parse_template_expr("inputs.topic | lower | trim").unwrap();
assert_eq!(
result,
TemplateExpr::Input {
path: "topic".to_string(),
transforms: vec!["lower".to_string(), "trim".to_string()]
}
);
}
#[test]
fn parse_expr_contextual_is_alias() {
let result = parse_template_expr("contextual").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "contextual".to_string(),
transforms: vec![],
}
);
}
#[test]
fn parse_expr_inputstream_is_alias() {
let result = parse_template_expr("inputstream").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "inputstream".to_string(),
transforms: vec![],
}
);
}
#[test]
fn parse_expr_empty_is_error() {
let result = parse_template_expr("");
assert!(result.is_err());
}
#[test]
fn parse_expr_whitespace_is_error() {
let result = parse_template_expr(" ");
assert!(result.is_err());
}
#[test]
fn parse_expr_context_dot_only_is_error() {
let result = parse_template_expr("context.");
assert!(result.is_err());
}
#[test]
fn parse_expr_inputs_dot_only_is_error() {
let result = parse_template_expr("inputs.");
assert!(result.is_err());
}
#[test]
fn parse_expr_whitespace_trimmed() {
let result = parse_template_expr(" title ").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "title".to_string(),
transforms: vec![],
}
);
}
#[test]
fn parse_expr_transform_with_spaces() {
let result = parse_template_expr(" name | upper | trim ").unwrap();
assert_eq!(
result,
TemplateExpr::Alias {
path: "name".to_string(),
transforms: vec!["upper".to_string(), "trim".to_string()],
}
);
}
#[test]
fn display_string() {
assert_eq!(value_to_display(&json!("hello")), "hello");
}
#[test]
fn display_number() {
assert_eq!(value_to_display(&json!(42)), "42");
assert_eq!(value_to_display(&json!(3.12)), "3.12");
}
#[test]
fn display_bool() {
assert_eq!(value_to_display(&json!(true)), "true");
assert_eq!(value_to_display(&json!(false)), "false");
}
#[test]
fn display_null_is_empty() {
assert_eq!(value_to_display(&Value::Null), "");
}
#[test]
fn display_array() {
assert_eq!(value_to_display(&json!([1, 2, 3])), "[1,2,3]");
}
#[test]
fn display_object() {
let val = json!({"a": 1});
let display = value_to_display(&val);
assert!(display.contains("\"a\""));
assert!(display.contains("1"));
}
#[test]
fn resolve_with_simple_alias() {
let with = make_with(&[("name", json!("World"))]);
let ds = empty_datastore();
let result = resolve_with("Hello {{name}}", &with, &ds).unwrap();
assert_eq!(result, "Hello World");
}
#[test]
fn resolve_with_deep_alias() {
let with = make_with(&[("data", json!({"items": [1, 2, 3]}))]);
let ds = empty_datastore();
let result = resolve_with("Items: {{data.items}}", &with, &ds).unwrap();
assert_eq!(result, "Items: [1,2,3]");
}
#[test]
fn resolve_with_transform() {
let with = make_with(&[("title", json!("hello world"))]);
let ds = empty_datastore();
let result = resolve_with("{{title | upper}}", &with, &ds).unwrap();
assert_eq!(result, "HELLO WORLD");
}
#[test]
fn resolve_with_array_json_serialization() {
let with = make_with(&[("items", json!(["a", "b", "c"]))]);
let ds = empty_datastore();
let result = resolve_with("{{items}}", &with, &ds).unwrap();
assert_eq!(result, "[\"a\",\"b\",\"c\"]");
}
#[test]
fn resolve_with_null_is_empty() {
let with = make_with(&[("val", Value::Null)]);
let ds = empty_datastore();
let result = resolve_with("Got: {{val}}!", &with, &ds).unwrap();
assert_eq!(result, "Got: !");
}
#[test]
fn resolve_with_multiple_aliases() {
let with = make_with(&[("a", json!("hello")), ("b", json!("world"))]);
let ds = empty_datastore();
let result = resolve_with("{{a}} and {{b}}", &with, &ds).unwrap();
assert_eq!(result, "hello and world");
}
#[test]
fn resolve_with_missing_alias_errors() {
let with = make_with(&[("name", json!("Alice"))]);
let ds = empty_datastore();
let result = resolve_with("{{missing}}", &with, &ds);
assert!(result.is_err());
}
#[test]
fn resolve_with_number() {
let with = make_with(&[("count", json!(42))]);
let ds = empty_datastore();
let result = resolve_with("Count: {{count}}", &with, &ds).unwrap();
assert_eq!(result, "Count: 42");
}
#[test]
fn resolve_with_bool() {
let with = make_with(&[("flag", json!(true))]);
let ds = empty_datastore();
let result = resolve_with("Flag: {{flag}}", &with, &ds).unwrap();
assert_eq!(result, "Flag: true");
}
#[test]
fn resolve_with_context_file() {
let with = FxHashMap::default();
let ds = empty_datastore();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("SuperNovae AI"));
ds.set_context(context);
let result = resolve_with("Brand: {{context.files.brand}}", &with, &ds).unwrap();
assert_eq!(result, "Brand: SuperNovae AI");
}
#[test]
fn resolve_with_context_session() {
let with = FxHashMap::default();
let ds = empty_datastore();
let mut context = LoadedContext::new();
context.session = Some(json!({"focus": "rust"}));
ds.set_context(context);
let result = resolve_with("Focus: {{context.session.focus}}", &with, &ds).unwrap();
assert_eq!(result, "Focus: rust");
}
#[test]
fn resolve_with_inputs() {
let with = FxHashMap::default();
let ds = empty_datastore();
let mut inputs = FxHashMap::default();
inputs.insert("locale".to_string(), json!("fr-FR"));
ds.set_inputs(inputs);
let result = resolve_with("Locale: {{inputs.locale}}", &with, &ds).unwrap();
assert_eq!(result, "Locale: fr-FR");
}
#[test]
fn resolve_with_inputs_nested() {
let with = FxHashMap::default();
let ds = empty_datastore();
let mut inputs = FxHashMap::default();
inputs.insert("config".to_string(), json!({"theme": "dark"}));
ds.set_inputs(inputs);
let result = resolve_with("Theme: {{inputs.config.theme}}", &with, &ds).unwrap();
assert_eq!(result, "Theme: dark");
}
#[test]
fn no_reevaluation_alias_containing_template() {
let with = make_with(&[("val", json!("{{context.files.secret}}"))]);
let ds = empty_datastore();
let mut context = LoadedContext::new();
context
.files
.insert("secret".to_string(), json!("TOP_SECRET"));
ds.set_context(context);
let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
assert_eq!(result, "Got: {{context.files.secret}}");
assert!(!result.contains("TOP_SECRET"));
}
#[test]
fn no_reevaluation_alias_to_alias() {
let with = make_with(&[("a", json!("{{b}}")), ("b", json!("secret"))]);
let ds = empty_datastore();
let result = resolve_with("Got: {{a}}", &with, &ds).unwrap();
assert_eq!(result, "Got: {{b}}");
}
#[test]
fn resolve_with_shell_escape() {
let with = make_with(&[("val", json!("hello 'world'"))]);
let ds = empty_datastore();
let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
assert_eq!(result, "'hello '\\''world'\\'''");
}
#[test]
fn resolve_with_shell_plus_transform() {
let with = make_with(&[("val", json!("Hello World"))]);
let ds = empty_datastore();
let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
assert_eq!(result, "'hello world'");
}
#[test]
fn resolve_with_empty_template() {
let with = FxHashMap::default();
let ds = empty_datastore();
let result = resolve_with("", &with, &ds).unwrap();
assert_eq!(result, "");
}
#[test]
fn resolve_with_no_templates() {
let with = FxHashMap::default();
let ds = empty_datastore();
let result = resolve_with("plain text", &with, &ds).unwrap();
assert_eq!(result, "plain text");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn resolve_with_unclosed_braces() {
let with = FxHashMap::default();
let ds = empty_datastore();
let result = resolve_with("{{incomplete", &with, &ds).unwrap();
assert_eq!(result, "{{incomplete");
}
#[test]
fn resolve_with_bracket_notation() {
let with = make_with(&[("items", json!(["a", "b", "c"]))]);
let ds = empty_datastore();
let result = resolve_with("{{items[1]}}", &with, &ds).unwrap();
assert_eq!(result, "b");
}
#[test]
fn resolve_with_nested_path() {
let with = make_with(&[(
"user",
json!({"name": "Alice", "address": {"city": "Paris"}}),
)]);
let ds = empty_datastore();
let result = resolve_with("{{user.address.city}}", &with, &ds).unwrap();
assert_eq!(result, "Paris");
}
#[test]
fn resolve_with_mixed_aliases_and_context() {
let with = make_with(&[("name", json!("Alice"))]);
let ds = empty_datastore();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("SuperNovae"));
ds.set_context(context);
let result =
resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
assert_eq!(result, "Hello Alice from SuperNovae");
}
#[test]
fn resolve_with_rejects_excessive_template_vars() {
let with = make_with(&[("x", json!("v"))]);
let ds = empty_datastore();
let template: String = (0..=MAX_TEMPLATE_VARS)
.map(|_| "{{x}}")
.collect::<Vec<_>>()
.join(" ");
let result = resolve_with(&template, &with, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
format!("{}", err).contains("exceeding the maximum"),
"Expected max vars error, got: {}",
err
);
}
#[test]
fn resolve_with_accepts_many_vars_under_limit() {
let with = make_with(&[("x", json!("v"))]);
let ds = empty_datastore();
let template: String = (0..MAX_TEMPLATE_VARS)
.map(|_| "{{x}}")
.collect::<Vec<_>>()
.join(" ");
let result = resolve_with(&template, &with, &ds);
assert!(result.is_ok());
}
#[test]
fn resolve_alias_rejects_excessive_path_depth() {
let segments: Vec<String> = (0..=MAX_PATH_DEPTH).map(|i| format!("k{}", i)).collect();
let deep_path = segments.join(".");
let mut value: Value = json!("leaf");
for key in segments.iter().rev().skip(1) {
let mut map = serde_json::Map::new();
map.insert(key.clone(), value);
value = Value::Object(map);
}
let with = make_with(&[(segments[0].as_str(), value)]);
let ds = empty_datastore();
let template = format!("{{{{{}}}}}", deep_path);
let result = resolve_with(&template, &with, &ds);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
format!("{}", err).contains("exceeds maximum"),
"Expected path depth error, got: {}",
err
);
}
#[test]
fn extract_refs_simple() {
let refs = extract_with_refs("Hello {{name}}!");
assert_eq!(refs, vec!["name".to_string()]);
}
#[test]
fn extract_refs_deep_path() {
let refs = extract_with_refs("{{data.items.0}}");
assert_eq!(refs, vec!["data".to_string()]);
}
#[test]
fn extract_refs_with_transforms() {
let refs = extract_with_refs("{{title | upper | trim}}");
assert_eq!(refs, vec!["title".to_string()]);
}
#[test]
fn extract_refs_skips_context_and_inputs() {
let refs = extract_with_refs("{{name}} and {{context.files.brand}} and {{inputs.locale}}");
assert_eq!(refs, vec!["name".to_string()]);
}
#[test]
fn extract_refs_empty() {
let refs = extract_with_refs("no templates here");
assert!(refs.is_empty());
}
#[test]
fn extract_refs_multiple() {
let refs = extract_with_refs("{{a}} then {{b.field}} then {{c}}");
assert_eq!(
refs,
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
}
#[test]
fn validate_refs_all_declared() {
let declared: FxHashSet<String> = ["name", "title"].iter().map(|s| s.to_string()).collect();
let result = validate_with_refs("{{name}} and {{title}}", &declared, "task1");
assert!(result.is_ok());
}
#[test]
fn validate_refs_unknown_alias() {
let declared: FxHashSet<String> = ["name"].iter().map(|s| s.to_string()).collect();
let result = validate_with_refs("{{name}} and {{missing}}", &declared, "task1");
assert!(result.is_err());
}
#[test]
fn validate_refs_context_not_checked() {
let declared: FxHashSet<String> = FxHashSet::default();
let result = validate_with_refs("{{context.files.brand}}", &declared, "task1");
assert!(result.is_ok());
}
#[test]
fn validate_refs_inputs_not_checked() {
let declared: FxHashSet<String> = FxHashSet::default();
let result = validate_with_refs("{{inputs.locale}}", &declared, "task1");
assert!(result.is_ok());
}
#[test]
fn audit_is_in_json_context_false_positive_unbalanced() {
let mut bindings = ResolvedBindings::new();
bindings.set("msg", json!("line1\nline2"));
let ds = empty_datastore();
let template = r#"He said "hello {{with.msg}}"#;
let result = resolve(template, &bindings, &ds).unwrap();
let has_escaped_newline = result.contains("\\n");
let has_raw_newline = result.contains('\n');
if has_escaped_newline && !has_raw_newline {
panic!(
"GAP CONFIRMED: is_in_json_context false positive! \
Non-JSON template '{}' has value JSON-escaped. \
Result: '{}'",
template, result
);
}
assert!(
has_raw_newline,
"Newline should be preserved (not JSON-escaped): '{}'",
result
);
}
#[test]
fn audit_is_in_json_context_correct_for_real_json() {
let mut bindings = ResolvedBindings::new();
bindings.set("name", json!("line1\nline2"));
let ds = empty_datastore();
let template = r#"{"user": "{{with.name}}"}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(
result.contains("\\n"),
"Newline should be JSON-escaped in JSON context: '{}'",
result
);
assert!(
!result.contains('\n'),
"Raw newline must not appear in JSON context: '{}'",
result
);
}
#[test]
fn audit_is_in_json_context_balanced_quotes_outside() {
let mut bindings = ResolvedBindings::new();
bindings.set("val", json!("test\nvalue"));
let ds = empty_datastore();
let template = r#"He said "hi" then {{with.val}}"#;
let result = resolve(template, &bindings, &ds).unwrap();
assert!(
result.contains('\n'),
"Outside JSON context, newline should be raw: '{}'",
result
);
}
#[test]
fn audit_resolve_for_shell_missing_inputs_support() {
let bindings = ResolvedBindings::new();
let store = RunContext::new();
let mut inputs = FxHashMap::default();
inputs.insert("topic".to_string(), json!("AI safety"));
store.set_inputs(inputs);
let result = resolve_for_shell("echo {{inputs.topic}}", &bindings, &store).unwrap();
if result.contains("{{inputs.topic}}") {
panic!(
"GAP CONFIRMED: resolve_for_shell does not resolve \
inputs templates. Result: '{}'. Fix: add has_inputs \
check alongside has_with and has_context.",
result
);
}
assert!(result.contains("AI safety"));
}
#[test]
fn audit_bracket_notation_negative_index() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["a", "b", "c"]));
let ds = empty_datastore();
let result = resolve("{{with.items[-1]}}", &bindings, &ds);
assert!(
result.is_err(),
"Negative index should produce an error, got: {:?}",
result
);
}
#[test]
fn audit_bracket_notation_non_numeric() {
let mut bindings = ResolvedBindings::new();
bindings.set("data", json!({"key": "value"}));
let ds = empty_datastore();
let result = resolve("{{with.data[key]}}", &bindings, &ds);
assert!(
result.is_err(),
"Non-numeric bracket access should produce an error, got: {:?}",
result
);
}
#[test]
fn audit_bracket_notation_root_array() {
let mut bindings = ResolvedBindings::new();
bindings.set("list", json!(["first", "second", "third"]));
let ds = empty_datastore();
let result = resolve("{{with.list[2]}}", &bindings, &ds).unwrap();
assert_eq!(result, "third");
}
#[test]
fn audit_bracket_notation_nested_arrays() {
let mut bindings = ResolvedBindings::new();
bindings.set("matrix", json!([[1, 2], [3, 4]]));
let ds = empty_datastore();
let result = resolve("{{with.matrix[1][0]}}", &bindings, &ds).unwrap();
assert_eq!(result, "3");
}
#[test]
fn audit_shell_transform_vs_escape_for_shell_consistency() {
let test_cases = vec![
"simple",
"hello world",
"it's a test",
"double\"quote",
"",
"special;chars|here",
"$(whoami)",
"`uname`",
"$HOME/.ssh",
"line1\nline2",
"tab\there",
];
for input in test_cases {
let method1 = escape_for_shell(input);
use crate::binding::transform::TransformOp;
let json_val = json!(input);
let method2_val = TransformOp::Shell.apply(&json_val).unwrap();
let method2 = method2_val.as_str().unwrap().to_string();
assert_eq!(
method1, method2,
"Shell escaping methods differ for input '{}': \
escape_for_shell='{}' vs TransformOp::Shell='{}'",
input, method1, method2
);
}
}
fn media_template_fixtures() -> (RunContext, ResolvedBindings) {
use crate::binding::entry::{BindingEntry, BindingSpec};
let store = RunContext::new();
let gen_media = vec![crate::media::MediaRef {
hash: "blake3:abc123".to_string(),
mime_type: "image/png".to_string(),
size_bytes: 524288,
path: std::path::PathBuf::from("/tmp/cas/ab/c123"),
extension: "png".to_string(),
created_by: "gen".to_string(),
metadata: {
let mut m = serde_json::Map::new();
m.insert("width".to_string(), json!(1024));
m.insert("height".to_string(), json!(768));
m
},
}];
store.insert(
std::sync::Arc::from("gen"),
crate::store::TaskResult::success(
json!({"prompt": "a sunset photo"}),
std::time::Duration::from_secs(3),
)
.with_media(gen_media),
);
store.insert(
std::sync::Arc::from("thumb"),
crate::store::TaskResult::success_str(
r#"{"hash":"blake3:def456","mime_type":"image/png","size_bytes":2048,"metadata":{"width":256,"height":192}}"#,
std::time::Duration::from_millis(100),
),
);
let mut spec = BindingSpec::default();
spec.insert(
"source_hash".to_string(),
BindingEntry::new("gen.media[0].hash"),
);
spec.insert(
"source_width".to_string(),
BindingEntry::new("gen.media[0].metadata.width"),
);
spec.insert("thumb".to_string(), BindingEntry::new("thumb"));
spec.insert("thumb_hash".to_string(), BindingEntry::new("thumb.hash"));
spec.insert(
"thumb_width".to_string(),
BindingEntry::new("thumb.metadata.width"),
);
let bindings = ResolvedBindings::from_binding_spec(Some(&spec), &store).unwrap();
(store, bindings)
}
#[test]
fn media_template_resolve_source_hash() {
let (store, bindings) = media_template_fixtures();
let result = resolve("Source image hash: {{with.source_hash}}", &bindings, &store).unwrap();
assert_eq!(result.as_ref(), "Source image hash: blake3:abc123");
}
#[test]
fn media_template_resolve_source_width() {
let (store, bindings) = media_template_fixtures();
let result = resolve("Original width: {{with.source_width}}px", &bindings, &store).unwrap();
assert_eq!(result.as_ref(), "Original width: 1024px");
}
#[test]
fn media_template_resolve_thumb_hash() {
let (store, bindings) = media_template_fixtures();
let result = resolve("Thumbnail hash: {{with.thumb_hash}}", &bindings, &store).unwrap();
assert_eq!(result.as_ref(), "Thumbnail hash: blake3:def456");
}
#[test]
fn media_template_resolve_thumb_nested_width() {
let (store, bindings) = media_template_fixtures();
let result = resolve("Thumb is {{with.thumb_width}}px wide", &bindings, &store).unwrap();
assert_eq!(result.as_ref(), "Thumb is 256px wide");
}
#[test]
fn media_template_thumb_output_traversal_requires_binding_spec() {
let (store, bindings) = media_template_fixtures();
let result = resolve(
"Hash via binding spec: {{with.thumb_hash}}",
&bindings,
&store,
)
.unwrap();
assert_eq!(result.as_ref(), "Hash via binding spec: blake3:def456");
let err = resolve("Broken: {{with.thumb.hash}}", &bindings, &store);
assert!(
err.is_err(),
"Traversing .hash on a JSON-string Value::String should error"
);
}
#[test]
fn media_template_thumb_deep_traversal_via_binding_spec() {
let (store, bindings) = media_template_fixtures();
let result = resolve("Width: {{with.thumb_width}}", &bindings, &store).unwrap();
assert_eq!(result.as_ref(), "Width: 256");
}
#[test]
fn media_template_thumb_output_as_parsed_json_object() {
let store = RunContext::new();
store.insert(
std::sync::Arc::from("thumb2"),
crate::store::TaskResult::success(
json!({
"hash": "blake3:parsed_obj",
"metadata": { "width": 128 }
}),
std::time::Duration::from_millis(50),
),
);
let mut bindings = ResolvedBindings::new();
bindings.set(
"thumb2",
json!({"hash": "blake3:parsed_obj", "metadata": {"width": 128}}),
);
let result = resolve(
"Hash: {{with.thumb2.hash}}, Width: {{with.thumb2.metadata.width}}",
&bindings,
&store,
)
.unwrap();
assert_eq!(result.as_ref(), "Hash: blake3:parsed_obj, Width: 128");
}
#[test]
fn media_template_chained_bindings_in_one_template() {
let (store, bindings) = media_template_fixtures();
let result = resolve(
"Source: {{with.source_hash}}, Thumb: {{with.thumb_hash}}, Width: {{with.thumb_width}}",
&bindings,
&store,
)
.unwrap();
assert_eq!(
result.as_ref(),
"Source: blake3:abc123, Thumb: blake3:def456, Width: 256"
);
}
#[test]
fn media_template_chained_bindings_in_prompt() {
let (store, bindings) = media_template_fixtures();
let result = resolve(
"The image ({{with.source_hash}}) was resized to {{with.thumb_width}}px. \
The thumbnail hash is {{with.thumb_hash}}.",
&bindings,
&store,
)
.unwrap();
assert_eq!(
result.as_ref(),
"The image (blake3:abc123) was resized to 256px. \
The thumbnail hash is blake3:def456."
);
}
#[test]
fn media_template_no_templates_returns_borrowed() {
let (store, bindings) = media_template_fixtures();
let result = resolve("plain text without templates", &bindings, &store).unwrap();
assert!(
matches!(result, std::borrow::Cow::Borrowed(_)),
"No-template strings should be zero-alloc Cow::Borrowed"
);
}
#[test]
fn media_template_json_context_escaping() {
let (store, bindings) = media_template_fixtures();
let result = resolve(
r#"{"source": "{{with.source_hash}}", "thumb": "{{with.thumb_hash}}"}"#,
&bindings,
&store,
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["source"], "blake3:abc123");
assert_eq!(parsed["thumb"], "blake3:def456");
}
#[test]
fn regression_bug29_bracket_notation_preserves_literal_text() {
let mut bindings = ResolvedBindings::new();
bindings.set("items", json!(["first", "second", "third"]));
let ds = empty_datastore();
let result = resolve("data[0] is {{with.items[0]}}", &bindings, &ds).unwrap();
assert_eq!(
result, "data[0] is first",
"Literal 'data[0]' outside {{}} must NOT be normalized to 'data.0'"
);
}
#[test]
fn regression_bug29_multiple_literal_brackets() {
let with = make_with(&[("items", json!(["a", "b"]))]);
let ds = empty_datastore();
let result = resolve_with(
"arr[0] and arr[1] are {{items[0]}} and {{items[1]}}",
&with,
&ds,
)
.unwrap();
assert_eq!(
result, "arr[0] and arr[1] are a and b",
"Multiple literal brackets must be preserved"
);
}
#[test]
fn regression_bug29_normalize_unit_test() {
assert_eq!(
normalize_bracket_notation("data[0] is {{with.items[0]}}"),
"data[0] is {{with.items.0}}"
);
assert_eq!(
normalize_bracket_notation("array[5] is cool"),
"array[5] is cool"
);
assert_eq!(
normalize_bracket_notation("{{a[0]}} and {{b[1]}}"),
"{{a.0}} and {{b.1}}"
);
}
#[test]
fn regression_bug45_no_cross_pass_contamination() {
let with = make_with(&[("user_input", json!("{{context.files.secret}}"))]);
let ds = empty_datastore();
let mut context = LoadedContext::new();
context
.files
.insert("secret".to_string(), json!("TOP_SECRET_VALUE"));
ds.set_context(context);
let result = resolve_with("Result: {{user_input}}", &with, &ds).unwrap();
assert_eq!(result, "Result: {{context.files.secret}}");
assert!(
!result.contains("TOP_SECRET_VALUE"),
"with: value containing {{context.files.x}} must NOT be evaluated"
);
}
#[test]
fn regression_bug45_no_inputs_injection() {
let with = make_with(&[("val", json!("{{inputs.locale}}"))]);
let ds = empty_datastore();
let mut inputs = FxHashMap::default();
inputs.insert("locale".to_string(), json!("fr-FR"));
ds.set_inputs(inputs);
let result = resolve_with("Got: {{val}}", &with, &ds).unwrap();
assert_eq!(result, "Got: {{inputs.locale}}");
assert!(
!result.contains("fr-FR"),
"with: value containing {{inputs.x}} must NOT be evaluated"
);
}
#[test]
fn regression_bug45_legitimate_context_still_resolves() {
let with = make_with(&[("name", json!("Alice"))]);
let ds = empty_datastore();
let mut context = LoadedContext::new();
context
.files
.insert("brand".to_string(), json!("SuperNovae"));
ds.set_context(context);
let result =
resolve_with("Hello {{name}} from {{context.files.brand}}", &with, &ds).unwrap();
assert_eq!(result, "Hello Alice from SuperNovae");
}
#[test]
fn regression_bug47_shell_not_double_applied() {
let with = make_with(&[("val", json!("hello world"))]);
let ds = empty_datastore();
let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
assert_eq!(result, "'hello world'");
}
#[test]
fn regression_bug47_shell_with_chain_not_double_applied() {
let with = make_with(&[("val", json!("Hello World"))]);
let ds = empty_datastore();
let result = resolve_with("{{val | lower | shell}}", &with, &ds).unwrap();
assert_eq!(result, "'hello world'");
}
#[test]
fn regression_bug47_shell_with_quotes() {
let with = make_with(&[("val", json!("it's a test"))]);
let ds = empty_datastore();
let result = resolve_with("{{val | shell}}", &with, &ds).unwrap();
assert_eq!(result, "'it'\\''s a test'");
}
}