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    /// Trailing backslash from previous chunk — deferred across chunks so
47    /// `\{{` split across a boundary still emits a literal opener.
48    pending_escape: bool,
49    /// Trailing `\` + `{` from previous chunk — deferred across chunks so
50    /// a `\{` split across a boundary still resolves as an escape pair.
51    pending_escape_brace: bool,
52    /// Configurable delimiter syntax.
53    delimiters: TemplateDelimiters,
54}
55
56impl StreamingExpand {
57    /// Create with env vars and optional explicit overrides.
58    /// Overrides take precedence over env vars.
59    pub fn new(overrides: &[(String, String)], env: &HashMap<String, String>) -> Self {
60        Self {
61            buffer: Vec::with_capacity(256),
62            overrides: overrides.iter().cloned().collect(),
63            env: env.clone(),
64            vars: HashMap::new(),
65            in_placeholder: false,
66            pending_brace: false,
67            pending_close_brace: false,
68            pending_escape: false,
69            pending_escape_brace: false,
70            delimiters: TemplateDelimiters::default(),
71        }
72    }
73
74    /// Create with env vars, structured variables, and optional explicit overrides.
75    /// Enables key-path evaluation in template tags (e.g., `{{ pkg.package.name }}`).
76    pub fn with_vars(mut self, vars: &HashMap<String, oxdock_parser::Value>) -> Self {
77        self.vars = vars.clone();
78        self
79    }
80
81    /// Process a chunk of input bytes, writing expanded output to `out`.
82    /// Returns early on empty input to preserve pending boundary state.
83    pub fn process_bytes(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<usize> {
84        if input.is_empty() {
85            return Ok(0);
86        }
87
88        let start_len = out.len();
89        let mut i = 0;
90
91        // Handle pending close brace from previous chunk
92        if self.pending_close_brace {
93            self.pending_close_brace = false;
94            if input[0] == self.delimiters.close[1] {
95                // Confirmed close delimiter across boundary — extract key, lookup, emit
96                let key = extract_key(&self.buffer);
97                let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
98                out.extend_from_slice(value.as_bytes());
99                self.buffer.clear();
100                self.in_placeholder = false;
101                i = self.delimiters.close.len() - 1; // Skip input[0] (the second byte)
102            } else {
103                // Lone closing byte — treat as literal part of key
104                // Push it to buffer, then let scan_placeholder process input[0]
105                self.buffer.push(self.delimiters.close[0]);
106                i = 0; // Do NOT skip input[0] — let scan_placeholder handle it
107            }
108        }
109
110        // Handle pending open brace from previous chunk
111        if self.pending_brace {
112            self.pending_brace = false;
113            if input[0] == self.delimiters.open[1] {
114                // Confirmed `{{` across boundary — enter PlaceholderScan
115                self.in_placeholder = true;
116                self.buffer.clear();
117                i = 1; // Skip the second open byte
118            } else {
119                // Single open byte was just a literal — flush it
120                out.push(self.delimiters.open[0]);
121            }
122        }
123
124        // Handle pending escape pair from previous chunk: a trailing `\{`
125        // combines with a leading `{` into a literal opener. Anything
126        // else means both bytes were literal; re-emit them, then let the
127        // scan below handle this chunk from the start.
128        if self.pending_escape_brace {
129            self.pending_escape_brace = false;
130            if !input.is_empty() && input[0] == self.delimiters.open[1] {
131                out.extend_from_slice(b"{{");
132                i = 1;
133            } else {
134                out.push(b'\\');
135                out.push(self.delimiters.open[0]);
136            }
137        }
138
139        // Handle pending escape from previous chunk: a lone trailing `\`
140        // combines with a leading `{{` into a literal opener, or with a
141        // leading `\` into a literal backslash (the pair is complete, so
142        // whatever follows processes normally). A lone trailing `{`
143        // cannot decide yet, so it joins the deferred pair above.
144        if self.pending_escape {
145            self.pending_escape = false;
146            if input.len() >= 2 && input[0] == b'{' && input[1] == b'{' {
147                out.extend_from_slice(b"{{");
148                i = 2;
149            } else if input.len() == 1 && input[0] == self.delimiters.open[0] {
150                self.pending_escape_brace = true;
151                i = 1;
152            } else if !input.is_empty() && input[0] == b'\\' {
153                out.push(b'\\');
154                i = 1;
155            } else {
156                // Not an escape — the backslash was literal; reprocess
157                // this chunk from the start (a lone `{` still defers via
158                // the pending_brace path below).
159                out.push(b'\\');
160            }
161        }
162
163        if self.in_placeholder {
164            // We're inside a placeholder — scan for closing delimiter
165            i = self.scan_placeholder(input, i, out)?;
166        }
167
168        // Normal state — scan for opening byte or flush literals
169        while i < input.len() {
170            if input[i] == b'\\' {
171                if i + 2 < input.len() && input[i + 1] == b'{' && input[i + 2] == b'{' {
172                    // Escaped opener — emit a literal `{{`, consume all three
173                    out.extend_from_slice(b"{{");
174                    i += 3;
175                } else if i + 2 == input.len() && input[i + 1] == self.delimiters.open[0] {
176                    // `\{` ends the chunk — defer the pair; the next
177                    // chunk decides literal `{{` vs literal `\` + `{`.
178                    self.pending_escape_brace = true;
179                    i += 2;
180                } else if i + 1 < input.len() && input[i + 1] == b'\\' {
181                    // Escaped backslash — emit one `\`, consume both (this
182                    // keeps `\\{{ ... }}` expanding, matching the lenient
183                    // interpolator used for command arguments)
184                    out.push(b'\\');
185                    i += 2;
186                } else if i + 1 == input.len() {
187                    // Trailing backslash — defer across the chunk boundary
188                    self.pending_escape = true;
189                    i += 1;
190                } else {
191                    // Ordinary backslash — literal, reprocess what follows
192                    out.push(b'\\');
193                    i += 1;
194                }
195            } else if input[i] == self.delimiters.open[0] {
196                if i + 1 < input.len() && input[i + 1] == self.delimiters.open[1] {
197                    // Found open delimiter — enter PlaceholderScan
198                    self.in_placeholder = true;
199                    self.buffer.clear();
200                    i += 2;
201                    i = self.scan_placeholder(input, i, out)?;
202                } else if i + 1 == input.len() {
203                    // Opening byte is the LAST byte of chunk — defer
204                    self.pending_brace = true;
205                    i += 1;
206                } else {
207                    // Single opening byte in the middle — flush as literal
208                    out.push(self.delimiters.open[0]);
209                    i += 1;
210                }
211            } else {
212                // Flush literal bytes until we find an opening byte, a
213                // backslash (potential `\{{` escape), or end of chunk
214                let start = i;
215                while i < input.len() && input[i] != self.delimiters.open[0] && input[i] != b'\\' {
216                    i += 1;
217                }
218                out.extend_from_slice(&input[start..i]);
219            }
220        }
221
222        Ok(out.len() - start_len)
223    }
224
225    /// Flush remaining buffer. Incomplete placeholders are treated as literals.
226    pub fn flush(mut self, out: &mut Vec<u8>) -> Result<()> {
227        // Emit deferred closing byte if present
228        if self.pending_close_brace {
229            self.buffer.push(self.delimiters.close[0]);
230            self.pending_close_brace = false;
231        }
232        // Emit deferred opening byte if present
233        if self.pending_brace {
234            out.push(self.delimiters.open[0]);
235            self.pending_brace = false;
236        }
237        // Emit deferred backslash if present
238        if self.pending_escape {
239            out.push(b'\\');
240            self.pending_escape = false;
241        }
242        // Emit deferred escape pair if present
243        if self.pending_escape_brace {
244            out.push(b'\\');
245            out.push(self.delimiters.open[0]);
246            self.pending_escape_brace = false;
247        }
248        // If inside placeholder, emit open delimiter prefix ONCE before buffer
249        if self.in_placeholder {
250            out.extend_from_slice(self.delimiters.open);
251            self.in_placeholder = false;
252        }
253        // Flush remaining buffer as literal text
254        out.extend_from_slice(&self.buffer);
255        self.buffer.clear();
256        Ok(())
257    }
258
259    /// Process a complete string (convenience for short command arguments).
260    pub fn expand_string(self, input: &str) -> Result<String> {
261        let mut out = Vec::with_capacity(input.len());
262        let mut expander = self;
263        expander.process_bytes(input.as_bytes(), &mut out)?;
264        expander.flush(&mut out)?;
265        Ok(String::from_utf8(out).unwrap_or_default())
266    }
267
268    /// Scan for closing delimiter starting at position `i`.
269    /// Returns the next position to process after the placeholder.
270    fn scan_placeholder(&mut self, input: &[u8], mut i: usize, out: &mut Vec<u8>) -> Result<usize> {
271        while i < input.len() {
272            if input[i] == self.delimiters.close[0] {
273                if i + 1 < input.len() && input[i + 1] == self.delimiters.close[1] {
274                    // Found closing delimiter — extract key, lookup, emit expansion
275                    let key = extract_key(&self.buffer);
276                    let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
277                    out.extend_from_slice(value.as_bytes());
278                    self.buffer.clear();
279                    self.in_placeholder = false;
280                    return Ok(i + 2);
281                }
282                if i + 1 == input.len() {
283                    // Closing byte is the LAST byte — defer to next chunk
284                    self.pending_close_brace = true;
285                    return Ok(i + 1);
286                }
287            }
288            self.buffer.push(input[i]);
289            i += 1;
290
291            // Buffer limit exceeded — flush as literal
292            if self.buffer.len() > MAX_PLACEHOLDER_SCAN {
293                out.extend_from_slice(self.delimiters.open);
294                out.extend_from_slice(&self.buffer);
295                self.buffer.clear();
296                self.in_placeholder = false;
297                return Ok(i);
298            }
299        }
300        Ok(i)
301    }
302}
303
304/// Extract and trim key from buffer content (the bytes between delimiters).
305fn extract_key(buffer: &[u8]) -> String {
306    String::from_utf8_lossy(buffer).trim().to_string()
307}
308
309/// Lookup a key in overrides and env, stripping namespace prefixes.
310///
311/// Strict resolution contract:
312/// - `{{ KEY }}` (bare) → overrides only
313/// - `{{ env:KEY }}` → overrides then env
314/// - `{{ $var }}` → script vars
315/// - `{{ $var.field }}` → script var key-path
316///
317/// All missing or invalid references return an error.
318fn lookup(
319    raw_key: &str,
320    overrides: &HashMap<String, String>,
321    env: &HashMap<String, String>,
322    vars: &HashMap<String, oxdock_parser::Value>,
323) -> Result<String> {
324    let key = raw_key.trim();
325
326    // 1. Explicit overrides (command-level CLI flags: KEY=val)
327    if let Some(val) = overrides.get(key) {
328        return Ok(val.clone());
329    }
330
331    // 2. Environment variables: must be prefixed with "env:"
332    if let Some(env_key) = key.strip_prefix("env:") {
333        if let Some(val) = overrides.get(env_key).or_else(|| env.get(env_key)) {
334            return Ok(val.clone());
335        }
336        let hint = if vars.contains_key(env_key) {
337            format!("; did you mean '${env_key}' (script variable)?")
338        } else {
339            String::new()
340        };
341        bail!("undefined environment variable: '{env_key}'{hint}");
342    }
343
344    // 3. Script variables: must be prefixed with "$"
345    if let Some(var_key) = key.strip_prefix('$') {
346        if var_key.contains('.') {
347            let parts: Vec<&str> = var_key.split('.').collect();
348            return resolve_key_path_strict(&parts, vars);
349        }
350        if let Some(val) = vars.get(var_key) {
351            return Ok(format_value_for_string(val));
352        }
353        let hint = if env.contains_key(var_key) {
354            format!("; did you mean 'env:{var_key}' (environment variable)?")
355        } else if overrides.contains_key(var_key) {
356            format!("; did you mean '{var_key}' (step override)?")
357        } else {
358            String::new()
359        };
360        bail!("undefined script variable: '${var_key}'{hint}");
361    }
362
363    // 4. Unprefixed key: could be a missing step override or invalid syntax
364    if !key.is_empty()
365        && key
366            .chars()
367            .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
368    {
369        let hint = if vars.contains_key(key) {
370            format!("; did you mean '${key}' (script variable)?")
371        } else if env.contains_key(key) {
372            format!("; did you mean 'env:{key}' (environment variable)?")
373        } else {
374            String::new()
375        };
376        bail!("missing required step override argument: '{key}'{hint}");
377    }
378    bail!(
379        "invalid placeholder format '{key}': script variables must start with '$' and environment variables with 'env:'"
380    );
381}
382
383/// Resolve nested key-paths against the vars map.
384///
385/// Fails explicitly on missing object keys, out-of-bounds array indices,
386/// or type mismatches (e.g. trying to access a property on a primitive).
387fn resolve_key_path_strict(
388    parts: &[&str],
389    vars: &HashMap<String, oxdock_parser::Value>,
390) -> Result<String> {
391    let root_key = parts[0];
392    let mut current = vars
393        .get(root_key)
394        .ok_or_else(|| anyhow!("undefined script variable: '${root_key}'"))?;
395
396    for &segment in &parts[1..] {
397        if let Some(map) = current.as_map() {
398            current = map
399                .get(segment)
400                .ok_or_else(|| anyhow!("property '{segment}' not found on object '${root_key}'"))?;
401        } else if let Some(list) = current.as_list() {
402            let idx: usize = segment
403                .parse()
404                .map_err(|_| anyhow!("invalid array index '{segment}' on list '${root_key}'"))?;
405            current = list.get(idx).ok_or_else(|| {
406                anyhow!(
407                    "index {idx} out of bounds for list '${root_key}' (len: {})",
408                    list.len()
409                )
410            })?;
411        } else {
412            bail!("cannot access property '{segment}' on primitive value of '${root_key}'")
413        }
414    }
415
416    Ok(format_value_for_string(current))
417}
418
419/// Format a Value as a string for inline interpolation.
420fn format_value_for_string(val: &oxdock_parser::Value) -> String {
421    if let Some(s) = val.as_str() {
422        return s.to_string();
423    }
424    if let Some(i) = val.as_i64() {
425        return i.to_string();
426    }
427    if let Some(f) = val.as_f64() {
428        return f.to_string();
429    }
430    if let Some(b) = val.as_bool() {
431        return b.to_string();
432    }
433    // Pipes render through `Display` (`<pipe>`) via the fallthrough below;
434    // handles are opaque and have no string form to spell.
435    if let Some(d) = val.as_duration() {
436        return oxdock_parser::command::format_duration(&d);
437    }
438    if let Some(p) = val.as_path() {
439        return p.to_string_lossy().to_string();
440    }
441    if let Some(items) = val.as_list() {
442        return items
443            .iter()
444            .map(format_value_for_string)
445            .collect::<Vec<_>>()
446            .join(" ");
447    }
448    if let Some(map) = val.as_map() {
449        return map
450            .iter()
451            .map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
452            .collect::<Vec<_>>()
453            .join(", ");
454    }
455    if let Some(id) = val.as_handle() {
456        return format!("task#{}", id);
457    }
458    format!("{}", val)
459}
460
461// Legacy functions for backward compatibility
462
463pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
464where
465    F: FnMut(&str) -> Option<String>,
466{
467    let mut out = String::with_capacity(input.len());
468    let mut chars = input.chars().peekable();
469    while let Some(c) = chars.next() {
470        if c == '{' {
471            if let Some(&'{') = chars.peek() {
472                chars.next(); // consume second '{'
473                let mut content = String::new();
474                let mut closed = false;
475                // Look ahead for closing }}
476                let mut inner_chars = chars.clone();
477                while let Some(ch) = inner_chars.next() {
478                    if ch == '}'
479                        && let Some(&'}') = inner_chars.peek()
480                    {
481                        closed = true;
482                        break;
483                    }
484                    content.push(ch);
485                }
486
487                if closed {
488                    // Advance main iterator past content and closing braces.
489                    // Count chars, not bytes: content may contain multi-byte
490                    // UTF-8 (e.g. non-ASCII placeholder names).
491                    for _ in 0..content.chars().count() {
492                        chars.next();
493                    }
494                    chars.next(); // first }
495                    chars.next(); // second }
496
497                    let key = content.trim();
498                    if !key.is_empty() {
499                        out.push_str(&lookup_fn(key).unwrap_or_default());
500                    }
501                } else {
502                    out.push('{');
503                    out.push('{');
504                }
505            } else {
506                out.push('{');
507            }
508        } else {
509            out.push(c);
510        }
511    }
512    out
513}
514
515pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
516    expand_with_lookup(input, |name| {
517        if let Some(key) = name.strip_prefix("env:") {
518            script_envs
519                .get(key)
520                .cloned()
521                .or_else(|| std::env::var(key).ok())
522        } else {
523            None
524        }
525    })
526}
527
528pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
529    expand_with_lookup(input, |name| {
530        if let Some(key) = name.strip_prefix("env:") {
531            ctx.envs().get(key).cloned()
532        } else {
533            None
534        }
535    })
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use std::collections::HashMap;
542
543    #[test]
544    fn basic_expansion() {
545        let mut env = HashMap::new();
546        env.insert("NAME".into(), "World".into());
547        let expander = StreamingExpand::new(&[], &env);
548        let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
549        assert_eq!(result, "Hello World");
550    }
551
552    #[test]
553    fn multiple_vars() {
554        let mut env = HashMap::new();
555        env.insert("A".into(), "X".into());
556        env.insert("B".into(), "Y".into());
557        let expander = StreamingExpand::new(&[], &env);
558        let result = expander
559            .expand_string("{{ env:A }} and {{ env:B }}")
560            .unwrap();
561        assert_eq!(result, "X and Y");
562    }
563
564    #[test]
565    fn missing_var() {
566        let env = HashMap::new();
567        let expander = StreamingExpand::new(&[], &env);
568        let result = expander.expand_string("{{ env:MISSING }}");
569        assert!(result.is_err());
570        assert!(
571            result
572                .unwrap_err()
573                .to_string()
574                .contains("undefined environment variable"),
575            "error should mention undefined environment variable"
576        );
577    }
578
579    #[test]
580    fn no_placeholders() {
581        let env = HashMap::new();
582        let expander = StreamingExpand::new(&[], &env);
583        let result = expander.expand_string("plain text").unwrap();
584        assert_eq!(result, "plain text");
585    }
586
587    #[test]
588    fn empty_input() {
589        let env = HashMap::new();
590        let expander = StreamingExpand::new(&[], &env);
591        let result = expander.expand_string("").unwrap();
592        assert_eq!(result, "");
593    }
594
595    #[test]
596    fn override_precedence() {
597        let mut env = HashMap::new();
598        env.insert("KEY".into(), "envval".into());
599        let overrides = vec![("KEY".into(), "override".into())];
600        let expander = StreamingExpand::new(&overrides, &env);
601        let result = expander.expand_string("{{ env:KEY }}").unwrap();
602        assert_eq!(result, "override");
603    }
604
605    #[test]
606    fn override_with_namespace() {
607        let mut env = HashMap::new();
608        env.insert("CRATE".into(), "envval".into());
609        let overrides = vec![("CRATE".into(), "override".into())];
610        let expander = StreamingExpand::new(&overrides, &env);
611        let result = expander.expand_string("{{ env:CRATE }}").unwrap();
612        assert_eq!(result, "override");
613    }
614
615    #[test]
616    fn override_raw_key_match() {
617        let mut env = HashMap::new();
618        env.insert("CRATE".into(), "envval".into());
619        let overrides = vec![("env:CRATE".into(), "override".into())];
620        let expander = StreamingExpand::new(&overrides, &env);
621        let result = expander.expand_string("{{ env:CRATE }}").unwrap();
622        assert_eq!(result, "override");
623    }
624
625    #[test]
626    fn unclosed_placeholder() {
627        let env = HashMap::new();
628        let expander = StreamingExpand::new(&[], &env);
629        let result = expander.expand_string("{{ env:KEY").unwrap();
630        assert_eq!(result, "{{ env:KEY");
631    }
632
633    #[test]
634    fn unclosed_with_prefix() {
635        let env = HashMap::new();
636        let expander = StreamingExpand::new(&[], &env);
637        let result = expander.expand_string("Hello {{ env:KEY").unwrap();
638        assert_eq!(result, "Hello {{ env:KEY");
639    }
640
641    #[test]
642    fn buffer_limit_exceeded() {
643        let env = HashMap::new();
644        let expander = StreamingExpand::new(&[], &env);
645        // Create input with `{{` followed by >1024 bytes without `}}`
646        let mut input = b"{{ ".to_vec();
647        input.extend(std::iter::repeat_n(b'x', 2000));
648        let result = expander
649            .expand_string(&String::from_utf8_lossy(&input))
650            .unwrap();
651        // Should flush as literal with `{{` prefix
652        assert!(result.starts_with("{{ "));
653        assert!(result.len() > 1024);
654    }
655
656    #[test]
657    fn escaped_opener_emits_literal() {
658        let mut env = HashMap::new();
659        env.insert("PROJECT".into(), "OxDock".into());
660        let expander = StreamingExpand::new(&[], &env);
661        let result = expander
662            .expand_string("Built with \\{{ env:PROJECT }}")
663            .unwrap();
664        assert_eq!(result, "Built with {{ env:PROJECT }}");
665    }
666
667    #[test]
668    fn escaped_opener_across_chunks() {
669        let mut env = HashMap::new();
670        env.insert("PROJECT".into(), "OxDock".into());
671        let mut expander = StreamingExpand::new(&[], &env);
672        let mut out = Vec::new();
673
674        // Split `\` / `{{ env:PROJECT }}` across chunks
675        expander.process_bytes(b"Built with \\", &mut out).unwrap();
676        expander
677            .process_bytes(b"{{ env:PROJECT }}", &mut out)
678            .unwrap();
679        expander.flush(&mut out).unwrap();
680
681        assert_eq!(
682            String::from_utf8_lossy(&out),
683            "Built with {{ env:PROJECT }}"
684        );
685    }
686
687    #[test]
688    fn double_backslash_then_placeholder_expands() {
689        let mut env = HashMap::new();
690        env.insert("PROJECT".into(), "OxDock".into());
691        let expander = StreamingExpand::new(&[], &env);
692        let result = expander.expand_string("\\\\{{ env:PROJECT }}").unwrap();
693        assert_eq!(result, "\\OxDock");
694    }
695
696    #[test]
697    fn trailing_backslash_flushes_literal() {
698        let env = HashMap::new();
699        let expander = StreamingExpand::new(&[], &env);
700        let result = expander.expand_string("end\\").unwrap();
701        assert_eq!(result, "end\\");
702    }
703
704    #[test]
705    fn backslash_before_other_text_is_literal() {
706        let env = HashMap::new();
707        let expander = StreamingExpand::new(&[], &env);
708        let result = expander.expand_string("a\\b \\{ once }").unwrap();
709        assert_eq!(result, "a\\b \\{ once }");
710    }
711
712    #[test]
713    fn escaped_pair_split_across_chunks() {
714        let mut env = HashMap::new();
715        env.insert("PROJECT".into(), "OxDock".into());
716        let mut expander = StreamingExpand::new(&[], &env);
717        let mut out = Vec::new();
718
719        // Split `\\` / `{{ env:PROJECT }}` across chunks: the pair
720        // completes to one backslash, then the placeholder expands
721        expander.process_bytes(b"\\", &mut out).unwrap();
722        expander
723            .process_bytes(b"\\{{ env:PROJECT }}", &mut out)
724            .unwrap();
725        expander.flush(&mut out).unwrap();
726
727        assert_eq!(String::from_utf8_lossy(&out), "\\OxDock");
728    }
729
730    #[test]
731    fn partial_across_chunks() {
732        let mut env = HashMap::new();
733        env.insert("NAME".into(), "World".into());
734        let mut expander = StreamingExpand::new(&[], &env);
735        let mut out = Vec::new();
736
737        // Split `{{ env:NA` / `ME }}` across chunks
738        expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
739        expander.process_bytes(b"ME }}", &mut out).unwrap();
740        expander.flush(&mut out).unwrap();
741
742        assert_eq!(String::from_utf8_lossy(&out), "World");
743    }
744
745    #[test]
746    fn trailing_brace_across_chunks() {
747        let mut env = HashMap::new();
748        env.insert("NAME".into(), "World".into());
749        let mut expander = StreamingExpand::new(&[], &env);
750        let mut out = Vec::new();
751
752        // Split `...{` / `{env:NAME}}` across chunks
753        expander.process_bytes(b"...", &mut out).unwrap();
754        expander.process_bytes(b"{", &mut out).unwrap();
755        expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
756        expander.flush(&mut out).unwrap();
757
758        assert_eq!(String::from_utf8_lossy(&out), "...World");
759    }
760
761    #[test]
762    fn trailing_brace_at_eof() {
763        let env = HashMap::new();
764        let mut expander = StreamingExpand::new(&[], &env);
765        let mut out = Vec::new();
766
767        expander.process_bytes(b"hello{", &mut out).unwrap();
768        expander.flush(&mut out).unwrap();
769
770        assert_eq!(String::from_utf8_lossy(&out), "hello{");
771    }
772
773    #[test]
774    fn immediate_flush_guarantee() {
775        let env = HashMap::new();
776        let mut expander = StreamingExpand::new(&[], &env);
777        let mut out = Vec::new();
778
779        // 1MB of plain text with no placeholders
780        let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
781        expander.process_bytes(&input, &mut out).unwrap();
782        expander.flush(&mut out).unwrap();
783
784        assert_eq!(out.len(), 1024 * 1024);
785    }
786
787    #[test]
788    fn nested_braces() {
789        let mut env = HashMap::new();
790        env.insert("KEY{1}".into(), "val".into());
791        let expander = StreamingExpand::new(&[], &env);
792        let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
793        assert_eq!(result, "val");
794    }
795
796    #[test]
797    fn split_close_delimiter_across_chunks() {
798        let mut env = HashMap::new();
799        env.insert("NAME".into(), "World".into());
800        let mut expander = StreamingExpand::new(&[], &env);
801        let mut out = Vec::new();
802
803        // Split `}}` across chunks: `{{ env:NAME` / `}}`
804        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
805        expander.process_bytes(b"}}", &mut out).unwrap();
806        expander.flush(&mut out).unwrap();
807
808        assert_eq!(String::from_utf8_lossy(&out), "World");
809    }
810
811    #[test]
812    fn split_close_delimiter_with_trailing_content() {
813        let mut env = HashMap::new();
814        env.insert("NAME".into(), "World".into());
815        let mut expander = StreamingExpand::new(&[], &env);
816        let mut out = Vec::new();
817
818        // Split `}}` across chunks with content after
819        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
820        expander.process_bytes(b"}} rest", &mut out).unwrap();
821        expander.flush(&mut out).unwrap();
822
823        assert_eq!(String::from_utf8_lossy(&out), "World rest");
824    }
825
826    #[test]
827    fn empty_input_preserves_pending_state() {
828        let mut env = HashMap::new();
829        env.insert("NAME".into(), "World".into());
830        let mut expander = StreamingExpand::new(&[], &env);
831        let mut out = Vec::new();
832
833        // End chunk with closing byte, then empty input, then confirm
834        expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
835        expander.process_bytes(b"", &mut out).unwrap(); // empty — should preserve state
836        expander.process_bytes(b"}}", &mut out).unwrap();
837        expander.flush(&mut out).unwrap();
838
839        assert_eq!(String::from_utf8_lossy(&out), "World");
840    }
841
842    #[test]
843    fn missing_env_var_errors() {
844        let env = HashMap::new();
845        let expander = StreamingExpand::new(&[], &env);
846        let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
847        assert!(result.is_err());
848        let msg = result.unwrap_err().to_string();
849        assert!(msg.contains("undefined environment variable"), "got: {msg}");
850        assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
851    }
852
853    #[test]
854    fn missing_script_var_errors() {
855        let env = HashMap::new();
856        let expander = StreamingExpand::new(&[], &env);
857        let result = expander.expand_string("{{ $undefined_var }}");
858        assert!(result.is_err());
859        let msg = result.unwrap_err().to_string();
860        assert!(msg.contains("undefined script variable"), "got: {msg}");
861        assert!(msg.contains("$undefined_var"), "got: {msg}");
862    }
863
864    #[test]
865    fn missing_key_in_map_errors() {
866        let mut vars = HashMap::new();
867        vars.insert(
868            "cfg".into(),
869            oxdock_parser::Value::map(std::collections::BTreeMap::from([(
870                "server".into(),
871                oxdock_parser::Value::map(std::collections::BTreeMap::from([(
872                    "port".into(),
873                    oxdock_parser::Value::int(8080),
874                )])),
875            )])),
876        );
877        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
878        let result = expander.expand_string("{{ $cfg.missing_key }}");
879        assert!(result.is_err());
880        let msg = result.unwrap_err().to_string();
881        assert!(
882            msg.contains("property 'missing_key' not found"),
883            "got: {msg}"
884        );
885    }
886
887    #[test]
888    fn out_of_bounds_array_index_errors() {
889        let mut vars = HashMap::new();
890        vars.insert(
891            "arr".into(),
892            oxdock_parser::Value::list(vec![oxdock_parser::Value::string("a".into())]),
893        );
894        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
895        let result = expander.expand_string("{{ $arr.5 }}");
896        assert!(result.is_err());
897        let msg = result.unwrap_err().to_string();
898        assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
899    }
900
901    #[test]
902    fn type_mismatch_navigation_errors() {
903        let mut vars = HashMap::new();
904        vars.insert("name".into(), oxdock_parser::Value::string("alice".into()));
905        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
906        let result = expander.expand_string("{{ $name.sub_field }}");
907        assert!(result.is_err());
908        let msg = result.unwrap_err().to_string();
909        assert!(
910            msg.contains("cannot access property 'sub_field' on primitive"),
911            "got: {msg}"
912        );
913    }
914
915    #[test]
916    fn unprefixed_identifier_errors() {
917        let env = HashMap::new();
918        let expander = StreamingExpand::new(&[], &env);
919        let result = expander.expand_string("{{ bare_word }}");
920        assert!(result.is_err());
921        let msg = result.unwrap_err().to_string();
922        assert!(msg.contains("missing required step override"), "got: {msg}");
923    }
924
925    // ── Strict namespace isolation tests ─────────────────────────────────────
926
927    #[test]
928    fn script_var_does_not_fall_back_to_env() {
929        let mut env = HashMap::new();
930        env.insert("WHO".into(), "from-env".into());
931        let expander = StreamingExpand::new(&[], &env);
932        // $WHO queries vars, NOT env — should error even though env has WHO
933        let result = expander.expand_string("{{ $WHO }}");
934        assert!(result.is_err());
935        let msg = result.unwrap_err().to_string();
936        assert!(msg.contains("undefined script variable"), "got: {msg}");
937        assert!(
938            msg.contains("did you mean 'env:WHO'"),
939            "hint should suggest env: prefix, got: {msg}"
940        );
941    }
942
943    #[test]
944    fn env_var_does_not_fall_back_to_vars() {
945        let mut vars = HashMap::new();
946        vars.insert(
947            "HOST".into(),
948            oxdock_parser::Value::string("from-var".into()),
949        );
950        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
951        // env:HOST queries env, NOT vars — should error even though vars has HOST
952        let result = expander.expand_string("{{ env:HOST }}");
953        assert!(result.is_err());
954        let msg = result.unwrap_err().to_string();
955        assert!(msg.contains("undefined environment variable"), "got: {msg}");
956        assert!(
957            msg.contains("did you mean '$HOST'"),
958            "hint should suggest $ prefix, got: {msg}"
959        );
960    }
961
962    #[test]
963    fn step_override_does_not_fall_back_to_vars() {
964        let mut vars = HashMap::new();
965        vars.insert("PORT".into(), oxdock_parser::Value::int(8080));
966        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
967        // PORT (bare) queries overrides, NOT vars — should error
968        let result = expander.expand_string("{{ PORT }}");
969        assert!(result.is_err());
970        let msg = result.unwrap_err().to_string();
971        assert!(msg.contains("missing required step override"), "got: {msg}");
972        assert!(
973            msg.contains("did you mean '$PORT'"),
974            "hint should suggest $ prefix, got: {msg}"
975        );
976    }
977
978    #[test]
979    fn env_prefix_isolated_from_script_vars() {
980        let mut vars = HashMap::new();
981        vars.insert("MODE".into(), oxdock_parser::Value::string("dev".into()));
982        let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
983        // env:MODE looks in env, not vars — should error
984        let result = expander.expand_string("{{ env:MODE }}");
985        assert!(result.is_err());
986        let msg = result.unwrap_err().to_string();
987        assert!(msg.contains("undefined environment variable"), "got: {msg}");
988        assert!(
989            msg.contains("did you mean '$MODE'"),
990            "hint should suggest $ prefix, got: {msg}"
991        );
992    }
993
994    #[test]
995    fn dollar_prefix_isolated_from_env() {
996        let mut env = HashMap::new();
997        env.insert("PORT".into(), "3000".into());
998        let expander = StreamingExpand::new(&[], &env);
999        // $PORT looks in vars, not env — should error
1000        let result = expander.expand_string("{{ $PORT }}");
1001        assert!(result.is_err());
1002        let msg = result.unwrap_err().to_string();
1003        assert!(msg.contains("undefined script variable"), "got: {msg}");
1004        assert!(
1005            msg.contains("did you mean 'env:PORT'"),
1006            "hint should suggest env: prefix, got: {msg}"
1007        );
1008    }
1009
1010    #[test]
1011    fn empty_placeholder_errors() {
1012        let expander = StreamingExpand::new(&[], &HashMap::new());
1013        let result = expander.expand_string("{{ }}");
1014        assert!(result.is_err());
1015        let msg = result.unwrap_err().to_string();
1016        assert!(msg.contains("invalid placeholder format"), "got: {msg}");
1017    }
1018
1019    #[test]
1020    fn malformed_symbol_placeholder_errors() {
1021        let expander = StreamingExpand::new(&[], &HashMap::new());
1022        let result = expander.expand_string("{{ @invalid! }}");
1023        assert!(result.is_err());
1024        let msg = result.unwrap_err().to_string();
1025        assert!(msg.contains("invalid placeholder format"), "got: {msg}");
1026    }
1027
1028    /// Feed input in fixed-size chunks, then flush: exercises every
1029    /// chunk-boundary alignment for the given split size.
1030    fn render_split(
1031        input: &[u8],
1032        size: usize,
1033        env: &HashMap<String, String>,
1034    ) -> Result<String, anyhow::Error> {
1035        let mut expander = StreamingExpand::new(&[], env);
1036        let mut out = Vec::new();
1037        for chunk in input.chunks(size) {
1038            expander.process_bytes(chunk, &mut out)?;
1039        }
1040        expander.flush(&mut out)?;
1041        Ok(String::from_utf8_lossy(&out).into_owned())
1042    }
1043
1044    #[test]
1045    fn escaped_opener_one_byte_chunks_stays_literal() {
1046        let mut env = HashMap::new();
1047        env.insert("NAME".into(), "World".into());
1048        // Every byte arrives alone: `\` | `{` | `{` | ... must still
1049        // resolve to a literal opener, never an expansion.
1050        let out = render_split(b"\\{{ env:NAME }}", 1, &env).unwrap();
1051        assert_eq!(out, "{{ env:NAME }}");
1052    }
1053
1054    #[test]
1055    fn escaped_opener_two_byte_chunks_stays_literal() {
1056        let mut env = HashMap::new();
1057        env.insert("NAME".into(), "World".into());
1058        // Two-byte splits land `\{` and lone `{` at chunk ends.
1059        let out = render_split(b"a\\{{ env:NAME }}b", 2, &env).unwrap();
1060        assert_eq!(out, "a{{ env:NAME }}b");
1061    }
1062
1063    #[test]
1064    fn escaped_opener_split_variants_stay_literal() {
1065        let mut env = HashMap::new();
1066        env.insert("NAME".into(), "World".into());
1067        for chunks in [
1068            vec![b"\\".as_slice(), b"{", b"{ env:NAME }}"],
1069            vec![b"\\{".as_slice(), b"{ env:NAME }}"],
1070            vec![b"\\{{ env:NAME ".as_slice(), b"}}"],
1071        ] {
1072            let mut expander = StreamingExpand::new(&[], &env);
1073            let mut out = Vec::new();
1074            for chunk in chunks {
1075                expander.process_bytes(chunk, &mut out).unwrap();
1076            }
1077            expander.flush(&mut out).unwrap();
1078            assert_eq!(
1079                String::from_utf8_lossy(&out),
1080                "{{ env:NAME }}",
1081                "chunks must not corrupt the escape"
1082            );
1083        }
1084    }
1085
1086    #[test]
1087    fn plain_opener_split_chunks_still_expands() {
1088        let mut env = HashMap::new();
1089        env.insert("NAME".into(), "World".into());
1090        // The fix must not swallow genuine openers fragmented the
1091        // same way: `{` | `{` opens, then the key resolves.
1092        let out = render_split(b"{{ env:NAME }}", 1, &env).unwrap();
1093        assert_eq!(out, "World");
1094    }
1095
1096    #[test]
1097    fn double_backslash_split_chunks_still_expands() {
1098        let mut env = HashMap::new();
1099        env.insert("NAME".into(), "World".into());
1100        // `\\` completes to one backslash; the opener after it expands.
1101        let out = render_split(b"\\\\{{ env:NAME }}", 1, &env).unwrap();
1102        assert_eq!(out, "\\World");
1103    }
1104
1105    #[test]
1106    fn trailing_escape_brace_flushes_literal() {
1107        let env = HashMap::new();
1108        let expander = StreamingExpand::new(&[], &env);
1109        let result = expander.expand_string("end\\{").unwrap();
1110        assert_eq!(result, "end\\{");
1111    }
1112
1113    // ── Single-pass substitution tests ────────────────────────────────────
1114    // Substituted values are emitted verbatim and never re-scanned for
1115    // `{{ ... }}`. These pin the top-level-only behavior: a value that
1116    // itself contains a placeholder stays literal instead of expanding.
1117
1118    #[test]
1119    fn substituted_override_value_is_not_rescanned() {
1120        let mut env = HashMap::new();
1121        env.insert("OTHER".into(), "world".into());
1122        let overrides = vec![("NAME".into(), "{{ env:OTHER }}".into())];
1123        let expander = StreamingExpand::new(&overrides, &env);
1124        let result = expander.expand_string("Hello {{ NAME }}").unwrap();
1125        assert_eq!(result, "Hello {{ env:OTHER }}");
1126    }
1127
1128    #[test]
1129    fn substituted_env_value_is_not_rescanned() {
1130        let mut env = HashMap::new();
1131        env.insert("NAME".into(), "{{ env:OTHER }}".into());
1132        env.insert("OTHER".into(), "world".into());
1133        let expander = StreamingExpand::new(&[], &env);
1134        let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
1135        assert_eq!(result, "Hello {{ env:OTHER }}");
1136    }
1137
1138    #[test]
1139    fn substituted_script_var_value_is_not_rescanned() {
1140        let mut vars = HashMap::new();
1141        vars.insert(
1142            "inner".into(),
1143            oxdock_parser::Value::string("{{ env:OTHER }}".into()),
1144        );
1145        let mut env = HashMap::new();
1146        env.insert("OTHER".into(), "world".into());
1147        let expander = StreamingExpand::new(&[], &env).with_vars(&vars);
1148        let result = expander.expand_string("Hello {{ $inner }}").unwrap();
1149        assert_eq!(result, "Hello {{ env:OTHER }}");
1150    }
1151}