use std::collections::HashMap;
use anyhow::{Result, anyhow, bail};
use crate::contract::CommandContext;
const MAX_PLACEHOLDER_SCAN: usize = 1024;
pub struct TemplateDelimiters {
pub open: &'static [u8],
pub close: &'static [u8],
}
impl Default for TemplateDelimiters {
fn default() -> Self {
Self {
open: b"{{",
close: b"}}",
}
}
}
pub struct StreamingExpand {
buffer: Vec<u8>,
overrides: HashMap<String, String>,
env: HashMap<String, String>,
vars: HashMap<String, oxdock_parser::Value>,
in_placeholder: bool,
pending_brace: bool,
pending_close_brace: bool,
pending_escape: bool,
pending_escape_brace: bool,
delimiters: TemplateDelimiters,
}
impl StreamingExpand {
pub fn new(overrides: &[(String, String)], env: &HashMap<String, String>) -> Self {
Self {
buffer: Vec::with_capacity(256),
overrides: overrides.iter().cloned().collect(),
env: env.clone(),
vars: HashMap::new(),
in_placeholder: false,
pending_brace: false,
pending_close_brace: false,
pending_escape: false,
pending_escape_brace: false,
delimiters: TemplateDelimiters::default(),
}
}
pub fn with_vars(mut self, vars: &HashMap<String, oxdock_parser::Value>) -> Self {
self.vars = vars.clone();
self
}
pub fn process_bytes(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<usize> {
if input.is_empty() {
return Ok(0);
}
let start_len = out.len();
let mut i = 0;
if self.pending_close_brace {
self.pending_close_brace = false;
if input[0] == self.delimiters.close[1] {
let key = extract_key(&self.buffer);
let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
out.extend_from_slice(value.as_bytes());
self.buffer.clear();
self.in_placeholder = false;
i = self.delimiters.close.len() - 1; } else {
self.buffer.push(self.delimiters.close[0]);
i = 0; }
}
if self.pending_brace {
self.pending_brace = false;
if input[0] == self.delimiters.open[1] {
self.in_placeholder = true;
self.buffer.clear();
i = 1; } else {
out.push(self.delimiters.open[0]);
}
}
if self.pending_escape_brace {
self.pending_escape_brace = false;
if !input.is_empty() && input[0] == self.delimiters.open[1] {
out.extend_from_slice(b"{{");
i = 1;
} else {
out.push(b'\\');
out.push(self.delimiters.open[0]);
}
}
if self.pending_escape {
self.pending_escape = false;
if input.len() >= 2 && input[0] == b'{' && input[1] == b'{' {
out.extend_from_slice(b"{{");
i = 2;
} else if input.len() == 1 && input[0] == self.delimiters.open[0] {
self.pending_escape_brace = true;
i = 1;
} else if !input.is_empty() && input[0] == b'\\' {
out.push(b'\\');
i = 1;
} else {
out.push(b'\\');
}
}
if self.in_placeholder {
i = self.scan_placeholder(input, i, out)?;
}
while i < input.len() {
if input[i] == b'\\' {
if i + 2 < input.len() && input[i + 1] == b'{' && input[i + 2] == b'{' {
out.extend_from_slice(b"{{");
i += 3;
} else if i + 2 == input.len() && input[i + 1] == self.delimiters.open[0] {
self.pending_escape_brace = true;
i += 2;
} else if i + 1 < input.len() && input[i + 1] == b'\\' {
out.push(b'\\');
i += 2;
} else if i + 1 == input.len() {
self.pending_escape = true;
i += 1;
} else {
out.push(b'\\');
i += 1;
}
} else if input[i] == self.delimiters.open[0] {
if i + 1 < input.len() && input[i + 1] == self.delimiters.open[1] {
self.in_placeholder = true;
self.buffer.clear();
i += 2;
i = self.scan_placeholder(input, i, out)?;
} else if i + 1 == input.len() {
self.pending_brace = true;
i += 1;
} else {
out.push(self.delimiters.open[0]);
i += 1;
}
} else {
let start = i;
while i < input.len() && input[i] != self.delimiters.open[0] && input[i] != b'\\' {
i += 1;
}
out.extend_from_slice(&input[start..i]);
}
}
Ok(out.len() - start_len)
}
pub fn flush(mut self, out: &mut Vec<u8>) -> Result<()> {
if self.pending_close_brace {
self.buffer.push(self.delimiters.close[0]);
self.pending_close_brace = false;
}
if self.pending_brace {
out.push(self.delimiters.open[0]);
self.pending_brace = false;
}
if self.pending_escape {
out.push(b'\\');
self.pending_escape = false;
}
if self.pending_escape_brace {
out.push(b'\\');
out.push(self.delimiters.open[0]);
self.pending_escape_brace = false;
}
if self.in_placeholder {
out.extend_from_slice(self.delimiters.open);
self.in_placeholder = false;
}
out.extend_from_slice(&self.buffer);
self.buffer.clear();
Ok(())
}
pub fn expand_string(self, input: &str) -> Result<String> {
let mut out = Vec::with_capacity(input.len());
let mut expander = self;
expander.process_bytes(input.as_bytes(), &mut out)?;
expander.flush(&mut out)?;
Ok(String::from_utf8(out).unwrap_or_default())
}
fn scan_placeholder(&mut self, input: &[u8], mut i: usize, out: &mut Vec<u8>) -> Result<usize> {
while i < input.len() {
if input[i] == self.delimiters.close[0] {
if i + 1 < input.len() && input[i + 1] == self.delimiters.close[1] {
let key = extract_key(&self.buffer);
let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
out.extend_from_slice(value.as_bytes());
self.buffer.clear();
self.in_placeholder = false;
return Ok(i + 2);
}
if i + 1 == input.len() {
self.pending_close_brace = true;
return Ok(i + 1);
}
}
self.buffer.push(input[i]);
i += 1;
if self.buffer.len() > MAX_PLACEHOLDER_SCAN {
out.extend_from_slice(self.delimiters.open);
out.extend_from_slice(&self.buffer);
self.buffer.clear();
self.in_placeholder = false;
return Ok(i);
}
}
Ok(i)
}
}
fn extract_key(buffer: &[u8]) -> String {
String::from_utf8_lossy(buffer).trim().to_string()
}
fn lookup(
raw_key: &str,
overrides: &HashMap<String, String>,
env: &HashMap<String, String>,
vars: &HashMap<String, oxdock_parser::Value>,
) -> Result<String> {
let key = raw_key.trim();
if let Some(val) = overrides.get(key) {
return Ok(val.clone());
}
if let Some(env_key) = key.strip_prefix("env:") {
if let Some(val) = overrides.get(env_key).or_else(|| env.get(env_key)) {
return Ok(val.clone());
}
let hint = if vars.contains_key(env_key) {
format!("; did you mean '${env_key}' (script variable)?")
} else {
String::new()
};
bail!("undefined environment variable: '{env_key}'{hint}");
}
if let Some(var_key) = key.strip_prefix('$') {
if var_key.contains('.') {
let parts: Vec<&str> = var_key.split('.').collect();
return resolve_key_path_strict(&parts, vars);
}
if let Some(val) = vars.get(var_key) {
return Ok(format_value_for_string(val));
}
let hint = if env.contains_key(var_key) {
format!("; did you mean 'env:{var_key}' (environment variable)?")
} else if overrides.contains_key(var_key) {
format!("; did you mean '{var_key}' (step override)?")
} else {
String::new()
};
bail!("undefined script variable: '${var_key}'{hint}");
}
if !key.is_empty()
&& key
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
{
let hint = if vars.contains_key(key) {
format!("; did you mean '${key}' (script variable)?")
} else if env.contains_key(key) {
format!("; did you mean 'env:{key}' (environment variable)?")
} else {
String::new()
};
bail!("missing required step override argument: '{key}'{hint}");
}
bail!(
"invalid placeholder format '{key}': script variables must start with '$' and environment variables with 'env:'"
);
}
fn resolve_key_path_strict(
parts: &[&str],
vars: &HashMap<String, oxdock_parser::Value>,
) -> Result<String> {
let root_key = parts[0];
let mut current = vars
.get(root_key)
.ok_or_else(|| anyhow!("undefined script variable: '${root_key}'"))?;
for &segment in &parts[1..] {
match current {
oxdock_parser::Value::Map(map) => {
current = map.get(segment).ok_or_else(|| {
anyhow!("property '{segment}' not found on object '${root_key}'")
})?;
}
oxdock_parser::Value::List(list) => {
let idx: usize = segment.parse().map_err(|_| {
anyhow!("invalid array index '{segment}' on list '${root_key}'")
})?;
current = list.get(idx).ok_or_else(|| {
anyhow!(
"index {idx} out of bounds for list '${root_key}' (len: {})",
list.len()
)
})?;
}
_ => bail!("cannot access property '{segment}' on primitive value of '${root_key}'"),
}
}
Ok(format_value_for_string(current))
}
fn format_value_for_string(val: &oxdock_parser::Value) -> String {
match val {
oxdock_parser::Value::String(s) => s.clone(),
oxdock_parser::Value::Int(i) => i.to_string(),
oxdock_parser::Value::Float(f) => f.to_string(),
oxdock_parser::Value::Bool(b) => b.to_string(),
oxdock_parser::Value::Pipe(n) => format!("pipe:{n}"),
oxdock_parser::Value::Duration(d) => oxdock_parser::command::format_duration(d),
oxdock_parser::Value::Path(p) => p.to_string_lossy().to_string(),
oxdock_parser::Value::List(items) => items
.iter()
.map(format_value_for_string)
.collect::<Vec<_>>()
.join(" "),
oxdock_parser::Value::Map(map) => map
.iter()
.map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
.collect::<Vec<_>>()
.join(", "),
oxdock_parser::Value::TaskHandle(id) => format!("task#{}", id),
}
}
pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
where
F: FnMut(&str) -> Option<String>,
{
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
if let Some(&'{') = chars.peek() {
chars.next(); let mut content = String::new();
let mut closed = false;
let mut inner_chars = chars.clone();
while let Some(ch) = inner_chars.next() {
if ch == '}'
&& let Some(&'}') = inner_chars.peek()
{
closed = true;
break;
}
content.push(ch);
}
if closed {
for _ in 0..content.chars().count() {
chars.next();
}
chars.next(); chars.next();
let key = content.trim();
if !key.is_empty() {
out.push_str(&lookup_fn(key).unwrap_or_default());
}
} else {
out.push('{');
out.push('{');
}
} else {
out.push('{');
}
} else {
out.push(c);
}
}
out
}
pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
expand_with_lookup(input, |name| {
if let Some(key) = name.strip_prefix("env:") {
script_envs
.get(key)
.cloned()
.or_else(|| std::env::var(key).ok())
} else {
None
}
})
}
pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
expand_with_lookup(input, |name| {
if let Some(key) = name.strip_prefix("env:") {
ctx.envs().get(key).cloned()
} else {
None
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn basic_expansion() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
assert_eq!(result, "Hello World");
}
#[test]
fn multiple_vars() {
let mut env = HashMap::new();
env.insert("A".into(), "X".into());
env.insert("B".into(), "Y".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander
.expand_string("{{ env:A }} and {{ env:B }}")
.unwrap();
assert_eq!(result, "X and Y");
}
#[test]
fn missing_var() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ env:MISSING }}");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("undefined environment variable"),
"error should mention undefined environment variable"
);
}
#[test]
fn no_placeholders() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("plain text").unwrap();
assert_eq!(result, "plain text");
}
#[test]
fn empty_input() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("").unwrap();
assert_eq!(result, "");
}
#[test]
fn override_precedence() {
let mut env = HashMap::new();
env.insert("KEY".into(), "envval".into());
let overrides = vec![("KEY".into(), "override".into())];
let expander = StreamingExpand::new(&overrides, &env);
let result = expander.expand_string("{{ env:KEY }}").unwrap();
assert_eq!(result, "override");
}
#[test]
fn override_with_namespace() {
let mut env = HashMap::new();
env.insert("CRATE".into(), "envval".into());
let overrides = vec![("CRATE".into(), "override".into())];
let expander = StreamingExpand::new(&overrides, &env);
let result = expander.expand_string("{{ env:CRATE }}").unwrap();
assert_eq!(result, "override");
}
#[test]
fn override_raw_key_match() {
let mut env = HashMap::new();
env.insert("CRATE".into(), "envval".into());
let overrides = vec![("env:CRATE".into(), "override".into())];
let expander = StreamingExpand::new(&overrides, &env);
let result = expander.expand_string("{{ env:CRATE }}").unwrap();
assert_eq!(result, "override");
}
#[test]
fn unclosed_placeholder() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ env:KEY").unwrap();
assert_eq!(result, "{{ env:KEY");
}
#[test]
fn unclosed_with_prefix() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("Hello {{ env:KEY").unwrap();
assert_eq!(result, "Hello {{ env:KEY");
}
#[test]
fn buffer_limit_exceeded() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let mut input = b"{{ ".to_vec();
input.extend(std::iter::repeat_n(b'x', 2000));
let result = expander
.expand_string(&String::from_utf8_lossy(&input))
.unwrap();
assert!(result.starts_with("{{ "));
assert!(result.len() > 1024);
}
#[test]
fn escaped_opener_emits_literal() {
let mut env = HashMap::new();
env.insert("PROJECT".into(), "OxDock".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander
.expand_string("Built with \\{{ env:PROJECT }}")
.unwrap();
assert_eq!(result, "Built with {{ env:PROJECT }}");
}
#[test]
fn escaped_opener_across_chunks() {
let mut env = HashMap::new();
env.insert("PROJECT".into(), "OxDock".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"Built with \\", &mut out).unwrap();
expander
.process_bytes(b"{{ env:PROJECT }}", &mut out)
.unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(
String::from_utf8_lossy(&out),
"Built with {{ env:PROJECT }}"
);
}
#[test]
fn double_backslash_then_placeholder_expands() {
let mut env = HashMap::new();
env.insert("PROJECT".into(), "OxDock".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("\\\\{{ env:PROJECT }}").unwrap();
assert_eq!(result, "\\OxDock");
}
#[test]
fn trailing_backslash_flushes_literal() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("end\\").unwrap();
assert_eq!(result, "end\\");
}
#[test]
fn backslash_before_other_text_is_literal() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("a\\b \\{ once }").unwrap();
assert_eq!(result, "a\\b \\{ once }");
}
#[test]
fn escaped_pair_split_across_chunks() {
let mut env = HashMap::new();
env.insert("PROJECT".into(), "OxDock".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"\\", &mut out).unwrap();
expander
.process_bytes(b"\\{{ env:PROJECT }}", &mut out)
.unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "\\OxDock");
}
#[test]
fn partial_across_chunks() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
expander.process_bytes(b"ME }}", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "World");
}
#[test]
fn trailing_brace_across_chunks() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"...", &mut out).unwrap();
expander.process_bytes(b"{", &mut out).unwrap();
expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "...World");
}
#[test]
fn trailing_brace_at_eof() {
let env = HashMap::new();
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"hello{", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "hello{");
}
#[test]
fn immediate_flush_guarantee() {
let env = HashMap::new();
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
expander.process_bytes(&input, &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(out.len(), 1024 * 1024);
}
#[test]
fn nested_braces() {
let mut env = HashMap::new();
env.insert("KEY{1}".into(), "val".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
assert_eq!(result, "val");
}
#[test]
fn split_close_delimiter_across_chunks() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
expander.process_bytes(b"}}", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "World");
}
#[test]
fn split_close_delimiter_with_trailing_content() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
expander.process_bytes(b"}} rest", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "World rest");
}
#[test]
fn empty_input_preserves_pending_state() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
expander.process_bytes(b"", &mut out).unwrap(); expander.process_bytes(b"}}", &mut out).unwrap();
expander.flush(&mut out).unwrap();
assert_eq!(String::from_utf8_lossy(&out), "World");
}
#[test]
fn missing_env_var_errors() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined environment variable"), "got: {msg}");
assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
}
#[test]
fn missing_script_var_errors() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ $undefined_var }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined script variable"), "got: {msg}");
assert!(msg.contains("$undefined_var"), "got: {msg}");
}
#[test]
fn missing_key_in_map_errors() {
let mut vars = HashMap::new();
vars.insert(
"cfg".into(),
oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
"server".into(),
oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
"port".into(),
oxdock_parser::Value::Int(8080),
)])),
)])),
);
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ $cfg.missing_key }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("property 'missing_key' not found"),
"got: {msg}"
);
}
#[test]
fn out_of_bounds_array_index_errors() {
let mut vars = HashMap::new();
vars.insert(
"arr".into(),
oxdock_parser::Value::List(vec![oxdock_parser::Value::String("a".into())]),
);
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ $arr.5 }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
}
#[test]
fn type_mismatch_navigation_errors() {
let mut vars = HashMap::new();
vars.insert("name".into(), oxdock_parser::Value::String("alice".into()));
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ $name.sub_field }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot access property 'sub_field' on primitive"),
"got: {msg}"
);
}
#[test]
fn unprefixed_identifier_errors() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ bare_word }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("missing required step override"), "got: {msg}");
}
#[test]
fn script_var_does_not_fall_back_to_env() {
let mut env = HashMap::new();
env.insert("WHO".into(), "from-env".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ $WHO }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined script variable"), "got: {msg}");
assert!(
msg.contains("did you mean 'env:WHO'"),
"hint should suggest env: prefix, got: {msg}"
);
}
#[test]
fn env_var_does_not_fall_back_to_vars() {
let mut vars = HashMap::new();
vars.insert(
"HOST".into(),
oxdock_parser::Value::String("from-var".into()),
);
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ env:HOST }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined environment variable"), "got: {msg}");
assert!(
msg.contains("did you mean '$HOST'"),
"hint should suggest $ prefix, got: {msg}"
);
}
#[test]
fn step_override_does_not_fall_back_to_vars() {
let mut vars = HashMap::new();
vars.insert("PORT".into(), oxdock_parser::Value::Int(8080));
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ PORT }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("missing required step override"), "got: {msg}");
assert!(
msg.contains("did you mean '$PORT'"),
"hint should suggest $ prefix, got: {msg}"
);
}
#[test]
fn env_prefix_isolated_from_script_vars() {
let mut vars = HashMap::new();
vars.insert("MODE".into(), oxdock_parser::Value::String("dev".into()));
let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
let result = expander.expand_string("{{ env:MODE }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined environment variable"), "got: {msg}");
assert!(
msg.contains("did you mean '$MODE'"),
"hint should suggest $ prefix, got: {msg}"
);
}
#[test]
fn dollar_prefix_isolated_from_env() {
let mut env = HashMap::new();
env.insert("PORT".into(), "3000".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("{{ $PORT }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("undefined script variable"), "got: {msg}");
assert!(
msg.contains("did you mean 'env:PORT'"),
"hint should suggest env: prefix, got: {msg}"
);
}
#[test]
fn empty_placeholder_errors() {
let expander = StreamingExpand::new(&[], &HashMap::new());
let result = expander.expand_string("{{ }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("invalid placeholder format"), "got: {msg}");
}
#[test]
fn malformed_symbol_placeholder_errors() {
let expander = StreamingExpand::new(&[], &HashMap::new());
let result = expander.expand_string("{{ @invalid! }}");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("invalid placeholder format"), "got: {msg}");
}
fn render_split(
input: &[u8],
size: usize,
env: &HashMap<String, String>,
) -> Result<String, anyhow::Error> {
let mut expander = StreamingExpand::new(&[], env);
let mut out = Vec::new();
for chunk in input.chunks(size) {
expander.process_bytes(chunk, &mut out)?;
}
expander.flush(&mut out)?;
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[test]
fn escaped_opener_one_byte_chunks_stays_literal() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let out = render_split(b"\\{{ env:NAME }}", 1, &env).unwrap();
assert_eq!(out, "{{ env:NAME }}");
}
#[test]
fn escaped_opener_two_byte_chunks_stays_literal() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let out = render_split(b"a\\{{ env:NAME }}b", 2, &env).unwrap();
assert_eq!(out, "a{{ env:NAME }}b");
}
#[test]
fn escaped_opener_split_variants_stay_literal() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
for chunks in [
vec![b"\\".as_slice(), b"{", b"{ env:NAME }}"],
vec![b"\\{".as_slice(), b"{ env:NAME }}"],
vec![b"\\{{ env:NAME ".as_slice(), b"}}"],
] {
let mut expander = StreamingExpand::new(&[], &env);
let mut out = Vec::new();
for chunk in chunks {
expander.process_bytes(chunk, &mut out).unwrap();
}
expander.flush(&mut out).unwrap();
assert_eq!(
String::from_utf8_lossy(&out),
"{{ env:NAME }}",
"chunks must not corrupt the escape"
);
}
}
#[test]
fn plain_opener_split_chunks_still_expands() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let out = render_split(b"{{ env:NAME }}", 1, &env).unwrap();
assert_eq!(out, "World");
}
#[test]
fn double_backslash_split_chunks_still_expands() {
let mut env = HashMap::new();
env.insert("NAME".into(), "World".into());
let out = render_split(b"\\\\{{ env:NAME }}", 1, &env).unwrap();
assert_eq!(out, "\\World");
}
#[test]
fn trailing_escape_brace_flushes_literal() {
let env = HashMap::new();
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("end\\{").unwrap();
assert_eq!(result, "end\\{");
}
#[test]
fn substituted_override_value_is_not_rescanned() {
let mut env = HashMap::new();
env.insert("OTHER".into(), "world".into());
let overrides = vec![("NAME".into(), "{{ env:OTHER }}".into())];
let expander = StreamingExpand::new(&overrides, &env);
let result = expander.expand_string("Hello {{ NAME }}").unwrap();
assert_eq!(result, "Hello {{ env:OTHER }}");
}
#[test]
fn substituted_env_value_is_not_rescanned() {
let mut env = HashMap::new();
env.insert("NAME".into(), "{{ env:OTHER }}".into());
env.insert("OTHER".into(), "world".into());
let expander = StreamingExpand::new(&[], &env);
let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
assert_eq!(result, "Hello {{ env:OTHER }}");
}
#[test]
fn substituted_script_var_value_is_not_rescanned() {
let mut vars = HashMap::new();
vars.insert(
"inner".into(),
oxdock_parser::Value::String("{{ env:OTHER }}".into()),
);
let mut env = HashMap::new();
env.insert("OTHER".into(), "world".into());
let expander = StreamingExpand::new(&[], &env).with_vars(&vars);
let result = expander.expand_string("Hello {{ $inner }}").unwrap();
assert_eq!(result, "Hello {{ env:OTHER }}");
}
}