Skip to main content

oxdock_process/
expand.rs

1use std::collections::HashMap;
2
3use anyhow::{Result, anyhow, bail};
4
5use crate::contract::CommandContext;
6
7/// Maximum bytes to buffer while scanning for closing delimiter.
8/// If exceeded without finding closing delimiter, buffered bytes are flushed as literals.
9const MAX_PLACEHOLDER_SCAN: usize = 1024;
10
11/// Configurable delimiter syntax for template expansion.
12pub struct TemplateDelimiters {
13    pub open: &'static [u8],
14    pub close: &'static [u8],
15}
16
17impl Default for TemplateDelimiters {
18    fn default() -> Self {
19        Self {
20            open: b"{{",
21            close: b"}}",
22        }
23    }
24}
25
26/// Streaming template expansion state machine.
27///
28/// Processes input bytes incrementally, expanding `{{ env:KEY }}` placeholders.
29/// At most `MAX_PLACEHOLDER_SCAN` bytes are held in buffer. Plain text streams
30/// flush immediately with zero buffering.
31pub struct StreamingExpand {
32    /// Bytes accumulated as key payload inside `{{ ... }}` (no open delimiter prefix).
33    buffer: Vec<u8>,
34    /// Explicit key=value overrides (take precedence over env).
35    overrides: HashMap<String, String>,
36    /// Environment variable lookup.
37    env: HashMap<String, String>,
38    /// Structured variable lookup (for key-path evaluation).
39    vars: HashMap<String, oxdock_parser::Value>,
40    /// State: are we currently inside a placeholder?
41    in_placeholder: bool,
42    /// Trailing opening byte from previous chunk — deferred across chunks.
43    pending_brace: bool,
44    /// Trailing closing byte from previous chunk — deferred across chunks.
45    pending_close_brace: bool,
46    /// Configurable delimiter syntax.
47    delimiters: TemplateDelimiters,
48}
49
50impl StreamingExpand {
51    /// Create with env vars and optional explicit overrides.
52    /// Overrides take precedence over env vars.
53    pub fn new(overrides: &[(String, String)], env: &HashMap<String, String>) -> Self {
54        Self {
55            buffer: Vec::with_capacity(256),
56            overrides: overrides.iter().cloned().collect(),
57            env: env.clone(),
58            vars: HashMap::new(),
59            in_placeholder: false,
60            pending_brace: false,
61            pending_close_brace: false,
62            delimiters: TemplateDelimiters::default(),
63        }
64    }
65
66    /// Create with env vars, structured variables, and optional explicit overrides.
67    /// Enables key-path evaluation in template tags (e.g., `{{ pkg.package.name }}`).
68    pub fn with_vars(mut self, vars: &HashMap<String, oxdock_parser::Value>) -> Self {
69        self.vars = vars.clone();
70        self
71    }
72
73    /// Process a chunk of input bytes, writing expanded output to `out`.
74    /// Returns early on empty input to preserve pending boundary state.
75    pub fn process_bytes(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<usize> {
76        if input.is_empty() {
77            return Ok(0);
78        }
79
80        let start_len = out.len();
81        let mut i = 0;
82
83        // Handle pending close brace from previous chunk
84        if self.pending_close_brace {
85            self.pending_close_brace = false;
86            if input[0] == self.delimiters.close[1] {
87                // Confirmed close delimiter across boundary — extract key, lookup, emit
88                let key = extract_key(&self.buffer);
89                let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
90                out.extend_from_slice(value.as_bytes());
91                self.buffer.clear();
92                self.in_placeholder = false;
93                i = self.delimiters.close.len() - 1; // Skip input[0] (the second byte)
94            } else {
95                // Lone closing byte — treat as literal part of key
96                // Push it to buffer, then let scan_placeholder process input[0]
97                self.buffer.push(self.delimiters.close[0]);
98                i = 0; // Do NOT skip input[0] — let scan_placeholder handle it
99            }
100        }
101
102        // Handle pending open brace from previous chunk
103        if self.pending_brace {
104            self.pending_brace = false;
105            if input[0] == self.delimiters.open[1] {
106                // Confirmed `{{` across boundary — enter PlaceholderScan
107                self.in_placeholder = true;
108                self.buffer.clear();
109                i = 1; // Skip the second open byte
110            } else {
111                // Single open byte was just a literal — flush it
112                out.push(self.delimiters.open[0]);
113            }
114        }
115
116        if self.in_placeholder {
117            // We're inside a placeholder — scan for closing delimiter
118            i = self.scan_placeholder(input, i, out)?;
119        }
120
121        // Normal state — scan for opening byte or flush literals
122        while i < input.len() {
123            if input[i] == self.delimiters.open[0] {
124                if i + 1 < input.len() && input[i + 1] == self.delimiters.open[1] {
125                    // Found open delimiter — enter PlaceholderScan
126                    self.in_placeholder = true;
127                    self.buffer.clear();
128                    i += 2;
129                    i = self.scan_placeholder(input, i, out)?;
130                } else if i + 1 == input.len() {
131                    // Opening byte is the LAST byte of chunk — defer
132                    self.pending_brace = true;
133                    i += 1;
134                } else {
135                    // Single opening byte in the middle — flush as literal
136                    out.push(self.delimiters.open[0]);
137                    i += 1;
138                }
139            } else {
140                // Flush literal bytes until we find opening byte or end of chunk
141                let start = i;
142                while i < input.len() && input[i] != self.delimiters.open[0] {
143                    i += 1;
144                }
145                out.extend_from_slice(&input[start..i]);
146            }
147        }
148
149        Ok(out.len() - start_len)
150    }
151
152    /// Flush remaining buffer. Incomplete placeholders are treated as literals.
153    pub fn flush(mut self, out: &mut Vec<u8>) -> Result<()> {
154        // Emit deferred closing byte if present
155        if self.pending_close_brace {
156            self.buffer.push(self.delimiters.close[0]);
157            self.pending_close_brace = false;
158        }
159        // Emit deferred opening byte if present
160        if self.pending_brace {
161            out.push(self.delimiters.open[0]);
162            self.pending_brace = false;
163        }
164        // If inside placeholder, emit open delimiter prefix ONCE before buffer
165        if self.in_placeholder {
166            out.extend_from_slice(self.delimiters.open);
167            self.in_placeholder = false;
168        }
169        // Flush remaining buffer as literal text
170        out.extend_from_slice(&self.buffer);
171        self.buffer.clear();
172        Ok(())
173    }
174
175    /// Process a complete string (convenience for short command arguments).
176    pub fn expand_string(self, input: &str) -> Result<String> {
177        let mut out = Vec::with_capacity(input.len());
178        let mut expander = self;
179        expander.process_bytes(input.as_bytes(), &mut out)?;
180        expander.flush(&mut out)?;
181        Ok(String::from_utf8(out).unwrap_or_default())
182    }
183
184    /// Scan for closing delimiter starting at position `i`.
185    /// Returns the next position to process after the placeholder.
186    fn scan_placeholder(&mut self, input: &[u8], mut i: usize, out: &mut Vec<u8>) -> Result<usize> {
187        while i < input.len() {
188            if input[i] == self.delimiters.close[0] {
189                if i + 1 < input.len() && input[i + 1] == self.delimiters.close[1] {
190                    // Found closing delimiter — extract key, lookup, emit expansion
191                    let key = extract_key(&self.buffer);
192                    let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
193                    out.extend_from_slice(value.as_bytes());
194                    self.buffer.clear();
195                    self.in_placeholder = false;
196                    return Ok(i + 2);
197                }
198                if i + 1 == input.len() {
199                    // Closing byte is the LAST byte — defer to next chunk
200                    self.pending_close_brace = true;
201                    return Ok(i + 1);
202                }
203            }
204            self.buffer.push(input[i]);
205            i += 1;
206
207            // Buffer limit exceeded — flush as literal
208            if self.buffer.len() > MAX_PLACEHOLDER_SCAN {
209                out.extend_from_slice(self.delimiters.open);
210                out.extend_from_slice(&self.buffer);
211                self.buffer.clear();
212                self.in_placeholder = false;
213                return Ok(i);
214            }
215        }
216        Ok(i)
217    }
218}
219
220/// Extract and trim key from buffer content (the bytes between delimiters).
221fn extract_key(buffer: &[u8]) -> String {
222    String::from_utf8_lossy(buffer).trim().to_string()
223}
224
225/// Lookup a key in overrides and env, stripping namespace prefixes.
226///
227/// Strict resolution contract:
228/// - `{{ KEY }}` (bare) → overrides only
229/// - `{{ env:KEY }}` → overrides then env
230/// - `{{ $var }}` → script vars
231/// - `{{ $var.field }}` → script var key-path
232///
233/// All missing or invalid references return an error.
234fn lookup(
235    raw_key: &str,
236    overrides: &HashMap<String, String>,
237    env: &HashMap<String, String>,
238    vars: &HashMap<String, oxdock_parser::Value>,
239) -> Result<String> {
240    let key = raw_key.trim();
241
242    // 1. Explicit overrides (command-level CLI flags: KEY=val)
243    if let Some(val) = overrides.get(key) {
244        return Ok(val.clone());
245    }
246
247    // 2. Environment variables: must be prefixed with "env:"
248    if let Some(env_key) = key.strip_prefix("env:") {
249        if let Some(val) = overrides.get(env_key).or_else(|| env.get(env_key)) {
250            return Ok(val.clone());
251        }
252        let hint = if vars.contains_key(env_key) {
253            format!("; did you mean '${env_key}' (script variable)?")
254        } else {
255            String::new()
256        };
257        bail!("undefined environment variable: '{env_key}'{hint}");
258    }
259
260    // 3. Script variables: must be prefixed with "$"
261    if let Some(var_key) = key.strip_prefix('$') {
262        if var_key.contains('.') {
263            let parts: Vec<&str> = var_key.split('.').collect();
264            return resolve_key_path_strict(&parts, vars);
265        }
266        if let Some(val) = vars.get(var_key) {
267            return Ok(format_value_for_string(val));
268        }
269        let hint = if env.contains_key(var_key) {
270            format!("; did you mean 'env:{var_key}' (environment variable)?")
271        } else if overrides.contains_key(var_key) {
272            format!("; did you mean '{var_key}' (step override)?")
273        } else {
274            String::new()
275        };
276        bail!("undefined script variable: '${var_key}'{hint}");
277    }
278
279    // 4. Unprefixed key: could be a missing step override or invalid syntax
280    if !key.is_empty()
281        && key
282            .chars()
283            .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
284    {
285        let hint = if vars.contains_key(key) {
286            format!("; did you mean '${key}' (script variable)?")
287        } else if env.contains_key(key) {
288            format!("; did you mean 'env:{key}' (environment variable)?")
289        } else {
290            String::new()
291        };
292        bail!("missing required step override argument: '{key}'{hint}");
293    }
294    bail!(
295        "invalid placeholder format '{key}': script variables must start with '$' and environment variables with 'env:'"
296    );
297}
298
299/// Resolve nested key-paths against the vars map.
300///
301/// Fails explicitly on missing object keys, out-of-bounds array indices,
302/// or type mismatches (e.g. trying to access a property on a primitive).
303fn resolve_key_path_strict(
304    parts: &[&str],
305    vars: &HashMap<String, oxdock_parser::Value>,
306) -> Result<String> {
307    let root_key = parts[0];
308    let mut current = vars
309        .get(root_key)
310        .ok_or_else(|| anyhow!("undefined script variable: '${root_key}'"))?;
311
312    for &segment in &parts[1..] {
313        match current {
314            oxdock_parser::Value::Map(map) => {
315                current = map.get(segment).ok_or_else(|| {
316                    anyhow!("property '{segment}' not found on object '${root_key}'")
317                })?;
318            }
319            oxdock_parser::Value::List(list) => {
320                let idx: usize = segment.parse().map_err(|_| {
321                    anyhow!("invalid array index '{segment}' on list '${root_key}'")
322                })?;
323                current = list.get(idx).ok_or_else(|| {
324                    anyhow!(
325                        "index {idx} out of bounds for list '${root_key}' (len: {})",
326                        list.len()
327                    )
328                })?;
329            }
330            _ => bail!("cannot access property '{segment}' on primitive value of '${root_key}'"),
331        }
332    }
333
334    Ok(format_value_for_string(current))
335}
336
337/// Format a Value as a string for inline interpolation.
338fn format_value_for_string(val: &oxdock_parser::Value) -> String {
339    match val {
340        oxdock_parser::Value::String(s) => s.clone(),
341        oxdock_parser::Value::Int(i) => i.to_string(),
342        oxdock_parser::Value::Bool(b) => b.to_string(),
343        oxdock_parser::Value::List(items) => items
344            .iter()
345            .map(format_value_for_string)
346            .collect::<Vec<_>>()
347            .join(" "),
348        oxdock_parser::Value::Map(map) => map
349            .iter()
350            .map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
351            .collect::<Vec<_>>()
352            .join(", "),
353        oxdock_parser::Value::TaskHandle(id) => format!("task#{}", id),
354    }
355}
356
357// Legacy functions for backward compatibility
358
359pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
360where
361    F: FnMut(&str) -> Option<String>,
362{
363    let mut out = String::with_capacity(input.len());
364    let mut chars = input.chars().peekable();
365    while let Some(c) = chars.next() {
366        if c == '{' {
367            if let Some(&'{') = chars.peek() {
368                chars.next(); // consume second '{'
369                let mut content = String::new();
370                let mut closed = false;
371                // Look ahead for closing }}
372                let mut inner_chars = chars.clone();
373                while let Some(ch) = inner_chars.next() {
374                    if ch == '}'
375                        && let Some(&'}') = inner_chars.peek()
376                    {
377                        closed = true;
378                        break;
379                    }
380                    content.push(ch);
381                }
382
383                if closed {
384                    // Advance main iterator past content and closing braces.
385                    // Count chars, not bytes: content may contain multi-byte
386                    // UTF-8 (e.g. non-ASCII placeholder names).
387                    for _ in 0..content.chars().count() {
388                        chars.next();
389                    }
390                    chars.next(); // first }
391                    chars.next(); // second }
392
393                    let key = content.trim();
394                    if !key.is_empty() {
395                        out.push_str(&lookup_fn(key).unwrap_or_default());
396                    }
397                } else {
398                    out.push('{');
399                    out.push('{');
400                }
401            } else {
402                out.push('{');
403            }
404        } else {
405            out.push(c);
406        }
407    }
408    out
409}
410
411pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
412    expand_with_lookup(input, |name| {
413        if let Some(key) = name.strip_prefix("env:") {
414            script_envs
415                .get(key)
416                .cloned()
417                .or_else(|| std::env::var(key).ok())
418        } else {
419            None
420        }
421    })
422}
423
424pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
425    expand_with_lookup(input, |name| {
426        if let Some(key) = name.strip_prefix("env:") {
427            ctx.envs().get(key).cloned()
428        } else {
429            None
430        }
431    })
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::collections::HashMap;
438
439    #[test]
440    fn basic_expansion() {
441        let mut env = HashMap::new();
442        env.insert("NAME".into(), "World".into());
443        let expander = StreamingExpand::new(&[], &env);
444        let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
445        assert_eq!(result, "Hello World");
446    }
447
448    #[test]
449    fn multiple_vars() {
450        let mut env = HashMap::new();
451        env.insert("A".into(), "X".into());
452        env.insert("B".into(), "Y".into());
453        let expander = StreamingExpand::new(&[], &env);
454        let result = expander
455            .expand_string("{{ env:A }} and {{ env:B }}")
456            .unwrap();
457        assert_eq!(result, "X and Y");
458    }
459
460    #[test]
461    fn missing_var() {
462        let env = HashMap::new();
463        let expander = StreamingExpand::new(&[], &env);
464        let result = expander.expand_string("{{ env:MISSING }}");
465        assert!(result.is_err());
466        assert!(
467            result
468                .unwrap_err()
469                .to_string()
470                .contains("undefined environment variable"),
471            "error should mention undefined environment variable"
472        );
473    }
474
475    #[test]
476    fn no_placeholders() {
477        let env = HashMap::new();
478        let expander = StreamingExpand::new(&[], &env);
479        let result = expander.expand_string("plain text").unwrap();
480        assert_eq!(result, "plain text");
481    }
482
483    #[test]
484    fn empty_input() {
485        let env = HashMap::new();
486        let expander = StreamingExpand::new(&[], &env);
487        let result = expander.expand_string("").unwrap();
488        assert_eq!(result, "");
489    }
490
491    #[test]
492    fn override_precedence() {
493        let mut env = HashMap::new();
494        env.insert("KEY".into(), "envval".into());
495        let overrides = vec![("KEY".into(), "override".into())];
496        let expander = StreamingExpand::new(&overrides, &env);
497        let result = expander.expand_string("{{ env:KEY }}").unwrap();
498        assert_eq!(result, "override");
499    }
500
501    #[test]
502    fn override_with_namespace() {
503        let mut env = HashMap::new();
504        env.insert("CRATE".into(), "envval".into());
505        let overrides = vec![("CRATE".into(), "override".into())];
506        let expander = StreamingExpand::new(&overrides, &env);
507        let result = expander.expand_string("{{ env:CRATE }}").unwrap();
508        assert_eq!(result, "override");
509    }
510
511    #[test]
512    fn override_raw_key_match() {
513        let mut env = HashMap::new();
514        env.insert("CRATE".into(), "envval".into());
515        let overrides = vec![("env:CRATE".into(), "override".into())];
516        let expander = StreamingExpand::new(&overrides, &env);
517        let result = expander.expand_string("{{ env:CRATE }}").unwrap();
518        assert_eq!(result, "override");
519    }
520
521    #[test]
522    fn unclosed_placeholder() {
523        let env = HashMap::new();
524        let expander = StreamingExpand::new(&[], &env);
525        let result = expander.expand_string("{{ env:KEY").unwrap();
526        assert_eq!(result, "{{ env:KEY");
527    }
528
529    #[test]
530    fn unclosed_with_prefix() {
531        let env = HashMap::new();
532        let expander = StreamingExpand::new(&[], &env);
533        let result = expander.expand_string("Hello {{ env:KEY").unwrap();
534        assert_eq!(result, "Hello {{ env:KEY");
535    }
536
537    #[test]
538    fn buffer_limit_exceeded() {
539        let env = HashMap::new();
540        let expander = StreamingExpand::new(&[], &env);
541        // Create input with `{{` followed by >1024 bytes without `}}`
542        let mut input = b"{{ ".to_vec();
543        input.extend(std::iter::repeat_n(b'x', 2000));
544        let result = expander
545            .expand_string(&String::from_utf8_lossy(&input))
546            .unwrap();
547        // Should flush as literal with `{{` prefix
548        assert!(result.starts_with("{{ "));
549        assert!(result.len() > 1024);
550    }
551
552    #[test]
553    fn partial_across_chunks() {
554        let mut env = HashMap::new();
555        env.insert("NAME".into(), "World".into());
556        let mut expander = StreamingExpand::new(&[], &env);
557        let mut out = Vec::new();
558
559        // Split `{{ env:NA` / `ME }}` across chunks
560        expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
561        expander.process_bytes(b"ME }}", &mut out).unwrap();
562        expander.flush(&mut out).unwrap();
563
564        assert_eq!(String::from_utf8_lossy(&out), "World");
565    }
566
567    #[test]
568    fn trailing_brace_across_chunks() {
569        let mut env = HashMap::new();
570        env.insert("NAME".into(), "World".into());
571        let mut expander = StreamingExpand::new(&[], &env);
572        let mut out = Vec::new();
573
574        // Split `...{` / `{env:NAME}}` across chunks
575        expander.process_bytes(b"...", &mut out).unwrap();
576        expander.process_bytes(b"{", &mut out).unwrap();
577        expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
578        expander.flush(&mut out).unwrap();
579
580        assert_eq!(String::from_utf8_lossy(&out), "...World");
581    }
582
583    #[test]
584    fn trailing_brace_at_eof() {
585        let env = HashMap::new();
586        let mut expander = StreamingExpand::new(&[], &env);
587        let mut out = Vec::new();
588
589        expander.process_bytes(b"hello{", &mut out).unwrap();
590        expander.flush(&mut out).unwrap();
591
592        assert_eq!(String::from_utf8_lossy(&out), "hello{");
593    }
594
595    #[test]
596    fn immediate_flush_guarantee() {
597        let env = HashMap::new();
598        let mut expander = StreamingExpand::new(&[], &env);
599        let mut out = Vec::new();
600
601        // 1MB of plain text with no placeholders
602        let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
603        expander.process_bytes(&input, &mut out).unwrap();
604        expander.flush(&mut out).unwrap();
605
606        assert_eq!(out.len(), 1024 * 1024);
607    }
608
609    #[test]
610    fn nested_braces() {
611        let mut env = HashMap::new();
612        env.insert("KEY{1}".into(), "val".into());
613        let expander = StreamingExpand::new(&[], &env);
614        let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
615        assert_eq!(result, "val");
616    }
617
618    #[test]
619    fn split_close_delimiter_across_chunks() {
620        let mut env = HashMap::new();
621        env.insert("NAME".into(), "World".into());
622        let mut expander = StreamingExpand::new(&[], &env);
623        let mut out = Vec::new();
624
625        // Split `}}` across chunks: `{{ env:NAME` / `}}`
626        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
627        expander.process_bytes(b"}}", &mut out).unwrap();
628        expander.flush(&mut out).unwrap();
629
630        assert_eq!(String::from_utf8_lossy(&out), "World");
631    }
632
633    #[test]
634    fn split_close_delimiter_with_trailing_content() {
635        let mut env = HashMap::new();
636        env.insert("NAME".into(), "World".into());
637        let mut expander = StreamingExpand::new(&[], &env);
638        let mut out = Vec::new();
639
640        // Split `}}` across chunks with content after
641        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
642        expander.process_bytes(b"}} rest", &mut out).unwrap();
643        expander.flush(&mut out).unwrap();
644
645        assert_eq!(String::from_utf8_lossy(&out), "World rest");
646    }
647
648    #[test]
649    fn empty_input_preserves_pending_state() {
650        let mut env = HashMap::new();
651        env.insert("NAME".into(), "World".into());
652        let mut expander = StreamingExpand::new(&[], &env);
653        let mut out = Vec::new();
654
655        // End chunk with closing byte, then empty input, then confirm
656        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
657        expander.process_bytes(b"", &mut out).unwrap(); // empty — should preserve state
658        expander.process_bytes(b"}}", &mut out).unwrap();
659        expander.flush(&mut out).unwrap();
660
661        assert_eq!(String::from_utf8_lossy(&out), "World");
662    }
663
664    #[test]
665    fn missing_env_var_errors() {
666        let env = HashMap::new();
667        let expander = StreamingExpand::new(&[], &env);
668        let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
669        assert!(result.is_err());
670        let msg = result.unwrap_err().to_string();
671        assert!(msg.contains("undefined environment variable"), "got: {msg}");
672        assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
673    }
674
675    #[test]
676    fn missing_script_var_errors() {
677        let env = HashMap::new();
678        let expander = StreamingExpand::new(&[], &env);
679        let result = expander.expand_string("{{ $undefined_var }}");
680        assert!(result.is_err());
681        let msg = result.unwrap_err().to_string();
682        assert!(msg.contains("undefined script variable"), "got: {msg}");
683        assert!(msg.contains("$undefined_var"), "got: {msg}");
684    }
685
686    #[test]
687    fn missing_key_in_map_errors() {
688        let mut vars = HashMap::new();
689        vars.insert(
690            "cfg".into(),
691            oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
692                "server".into(),
693                oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
694                    "port".into(),
695                    oxdock_parser::Value::Int(8080),
696                )])),
697            )])),
698        );
699        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
700        let result = expander.expand_string("{{ $cfg.missing_key }}");
701        assert!(result.is_err());
702        let msg = result.unwrap_err().to_string();
703        assert!(
704            msg.contains("property 'missing_key' not found"),
705            "got: {msg}"
706        );
707    }
708
709    #[test]
710    fn out_of_bounds_array_index_errors() {
711        let mut vars = HashMap::new();
712        vars.insert(
713            "arr".into(),
714            oxdock_parser::Value::List(vec![oxdock_parser::Value::String("a".into())]),
715        );
716        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
717        let result = expander.expand_string("{{ $arr.5 }}");
718        assert!(result.is_err());
719        let msg = result.unwrap_err().to_string();
720        assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
721    }
722
723    #[test]
724    fn type_mismatch_navigation_errors() {
725        let mut vars = HashMap::new();
726        vars.insert("name".into(), oxdock_parser::Value::String("alice".into()));
727        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
728        let result = expander.expand_string("{{ $name.sub_field }}");
729        assert!(result.is_err());
730        let msg = result.unwrap_err().to_string();
731        assert!(
732            msg.contains("cannot access property 'sub_field' on primitive"),
733            "got: {msg}"
734        );
735    }
736
737    #[test]
738    fn unprefixed_identifier_errors() {
739        let env = HashMap::new();
740        let expander = StreamingExpand::new(&[], &env);
741        let result = expander.expand_string("{{ bare_word }}");
742        assert!(result.is_err());
743        let msg = result.unwrap_err().to_string();
744        assert!(msg.contains("missing required step override"), "got: {msg}");
745    }
746
747    // ── Strict namespace isolation tests ─────────────────────────────────────
748
749    #[test]
750    fn script_var_does_not_fall_back_to_env() {
751        let mut env = HashMap::new();
752        env.insert("WHO".into(), "from-env".into());
753        let expander = StreamingExpand::new(&[], &env);
754        // $WHO queries vars, NOT env — should error even though env has WHO
755        let result = expander.expand_string("{{ $WHO }}");
756        assert!(result.is_err());
757        let msg = result.unwrap_err().to_string();
758        assert!(msg.contains("undefined script variable"), "got: {msg}");
759        assert!(
760            msg.contains("did you mean 'env:WHO'"),
761            "hint should suggest env: prefix, got: {msg}"
762        );
763    }
764
765    #[test]
766    fn env_var_does_not_fall_back_to_vars() {
767        let mut vars = HashMap::new();
768        vars.insert(
769            "HOST".into(),
770            oxdock_parser::Value::String("from-var".into()),
771        );
772        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
773        // env:HOST queries env, NOT vars — should error even though vars has HOST
774        let result = expander.expand_string("{{ env:HOST }}");
775        assert!(result.is_err());
776        let msg = result.unwrap_err().to_string();
777        assert!(msg.contains("undefined environment variable"), "got: {msg}");
778        assert!(
779            msg.contains("did you mean '$HOST'"),
780            "hint should suggest $ prefix, got: {msg}"
781        );
782    }
783
784    #[test]
785    fn step_override_does_not_fall_back_to_vars() {
786        let mut vars = HashMap::new();
787        vars.insert("PORT".into(), oxdock_parser::Value::Int(8080));
788        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
789        // PORT (bare) queries overrides, NOT vars — should error
790        let result = expander.expand_string("{{ PORT }}");
791        assert!(result.is_err());
792        let msg = result.unwrap_err().to_string();
793        assert!(msg.contains("missing required step override"), "got: {msg}");
794        assert!(
795            msg.contains("did you mean '$PORT'"),
796            "hint should suggest $ prefix, got: {msg}"
797        );
798    }
799
800    #[test]
801    fn env_prefix_isolated_from_script_vars() {
802        let mut vars = HashMap::new();
803        vars.insert("MODE".into(), oxdock_parser::Value::String("dev".into()));
804        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
805        // env:MODE looks in env, not vars — should error
806        let result = expander.expand_string("{{ env:MODE }}");
807        assert!(result.is_err());
808        let msg = result.unwrap_err().to_string();
809        assert!(msg.contains("undefined environment variable"), "got: {msg}");
810        assert!(
811            msg.contains("did you mean '$MODE'"),
812            "hint should suggest $ prefix, got: {msg}"
813        );
814    }
815
816    #[test]
817    fn dollar_prefix_isolated_from_env() {
818        let mut env = HashMap::new();
819        env.insert("PORT".into(), "3000".into());
820        let expander = StreamingExpand::new(&[], &env);
821        // $PORT looks in vars, not env — should error
822        let result = expander.expand_string("{{ $PORT }}");
823        assert!(result.is_err());
824        let msg = result.unwrap_err().to_string();
825        assert!(msg.contains("undefined script variable"), "got: {msg}");
826        assert!(
827            msg.contains("did you mean 'env:PORT'"),
828            "hint should suggest env: prefix, got: {msg}"
829        );
830    }
831
832    #[test]
833    fn empty_placeholder_errors() {
834        let expander = StreamingExpand::new(&[], &HashMap::new());
835        let result = expander.expand_string("{{ }}");
836        assert!(result.is_err());
837        let msg = result.unwrap_err().to_string();
838        assert!(msg.contains("invalid placeholder format"), "got: {msg}");
839    }
840
841    #[test]
842    fn malformed_symbol_placeholder_errors() {
843        let expander = StreamingExpand::new(&[], &HashMap::new());
844        let result = expander.expand_string("{{ @invalid! }}");
845        assert!(result.is_err());
846        let msg = result.unwrap_err().to_string();
847        assert!(msg.contains("invalid placeholder format"), "got: {msg}");
848    }
849}