1use std::collections::HashMap;
2
3use anyhow::{Result, anyhow, bail};
4
5use crate::contract::CommandContext;
6
7const MAX_PLACEHOLDER_SCAN: usize = 1024;
10
11pub 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
26pub struct StreamingExpand {
32 buffer: Vec<u8>,
34 overrides: HashMap<String, String>,
36 env: HashMap<String, String>,
38 vars: HashMap<String, oxdock_parser::Value>,
40 in_placeholder: bool,
42 pending_brace: bool,
44 pending_close_brace: bool,
46 pending_escape: bool,
49 pending_escape_brace: bool,
52 delimiters: TemplateDelimiters,
54}
55
56impl StreamingExpand {
57 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 pub fn with_vars(mut self, vars: &HashMap<String, oxdock_parser::Value>) -> Self {
77 self.vars = vars.clone();
78 self
79 }
80
81 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 if self.pending_close_brace {
93 self.pending_close_brace = false;
94 if input[0] == self.delimiters.close[1] {
95 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; } else {
103 self.buffer.push(self.delimiters.close[0]);
106 i = 0; }
108 }
109
110 if self.pending_brace {
112 self.pending_brace = false;
113 if input[0] == self.delimiters.open[1] {
114 self.in_placeholder = true;
116 self.buffer.clear();
117 i = 1; } else {
119 out.push(self.delimiters.open[0]);
121 }
122 }
123
124 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 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 out.push(b'\\');
160 }
161 }
162
163 if self.in_placeholder {
164 i = self.scan_placeholder(input, i, out)?;
166 }
167
168 while i < input.len() {
170 if input[i] == b'\\' {
171 if i + 2 < input.len() && input[i + 1] == b'{' && input[i + 2] == b'{' {
172 out.extend_from_slice(b"{{");
174 i += 3;
175 } else if i + 2 == input.len() && input[i + 1] == self.delimiters.open[0] {
176 self.pending_escape_brace = true;
179 i += 2;
180 } else if i + 1 < input.len() && input[i + 1] == b'\\' {
181 out.push(b'\\');
185 i += 2;
186 } else if i + 1 == input.len() {
187 self.pending_escape = true;
189 i += 1;
190 } else {
191 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 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 self.pending_brace = true;
205 i += 1;
206 } else {
207 out.push(self.delimiters.open[0]);
209 i += 1;
210 }
211 } else {
212 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 pub fn flush(mut self, out: &mut Vec<u8>) -> Result<()> {
227 if self.pending_close_brace {
229 self.buffer.push(self.delimiters.close[0]);
230 self.pending_close_brace = false;
231 }
232 if self.pending_brace {
234 out.push(self.delimiters.open[0]);
235 self.pending_brace = false;
236 }
237 if self.pending_escape {
239 out.push(b'\\');
240 self.pending_escape = false;
241 }
242 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 self.in_placeholder {
250 out.extend_from_slice(self.delimiters.open);
251 self.in_placeholder = false;
252 }
253 out.extend_from_slice(&self.buffer);
255 self.buffer.clear();
256 Ok(())
257 }
258
259 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 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 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 self.pending_close_brace = true;
285 return Ok(i + 1);
286 }
287 }
288 self.buffer.push(input[i]);
289 i += 1;
290
291 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
304fn extract_key(buffer: &[u8]) -> String {
306 String::from_utf8_lossy(buffer).trim().to_string()
307}
308
309fn 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 if let Some(val) = overrides.get(key) {
328 return Ok(val.clone());
329 }
330
331 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 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 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
383fn 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
419fn 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 if let Some(n) = val.as_pipe_name() {
434 return format!("pipe:{n}");
435 }
436 if let Some(d) = val.as_duration() {
437 return oxdock_parser::command::format_duration(&d);
438 }
439 if let Some(p) = val.as_path() {
440 return p.to_string_lossy().to_string();
441 }
442 if let Some(items) = val.as_list() {
443 return items
444 .iter()
445 .map(format_value_for_string)
446 .collect::<Vec<_>>()
447 .join(" ");
448 }
449 if let Some(map) = val.as_map() {
450 return map
451 .iter()
452 .map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
453 .collect::<Vec<_>>()
454 .join(", ");
455 }
456 if let Some(id) = val.as_handle() {
457 return format!("task#{}", id);
458 }
459 format!("{}", val)
460}
461
462pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
465where
466 F: FnMut(&str) -> Option<String>,
467{
468 let mut out = String::with_capacity(input.len());
469 let mut chars = input.chars().peekable();
470 while let Some(c) = chars.next() {
471 if c == '{' {
472 if let Some(&'{') = chars.peek() {
473 chars.next(); let mut content = String::new();
475 let mut closed = false;
476 let mut inner_chars = chars.clone();
478 while let Some(ch) = inner_chars.next() {
479 if ch == '}'
480 && let Some(&'}') = inner_chars.peek()
481 {
482 closed = true;
483 break;
484 }
485 content.push(ch);
486 }
487
488 if closed {
489 for _ in 0..content.chars().count() {
493 chars.next();
494 }
495 chars.next(); chars.next(); let key = content.trim();
499 if !key.is_empty() {
500 out.push_str(&lookup_fn(key).unwrap_or_default());
501 }
502 } else {
503 out.push('{');
504 out.push('{');
505 }
506 } else {
507 out.push('{');
508 }
509 } else {
510 out.push(c);
511 }
512 }
513 out
514}
515
516pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
517 expand_with_lookup(input, |name| {
518 if let Some(key) = name.strip_prefix("env:") {
519 script_envs
520 .get(key)
521 .cloned()
522 .or_else(|| std::env::var(key).ok())
523 } else {
524 None
525 }
526 })
527}
528
529pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
530 expand_with_lookup(input, |name| {
531 if let Some(key) = name.strip_prefix("env:") {
532 ctx.envs().get(key).cloned()
533 } else {
534 None
535 }
536 })
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use std::collections::HashMap;
543
544 #[test]
545 fn basic_expansion() {
546 let mut env = HashMap::new();
547 env.insert("NAME".into(), "World".into());
548 let expander = StreamingExpand::new(&[], &env);
549 let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
550 assert_eq!(result, "Hello World");
551 }
552
553 #[test]
554 fn multiple_vars() {
555 let mut env = HashMap::new();
556 env.insert("A".into(), "X".into());
557 env.insert("B".into(), "Y".into());
558 let expander = StreamingExpand::new(&[], &env);
559 let result = expander
560 .expand_string("{{ env:A }} and {{ env:B }}")
561 .unwrap();
562 assert_eq!(result, "X and Y");
563 }
564
565 #[test]
566 fn missing_var() {
567 let env = HashMap::new();
568 let expander = StreamingExpand::new(&[], &env);
569 let result = expander.expand_string("{{ env:MISSING }}");
570 assert!(result.is_err());
571 assert!(
572 result
573 .unwrap_err()
574 .to_string()
575 .contains("undefined environment variable"),
576 "error should mention undefined environment variable"
577 );
578 }
579
580 #[test]
581 fn no_placeholders() {
582 let env = HashMap::new();
583 let expander = StreamingExpand::new(&[], &env);
584 let result = expander.expand_string("plain text").unwrap();
585 assert_eq!(result, "plain text");
586 }
587
588 #[test]
589 fn empty_input() {
590 let env = HashMap::new();
591 let expander = StreamingExpand::new(&[], &env);
592 let result = expander.expand_string("").unwrap();
593 assert_eq!(result, "");
594 }
595
596 #[test]
597 fn override_precedence() {
598 let mut env = HashMap::new();
599 env.insert("KEY".into(), "envval".into());
600 let overrides = vec![("KEY".into(), "override".into())];
601 let expander = StreamingExpand::new(&overrides, &env);
602 let result = expander.expand_string("{{ env:KEY }}").unwrap();
603 assert_eq!(result, "override");
604 }
605
606 #[test]
607 fn override_with_namespace() {
608 let mut env = HashMap::new();
609 env.insert("CRATE".into(), "envval".into());
610 let overrides = vec![("CRATE".into(), "override".into())];
611 let expander = StreamingExpand::new(&overrides, &env);
612 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
613 assert_eq!(result, "override");
614 }
615
616 #[test]
617 fn override_raw_key_match() {
618 let mut env = HashMap::new();
619 env.insert("CRATE".into(), "envval".into());
620 let overrides = vec![("env:CRATE".into(), "override".into())];
621 let expander = StreamingExpand::new(&overrides, &env);
622 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
623 assert_eq!(result, "override");
624 }
625
626 #[test]
627 fn unclosed_placeholder() {
628 let env = HashMap::new();
629 let expander = StreamingExpand::new(&[], &env);
630 let result = expander.expand_string("{{ env:KEY").unwrap();
631 assert_eq!(result, "{{ env:KEY");
632 }
633
634 #[test]
635 fn unclosed_with_prefix() {
636 let env = HashMap::new();
637 let expander = StreamingExpand::new(&[], &env);
638 let result = expander.expand_string("Hello {{ env:KEY").unwrap();
639 assert_eq!(result, "Hello {{ env:KEY");
640 }
641
642 #[test]
643 fn buffer_limit_exceeded() {
644 let env = HashMap::new();
645 let expander = StreamingExpand::new(&[], &env);
646 let mut input = b"{{ ".to_vec();
648 input.extend(std::iter::repeat_n(b'x', 2000));
649 let result = expander
650 .expand_string(&String::from_utf8_lossy(&input))
651 .unwrap();
652 assert!(result.starts_with("{{ "));
654 assert!(result.len() > 1024);
655 }
656
657 #[test]
658 fn escaped_opener_emits_literal() {
659 let mut env = HashMap::new();
660 env.insert("PROJECT".into(), "OxDock".into());
661 let expander = StreamingExpand::new(&[], &env);
662 let result = expander
663 .expand_string("Built with \\{{ env:PROJECT }}")
664 .unwrap();
665 assert_eq!(result, "Built with {{ env:PROJECT }}");
666 }
667
668 #[test]
669 fn escaped_opener_across_chunks() {
670 let mut env = HashMap::new();
671 env.insert("PROJECT".into(), "OxDock".into());
672 let mut expander = StreamingExpand::new(&[], &env);
673 let mut out = Vec::new();
674
675 expander.process_bytes(b"Built with \\", &mut out).unwrap();
677 expander
678 .process_bytes(b"{{ env:PROJECT }}", &mut out)
679 .unwrap();
680 expander.flush(&mut out).unwrap();
681
682 assert_eq!(
683 String::from_utf8_lossy(&out),
684 "Built with {{ env:PROJECT }}"
685 );
686 }
687
688 #[test]
689 fn double_backslash_then_placeholder_expands() {
690 let mut env = HashMap::new();
691 env.insert("PROJECT".into(), "OxDock".into());
692 let expander = StreamingExpand::new(&[], &env);
693 let result = expander.expand_string("\\\\{{ env:PROJECT }}").unwrap();
694 assert_eq!(result, "\\OxDock");
695 }
696
697 #[test]
698 fn trailing_backslash_flushes_literal() {
699 let env = HashMap::new();
700 let expander = StreamingExpand::new(&[], &env);
701 let result = expander.expand_string("end\\").unwrap();
702 assert_eq!(result, "end\\");
703 }
704
705 #[test]
706 fn backslash_before_other_text_is_literal() {
707 let env = HashMap::new();
708 let expander = StreamingExpand::new(&[], &env);
709 let result = expander.expand_string("a\\b \\{ once }").unwrap();
710 assert_eq!(result, "a\\b \\{ once }");
711 }
712
713 #[test]
714 fn escaped_pair_split_across_chunks() {
715 let mut env = HashMap::new();
716 env.insert("PROJECT".into(), "OxDock".into());
717 let mut expander = StreamingExpand::new(&[], &env);
718 let mut out = Vec::new();
719
720 expander.process_bytes(b"\\", &mut out).unwrap();
723 expander
724 .process_bytes(b"\\{{ env:PROJECT }}", &mut out)
725 .unwrap();
726 expander.flush(&mut out).unwrap();
727
728 assert_eq!(String::from_utf8_lossy(&out), "\\OxDock");
729 }
730
731 #[test]
732 fn partial_across_chunks() {
733 let mut env = HashMap::new();
734 env.insert("NAME".into(), "World".into());
735 let mut expander = StreamingExpand::new(&[], &env);
736 let mut out = Vec::new();
737
738 expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
740 expander.process_bytes(b"ME }}", &mut out).unwrap();
741 expander.flush(&mut out).unwrap();
742
743 assert_eq!(String::from_utf8_lossy(&out), "World");
744 }
745
746 #[test]
747 fn trailing_brace_across_chunks() {
748 let mut env = HashMap::new();
749 env.insert("NAME".into(), "World".into());
750 let mut expander = StreamingExpand::new(&[], &env);
751 let mut out = Vec::new();
752
753 expander.process_bytes(b"...", &mut out).unwrap();
755 expander.process_bytes(b"{", &mut out).unwrap();
756 expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
757 expander.flush(&mut out).unwrap();
758
759 assert_eq!(String::from_utf8_lossy(&out), "...World");
760 }
761
762 #[test]
763 fn trailing_brace_at_eof() {
764 let env = HashMap::new();
765 let mut expander = StreamingExpand::new(&[], &env);
766 let mut out = Vec::new();
767
768 expander.process_bytes(b"hello{", &mut out).unwrap();
769 expander.flush(&mut out).unwrap();
770
771 assert_eq!(String::from_utf8_lossy(&out), "hello{");
772 }
773
774 #[test]
775 fn immediate_flush_guarantee() {
776 let env = HashMap::new();
777 let mut expander = StreamingExpand::new(&[], &env);
778 let mut out = Vec::new();
779
780 let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
782 expander.process_bytes(&input, &mut out).unwrap();
783 expander.flush(&mut out).unwrap();
784
785 assert_eq!(out.len(), 1024 * 1024);
786 }
787
788 #[test]
789 fn nested_braces() {
790 let mut env = HashMap::new();
791 env.insert("KEY{1}".into(), "val".into());
792 let expander = StreamingExpand::new(&[], &env);
793 let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
794 assert_eq!(result, "val");
795 }
796
797 #[test]
798 fn split_close_delimiter_across_chunks() {
799 let mut env = HashMap::new();
800 env.insert("NAME".into(), "World".into());
801 let mut expander = StreamingExpand::new(&[], &env);
802 let mut out = Vec::new();
803
804 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
806 expander.process_bytes(b"}}", &mut out).unwrap();
807 expander.flush(&mut out).unwrap();
808
809 assert_eq!(String::from_utf8_lossy(&out), "World");
810 }
811
812 #[test]
813 fn split_close_delimiter_with_trailing_content() {
814 let mut env = HashMap::new();
815 env.insert("NAME".into(), "World".into());
816 let mut expander = StreamingExpand::new(&[], &env);
817 let mut out = Vec::new();
818
819 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
821 expander.process_bytes(b"}} rest", &mut out).unwrap();
822 expander.flush(&mut out).unwrap();
823
824 assert_eq!(String::from_utf8_lossy(&out), "World rest");
825 }
826
827 #[test]
828 fn empty_input_preserves_pending_state() {
829 let mut env = HashMap::new();
830 env.insert("NAME".into(), "World".into());
831 let mut expander = StreamingExpand::new(&[], &env);
832 let mut out = Vec::new();
833
834 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
836 expander.process_bytes(b"", &mut out).unwrap(); expander.process_bytes(b"}}", &mut out).unwrap();
838 expander.flush(&mut out).unwrap();
839
840 assert_eq!(String::from_utf8_lossy(&out), "World");
841 }
842
843 #[test]
844 fn missing_env_var_errors() {
845 let env = HashMap::new();
846 let expander = StreamingExpand::new(&[], &env);
847 let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
848 assert!(result.is_err());
849 let msg = result.unwrap_err().to_string();
850 assert!(msg.contains("undefined environment variable"), "got: {msg}");
851 assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
852 }
853
854 #[test]
855 fn missing_script_var_errors() {
856 let env = HashMap::new();
857 let expander = StreamingExpand::new(&[], &env);
858 let result = expander.expand_string("{{ $undefined_var }}");
859 assert!(result.is_err());
860 let msg = result.unwrap_err().to_string();
861 assert!(msg.contains("undefined script variable"), "got: {msg}");
862 assert!(msg.contains("$undefined_var"), "got: {msg}");
863 }
864
865 #[test]
866 fn missing_key_in_map_errors() {
867 let mut vars = HashMap::new();
868 vars.insert(
869 "cfg".into(),
870 oxdock_parser::Value::map(std::collections::BTreeMap::from([(
871 "server".into(),
872 oxdock_parser::Value::map(std::collections::BTreeMap::from([(
873 "port".into(),
874 oxdock_parser::Value::int(8080),
875 )])),
876 )])),
877 );
878 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
879 let result = expander.expand_string("{{ $cfg.missing_key }}");
880 assert!(result.is_err());
881 let msg = result.unwrap_err().to_string();
882 assert!(
883 msg.contains("property 'missing_key' not found"),
884 "got: {msg}"
885 );
886 }
887
888 #[test]
889 fn out_of_bounds_array_index_errors() {
890 let mut vars = HashMap::new();
891 vars.insert(
892 "arr".into(),
893 oxdock_parser::Value::list(vec![oxdock_parser::Value::string("a".into())]),
894 );
895 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
896 let result = expander.expand_string("{{ $arr.5 }}");
897 assert!(result.is_err());
898 let msg = result.unwrap_err().to_string();
899 assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
900 }
901
902 #[test]
903 fn type_mismatch_navigation_errors() {
904 let mut vars = HashMap::new();
905 vars.insert("name".into(), oxdock_parser::Value::string("alice".into()));
906 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
907 let result = expander.expand_string("{{ $name.sub_field }}");
908 assert!(result.is_err());
909 let msg = result.unwrap_err().to_string();
910 assert!(
911 msg.contains("cannot access property 'sub_field' on primitive"),
912 "got: {msg}"
913 );
914 }
915
916 #[test]
917 fn unprefixed_identifier_errors() {
918 let env = HashMap::new();
919 let expander = StreamingExpand::new(&[], &env);
920 let result = expander.expand_string("{{ bare_word }}");
921 assert!(result.is_err());
922 let msg = result.unwrap_err().to_string();
923 assert!(msg.contains("missing required step override"), "got: {msg}");
924 }
925
926 #[test]
929 fn script_var_does_not_fall_back_to_env() {
930 let mut env = HashMap::new();
931 env.insert("WHO".into(), "from-env".into());
932 let expander = StreamingExpand::new(&[], &env);
933 let result = expander.expand_string("{{ $WHO }}");
935 assert!(result.is_err());
936 let msg = result.unwrap_err().to_string();
937 assert!(msg.contains("undefined script variable"), "got: {msg}");
938 assert!(
939 msg.contains("did you mean 'env:WHO'"),
940 "hint should suggest env: prefix, got: {msg}"
941 );
942 }
943
944 #[test]
945 fn env_var_does_not_fall_back_to_vars() {
946 let mut vars = HashMap::new();
947 vars.insert(
948 "HOST".into(),
949 oxdock_parser::Value::string("from-var".into()),
950 );
951 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
952 let result = expander.expand_string("{{ env:HOST }}");
954 assert!(result.is_err());
955 let msg = result.unwrap_err().to_string();
956 assert!(msg.contains("undefined environment variable"), "got: {msg}");
957 assert!(
958 msg.contains("did you mean '$HOST'"),
959 "hint should suggest $ prefix, got: {msg}"
960 );
961 }
962
963 #[test]
964 fn step_override_does_not_fall_back_to_vars() {
965 let mut vars = HashMap::new();
966 vars.insert("PORT".into(), oxdock_parser::Value::int(8080));
967 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
968 let result = expander.expand_string("{{ PORT }}");
970 assert!(result.is_err());
971 let msg = result.unwrap_err().to_string();
972 assert!(msg.contains("missing required step override"), "got: {msg}");
973 assert!(
974 msg.contains("did you mean '$PORT'"),
975 "hint should suggest $ prefix, got: {msg}"
976 );
977 }
978
979 #[test]
980 fn env_prefix_isolated_from_script_vars() {
981 let mut vars = HashMap::new();
982 vars.insert("MODE".into(), oxdock_parser::Value::string("dev".into()));
983 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
984 let result = expander.expand_string("{{ env:MODE }}");
986 assert!(result.is_err());
987 let msg = result.unwrap_err().to_string();
988 assert!(msg.contains("undefined environment variable"), "got: {msg}");
989 assert!(
990 msg.contains("did you mean '$MODE'"),
991 "hint should suggest $ prefix, got: {msg}"
992 );
993 }
994
995 #[test]
996 fn dollar_prefix_isolated_from_env() {
997 let mut env = HashMap::new();
998 env.insert("PORT".into(), "3000".into());
999 let expander = StreamingExpand::new(&[], &env);
1000 let result = expander.expand_string("{{ $PORT }}");
1002 assert!(result.is_err());
1003 let msg = result.unwrap_err().to_string();
1004 assert!(msg.contains("undefined script variable"), "got: {msg}");
1005 assert!(
1006 msg.contains("did you mean 'env:PORT'"),
1007 "hint should suggest env: prefix, got: {msg}"
1008 );
1009 }
1010
1011 #[test]
1012 fn empty_placeholder_errors() {
1013 let expander = StreamingExpand::new(&[], &HashMap::new());
1014 let result = expander.expand_string("{{ }}");
1015 assert!(result.is_err());
1016 let msg = result.unwrap_err().to_string();
1017 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
1018 }
1019
1020 #[test]
1021 fn malformed_symbol_placeholder_errors() {
1022 let expander = StreamingExpand::new(&[], &HashMap::new());
1023 let result = expander.expand_string("{{ @invalid! }}");
1024 assert!(result.is_err());
1025 let msg = result.unwrap_err().to_string();
1026 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
1027 }
1028
1029 fn render_split(
1032 input: &[u8],
1033 size: usize,
1034 env: &HashMap<String, String>,
1035 ) -> Result<String, anyhow::Error> {
1036 let mut expander = StreamingExpand::new(&[], env);
1037 let mut out = Vec::new();
1038 for chunk in input.chunks(size) {
1039 expander.process_bytes(chunk, &mut out)?;
1040 }
1041 expander.flush(&mut out)?;
1042 Ok(String::from_utf8_lossy(&out).into_owned())
1043 }
1044
1045 #[test]
1046 fn escaped_opener_one_byte_chunks_stays_literal() {
1047 let mut env = HashMap::new();
1048 env.insert("NAME".into(), "World".into());
1049 let out = render_split(b"\\{{ env:NAME }}", 1, &env).unwrap();
1052 assert_eq!(out, "{{ env:NAME }}");
1053 }
1054
1055 #[test]
1056 fn escaped_opener_two_byte_chunks_stays_literal() {
1057 let mut env = HashMap::new();
1058 env.insert("NAME".into(), "World".into());
1059 let out = render_split(b"a\\{{ env:NAME }}b", 2, &env).unwrap();
1061 assert_eq!(out, "a{{ env:NAME }}b");
1062 }
1063
1064 #[test]
1065 fn escaped_opener_split_variants_stay_literal() {
1066 let mut env = HashMap::new();
1067 env.insert("NAME".into(), "World".into());
1068 for chunks in [
1069 vec![b"\\".as_slice(), b"{", b"{ env:NAME }}"],
1070 vec![b"\\{".as_slice(), b"{ env:NAME }}"],
1071 vec![b"\\{{ env:NAME ".as_slice(), b"}}"],
1072 ] {
1073 let mut expander = StreamingExpand::new(&[], &env);
1074 let mut out = Vec::new();
1075 for chunk in chunks {
1076 expander.process_bytes(chunk, &mut out).unwrap();
1077 }
1078 expander.flush(&mut out).unwrap();
1079 assert_eq!(
1080 String::from_utf8_lossy(&out),
1081 "{{ env:NAME }}",
1082 "chunks must not corrupt the escape"
1083 );
1084 }
1085 }
1086
1087 #[test]
1088 fn plain_opener_split_chunks_still_expands() {
1089 let mut env = HashMap::new();
1090 env.insert("NAME".into(), "World".into());
1091 let out = render_split(b"{{ env:NAME }}", 1, &env).unwrap();
1094 assert_eq!(out, "World");
1095 }
1096
1097 #[test]
1098 fn double_backslash_split_chunks_still_expands() {
1099 let mut env = HashMap::new();
1100 env.insert("NAME".into(), "World".into());
1101 let out = render_split(b"\\\\{{ env:NAME }}", 1, &env).unwrap();
1103 assert_eq!(out, "\\World");
1104 }
1105
1106 #[test]
1107 fn trailing_escape_brace_flushes_literal() {
1108 let env = HashMap::new();
1109 let expander = StreamingExpand::new(&[], &env);
1110 let result = expander.expand_string("end\\{").unwrap();
1111 assert_eq!(result, "end\\{");
1112 }
1113
1114 #[test]
1120 fn substituted_override_value_is_not_rescanned() {
1121 let mut env = HashMap::new();
1122 env.insert("OTHER".into(), "world".into());
1123 let overrides = vec![("NAME".into(), "{{ env:OTHER }}".into())];
1124 let expander = StreamingExpand::new(&overrides, &env);
1125 let result = expander.expand_string("Hello {{ NAME }}").unwrap();
1126 assert_eq!(result, "Hello {{ env:OTHER }}");
1127 }
1128
1129 #[test]
1130 fn substituted_env_value_is_not_rescanned() {
1131 let mut env = HashMap::new();
1132 env.insert("NAME".into(), "{{ env:OTHER }}".into());
1133 env.insert("OTHER".into(), "world".into());
1134 let expander = StreamingExpand::new(&[], &env);
1135 let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
1136 assert_eq!(result, "Hello {{ env:OTHER }}");
1137 }
1138
1139 #[test]
1140 fn substituted_script_var_value_is_not_rescanned() {
1141 let mut vars = HashMap::new();
1142 vars.insert(
1143 "inner".into(),
1144 oxdock_parser::Value::string("{{ env:OTHER }}".into()),
1145 );
1146 let mut env = HashMap::new();
1147 env.insert("OTHER".into(), "world".into());
1148 let expander = StreamingExpand::new(&[], &env).with_vars(&vars);
1149 let result = expander.expand_string("Hello {{ $inner }}").unwrap();
1150 assert_eq!(result, "Hello {{ env:OTHER }}");
1151 }
1152}