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 match current {
398 oxdock_parser::Value::Map(map) => {
399 current = map.get(segment).ok_or_else(|| {
400 anyhow!("property '{segment}' not found on object '${root_key}'")
401 })?;
402 }
403 oxdock_parser::Value::List(list) => {
404 let idx: usize = segment.parse().map_err(|_| {
405 anyhow!("invalid array index '{segment}' on list '${root_key}'")
406 })?;
407 current = list.get(idx).ok_or_else(|| {
408 anyhow!(
409 "index {idx} out of bounds for list '${root_key}' (len: {})",
410 list.len()
411 )
412 })?;
413 }
414 _ => bail!("cannot access property '{segment}' on primitive value of '${root_key}'"),
415 }
416 }
417
418 Ok(format_value_for_string(current))
419}
420
421fn format_value_for_string(val: &oxdock_parser::Value) -> String {
423 match val {
424 oxdock_parser::Value::String(s) => s.clone(),
425 oxdock_parser::Value::Int(i) => i.to_string(),
426 oxdock_parser::Value::Bool(b) => b.to_string(),
427 oxdock_parser::Value::List(items) => items
428 .iter()
429 .map(format_value_for_string)
430 .collect::<Vec<_>>()
431 .join(" "),
432 oxdock_parser::Value::Map(map) => map
433 .iter()
434 .map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
435 .collect::<Vec<_>>()
436 .join(", "),
437 oxdock_parser::Value::TaskHandle(id) => format!("task#{}", id),
438 }
439}
440
441pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
444where
445 F: FnMut(&str) -> Option<String>,
446{
447 let mut out = String::with_capacity(input.len());
448 let mut chars = input.chars().peekable();
449 while let Some(c) = chars.next() {
450 if c == '{' {
451 if let Some(&'{') = chars.peek() {
452 chars.next(); let mut content = String::new();
454 let mut closed = false;
455 let mut inner_chars = chars.clone();
457 while let Some(ch) = inner_chars.next() {
458 if ch == '}'
459 && let Some(&'}') = inner_chars.peek()
460 {
461 closed = true;
462 break;
463 }
464 content.push(ch);
465 }
466
467 if closed {
468 for _ in 0..content.chars().count() {
472 chars.next();
473 }
474 chars.next(); chars.next(); let key = content.trim();
478 if !key.is_empty() {
479 out.push_str(&lookup_fn(key).unwrap_or_default());
480 }
481 } else {
482 out.push('{');
483 out.push('{');
484 }
485 } else {
486 out.push('{');
487 }
488 } else {
489 out.push(c);
490 }
491 }
492 out
493}
494
495pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
496 expand_with_lookup(input, |name| {
497 if let Some(key) = name.strip_prefix("env:") {
498 script_envs
499 .get(key)
500 .cloned()
501 .or_else(|| std::env::var(key).ok())
502 } else {
503 None
504 }
505 })
506}
507
508pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
509 expand_with_lookup(input, |name| {
510 if let Some(key) = name.strip_prefix("env:") {
511 ctx.envs().get(key).cloned()
512 } else {
513 None
514 }
515 })
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use std::collections::HashMap;
522
523 #[test]
524 fn basic_expansion() {
525 let mut env = HashMap::new();
526 env.insert("NAME".into(), "World".into());
527 let expander = StreamingExpand::new(&[], &env);
528 let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
529 assert_eq!(result, "Hello World");
530 }
531
532 #[test]
533 fn multiple_vars() {
534 let mut env = HashMap::new();
535 env.insert("A".into(), "X".into());
536 env.insert("B".into(), "Y".into());
537 let expander = StreamingExpand::new(&[], &env);
538 let result = expander
539 .expand_string("{{ env:A }} and {{ env:B }}")
540 .unwrap();
541 assert_eq!(result, "X and Y");
542 }
543
544 #[test]
545 fn missing_var() {
546 let env = HashMap::new();
547 let expander = StreamingExpand::new(&[], &env);
548 let result = expander.expand_string("{{ env:MISSING }}");
549 assert!(result.is_err());
550 assert!(
551 result
552 .unwrap_err()
553 .to_string()
554 .contains("undefined environment variable"),
555 "error should mention undefined environment variable"
556 );
557 }
558
559 #[test]
560 fn no_placeholders() {
561 let env = HashMap::new();
562 let expander = StreamingExpand::new(&[], &env);
563 let result = expander.expand_string("plain text").unwrap();
564 assert_eq!(result, "plain text");
565 }
566
567 #[test]
568 fn empty_input() {
569 let env = HashMap::new();
570 let expander = StreamingExpand::new(&[], &env);
571 let result = expander.expand_string("").unwrap();
572 assert_eq!(result, "");
573 }
574
575 #[test]
576 fn override_precedence() {
577 let mut env = HashMap::new();
578 env.insert("KEY".into(), "envval".into());
579 let overrides = vec![("KEY".into(), "override".into())];
580 let expander = StreamingExpand::new(&overrides, &env);
581 let result = expander.expand_string("{{ env:KEY }}").unwrap();
582 assert_eq!(result, "override");
583 }
584
585 #[test]
586 fn override_with_namespace() {
587 let mut env = HashMap::new();
588 env.insert("CRATE".into(), "envval".into());
589 let overrides = vec![("CRATE".into(), "override".into())];
590 let expander = StreamingExpand::new(&overrides, &env);
591 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
592 assert_eq!(result, "override");
593 }
594
595 #[test]
596 fn override_raw_key_match() {
597 let mut env = HashMap::new();
598 env.insert("CRATE".into(), "envval".into());
599 let overrides = vec![("env:CRATE".into(), "override".into())];
600 let expander = StreamingExpand::new(&overrides, &env);
601 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
602 assert_eq!(result, "override");
603 }
604
605 #[test]
606 fn unclosed_placeholder() {
607 let env = HashMap::new();
608 let expander = StreamingExpand::new(&[], &env);
609 let result = expander.expand_string("{{ env:KEY").unwrap();
610 assert_eq!(result, "{{ env:KEY");
611 }
612
613 #[test]
614 fn unclosed_with_prefix() {
615 let env = HashMap::new();
616 let expander = StreamingExpand::new(&[], &env);
617 let result = expander.expand_string("Hello {{ env:KEY").unwrap();
618 assert_eq!(result, "Hello {{ env:KEY");
619 }
620
621 #[test]
622 fn buffer_limit_exceeded() {
623 let env = HashMap::new();
624 let expander = StreamingExpand::new(&[], &env);
625 let mut input = b"{{ ".to_vec();
627 input.extend(std::iter::repeat_n(b'x', 2000));
628 let result = expander
629 .expand_string(&String::from_utf8_lossy(&input))
630 .unwrap();
631 assert!(result.starts_with("{{ "));
633 assert!(result.len() > 1024);
634 }
635
636 #[test]
637 fn escaped_opener_emits_literal() {
638 let mut env = HashMap::new();
639 env.insert("PROJECT".into(), "OxDock".into());
640 let expander = StreamingExpand::new(&[], &env);
641 let result = expander
642 .expand_string("Built with \\{{ env:PROJECT }}")
643 .unwrap();
644 assert_eq!(result, "Built with {{ env:PROJECT }}");
645 }
646
647 #[test]
648 fn escaped_opener_across_chunks() {
649 let mut env = HashMap::new();
650 env.insert("PROJECT".into(), "OxDock".into());
651 let mut expander = StreamingExpand::new(&[], &env);
652 let mut out = Vec::new();
653
654 expander.process_bytes(b"Built with \\", &mut out).unwrap();
656 expander
657 .process_bytes(b"{{ env:PROJECT }}", &mut out)
658 .unwrap();
659 expander.flush(&mut out).unwrap();
660
661 assert_eq!(
662 String::from_utf8_lossy(&out),
663 "Built with {{ env:PROJECT }}"
664 );
665 }
666
667 #[test]
668 fn double_backslash_then_placeholder_expands() {
669 let mut env = HashMap::new();
670 env.insert("PROJECT".into(), "OxDock".into());
671 let expander = StreamingExpand::new(&[], &env);
672 let result = expander.expand_string("\\\\{{ env:PROJECT }}").unwrap();
673 assert_eq!(result, "\\OxDock");
674 }
675
676 #[test]
677 fn trailing_backslash_flushes_literal() {
678 let env = HashMap::new();
679 let expander = StreamingExpand::new(&[], &env);
680 let result = expander.expand_string("end\\").unwrap();
681 assert_eq!(result, "end\\");
682 }
683
684 #[test]
685 fn backslash_before_other_text_is_literal() {
686 let env = HashMap::new();
687 let expander = StreamingExpand::new(&[], &env);
688 let result = expander.expand_string("a\\b \\{ once }").unwrap();
689 assert_eq!(result, "a\\b \\{ once }");
690 }
691
692 #[test]
693 fn escaped_pair_split_across_chunks() {
694 let mut env = HashMap::new();
695 env.insert("PROJECT".into(), "OxDock".into());
696 let mut expander = StreamingExpand::new(&[], &env);
697 let mut out = Vec::new();
698
699 expander.process_bytes(b"\\", &mut out).unwrap();
702 expander
703 .process_bytes(b"\\{{ env:PROJECT }}", &mut out)
704 .unwrap();
705 expander.flush(&mut out).unwrap();
706
707 assert_eq!(String::from_utf8_lossy(&out), "\\OxDock");
708 }
709
710 #[test]
711 fn partial_across_chunks() {
712 let mut env = HashMap::new();
713 env.insert("NAME".into(), "World".into());
714 let mut expander = StreamingExpand::new(&[], &env);
715 let mut out = Vec::new();
716
717 expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
719 expander.process_bytes(b"ME }}", &mut out).unwrap();
720 expander.flush(&mut out).unwrap();
721
722 assert_eq!(String::from_utf8_lossy(&out), "World");
723 }
724
725 #[test]
726 fn trailing_brace_across_chunks() {
727 let mut env = HashMap::new();
728 env.insert("NAME".into(), "World".into());
729 let mut expander = StreamingExpand::new(&[], &env);
730 let mut out = Vec::new();
731
732 expander.process_bytes(b"...", &mut out).unwrap();
734 expander.process_bytes(b"{", &mut out).unwrap();
735 expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
736 expander.flush(&mut out).unwrap();
737
738 assert_eq!(String::from_utf8_lossy(&out), "...World");
739 }
740
741 #[test]
742 fn trailing_brace_at_eof() {
743 let env = HashMap::new();
744 let mut expander = StreamingExpand::new(&[], &env);
745 let mut out = Vec::new();
746
747 expander.process_bytes(b"hello{", &mut out).unwrap();
748 expander.flush(&mut out).unwrap();
749
750 assert_eq!(String::from_utf8_lossy(&out), "hello{");
751 }
752
753 #[test]
754 fn immediate_flush_guarantee() {
755 let env = HashMap::new();
756 let mut expander = StreamingExpand::new(&[], &env);
757 let mut out = Vec::new();
758
759 let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
761 expander.process_bytes(&input, &mut out).unwrap();
762 expander.flush(&mut out).unwrap();
763
764 assert_eq!(out.len(), 1024 * 1024);
765 }
766
767 #[test]
768 fn nested_braces() {
769 let mut env = HashMap::new();
770 env.insert("KEY{1}".into(), "val".into());
771 let expander = StreamingExpand::new(&[], &env);
772 let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
773 assert_eq!(result, "val");
774 }
775
776 #[test]
777 fn split_close_delimiter_across_chunks() {
778 let mut env = HashMap::new();
779 env.insert("NAME".into(), "World".into());
780 let mut expander = StreamingExpand::new(&[], &env);
781 let mut out = Vec::new();
782
783 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
785 expander.process_bytes(b"}}", &mut out).unwrap();
786 expander.flush(&mut out).unwrap();
787
788 assert_eq!(String::from_utf8_lossy(&out), "World");
789 }
790
791 #[test]
792 fn split_close_delimiter_with_trailing_content() {
793 let mut env = HashMap::new();
794 env.insert("NAME".into(), "World".into());
795 let mut expander = StreamingExpand::new(&[], &env);
796 let mut out = Vec::new();
797
798 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
800 expander.process_bytes(b"}} rest", &mut out).unwrap();
801 expander.flush(&mut out).unwrap();
802
803 assert_eq!(String::from_utf8_lossy(&out), "World rest");
804 }
805
806 #[test]
807 fn empty_input_preserves_pending_state() {
808 let mut env = HashMap::new();
809 env.insert("NAME".into(), "World".into());
810 let mut expander = StreamingExpand::new(&[], &env);
811 let mut out = Vec::new();
812
813 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
815 expander.process_bytes(b"", &mut out).unwrap(); expander.process_bytes(b"}}", &mut out).unwrap();
817 expander.flush(&mut out).unwrap();
818
819 assert_eq!(String::from_utf8_lossy(&out), "World");
820 }
821
822 #[test]
823 fn missing_env_var_errors() {
824 let env = HashMap::new();
825 let expander = StreamingExpand::new(&[], &env);
826 let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
827 assert!(result.is_err());
828 let msg = result.unwrap_err().to_string();
829 assert!(msg.contains("undefined environment variable"), "got: {msg}");
830 assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
831 }
832
833 #[test]
834 fn missing_script_var_errors() {
835 let env = HashMap::new();
836 let expander = StreamingExpand::new(&[], &env);
837 let result = expander.expand_string("{{ $undefined_var }}");
838 assert!(result.is_err());
839 let msg = result.unwrap_err().to_string();
840 assert!(msg.contains("undefined script variable"), "got: {msg}");
841 assert!(msg.contains("$undefined_var"), "got: {msg}");
842 }
843
844 #[test]
845 fn missing_key_in_map_errors() {
846 let mut vars = HashMap::new();
847 vars.insert(
848 "cfg".into(),
849 oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
850 "server".into(),
851 oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
852 "port".into(),
853 oxdock_parser::Value::Int(8080),
854 )])),
855 )])),
856 );
857 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
858 let result = expander.expand_string("{{ $cfg.missing_key }}");
859 assert!(result.is_err());
860 let msg = result.unwrap_err().to_string();
861 assert!(
862 msg.contains("property 'missing_key' not found"),
863 "got: {msg}"
864 );
865 }
866
867 #[test]
868 fn out_of_bounds_array_index_errors() {
869 let mut vars = HashMap::new();
870 vars.insert(
871 "arr".into(),
872 oxdock_parser::Value::List(vec![oxdock_parser::Value::String("a".into())]),
873 );
874 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
875 let result = expander.expand_string("{{ $arr.5 }}");
876 assert!(result.is_err());
877 let msg = result.unwrap_err().to_string();
878 assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
879 }
880
881 #[test]
882 fn type_mismatch_navigation_errors() {
883 let mut vars = HashMap::new();
884 vars.insert("name".into(), oxdock_parser::Value::String("alice".into()));
885 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
886 let result = expander.expand_string("{{ $name.sub_field }}");
887 assert!(result.is_err());
888 let msg = result.unwrap_err().to_string();
889 assert!(
890 msg.contains("cannot access property 'sub_field' on primitive"),
891 "got: {msg}"
892 );
893 }
894
895 #[test]
896 fn unprefixed_identifier_errors() {
897 let env = HashMap::new();
898 let expander = StreamingExpand::new(&[], &env);
899 let result = expander.expand_string("{{ bare_word }}");
900 assert!(result.is_err());
901 let msg = result.unwrap_err().to_string();
902 assert!(msg.contains("missing required step override"), "got: {msg}");
903 }
904
905 #[test]
908 fn script_var_does_not_fall_back_to_env() {
909 let mut env = HashMap::new();
910 env.insert("WHO".into(), "from-env".into());
911 let expander = StreamingExpand::new(&[], &env);
912 let result = expander.expand_string("{{ $WHO }}");
914 assert!(result.is_err());
915 let msg = result.unwrap_err().to_string();
916 assert!(msg.contains("undefined script variable"), "got: {msg}");
917 assert!(
918 msg.contains("did you mean 'env:WHO'"),
919 "hint should suggest env: prefix, got: {msg}"
920 );
921 }
922
923 #[test]
924 fn env_var_does_not_fall_back_to_vars() {
925 let mut vars = HashMap::new();
926 vars.insert(
927 "HOST".into(),
928 oxdock_parser::Value::String("from-var".into()),
929 );
930 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
931 let result = expander.expand_string("{{ env:HOST }}");
933 assert!(result.is_err());
934 let msg = result.unwrap_err().to_string();
935 assert!(msg.contains("undefined environment variable"), "got: {msg}");
936 assert!(
937 msg.contains("did you mean '$HOST'"),
938 "hint should suggest $ prefix, got: {msg}"
939 );
940 }
941
942 #[test]
943 fn step_override_does_not_fall_back_to_vars() {
944 let mut vars = HashMap::new();
945 vars.insert("PORT".into(), oxdock_parser::Value::Int(8080));
946 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
947 let result = expander.expand_string("{{ PORT }}");
949 assert!(result.is_err());
950 let msg = result.unwrap_err().to_string();
951 assert!(msg.contains("missing required step override"), "got: {msg}");
952 assert!(
953 msg.contains("did you mean '$PORT'"),
954 "hint should suggest $ prefix, got: {msg}"
955 );
956 }
957
958 #[test]
959 fn env_prefix_isolated_from_script_vars() {
960 let mut vars = HashMap::new();
961 vars.insert("MODE".into(), oxdock_parser::Value::String("dev".into()));
962 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
963 let result = expander.expand_string("{{ env:MODE }}");
965 assert!(result.is_err());
966 let msg = result.unwrap_err().to_string();
967 assert!(msg.contains("undefined environment variable"), "got: {msg}");
968 assert!(
969 msg.contains("did you mean '$MODE'"),
970 "hint should suggest $ prefix, got: {msg}"
971 );
972 }
973
974 #[test]
975 fn dollar_prefix_isolated_from_env() {
976 let mut env = HashMap::new();
977 env.insert("PORT".into(), "3000".into());
978 let expander = StreamingExpand::new(&[], &env);
979 let result = expander.expand_string("{{ $PORT }}");
981 assert!(result.is_err());
982 let msg = result.unwrap_err().to_string();
983 assert!(msg.contains("undefined script variable"), "got: {msg}");
984 assert!(
985 msg.contains("did you mean 'env:PORT'"),
986 "hint should suggest env: prefix, got: {msg}"
987 );
988 }
989
990 #[test]
991 fn empty_placeholder_errors() {
992 let expander = StreamingExpand::new(&[], &HashMap::new());
993 let result = expander.expand_string("{{ }}");
994 assert!(result.is_err());
995 let msg = result.unwrap_err().to_string();
996 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
997 }
998
999 #[test]
1000 fn malformed_symbol_placeholder_errors() {
1001 let expander = StreamingExpand::new(&[], &HashMap::new());
1002 let result = expander.expand_string("{{ @invalid! }}");
1003 assert!(result.is_err());
1004 let msg = result.unwrap_err().to_string();
1005 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
1006 }
1007
1008 fn render_split(
1011 input: &[u8],
1012 size: usize,
1013 env: &HashMap<String, String>,
1014 ) -> Result<String, anyhow::Error> {
1015 let mut expander = StreamingExpand::new(&[], env);
1016 let mut out = Vec::new();
1017 for chunk in input.chunks(size) {
1018 expander.process_bytes(chunk, &mut out)?;
1019 }
1020 expander.flush(&mut out)?;
1021 Ok(String::from_utf8_lossy(&out).into_owned())
1022 }
1023
1024 #[test]
1025 fn escaped_opener_one_byte_chunks_stays_literal() {
1026 let mut env = HashMap::new();
1027 env.insert("NAME".into(), "World".into());
1028 let out = render_split(b"\\{{ env:NAME }}", 1, &env).unwrap();
1031 assert_eq!(out, "{{ env:NAME }}");
1032 }
1033
1034 #[test]
1035 fn escaped_opener_two_byte_chunks_stays_literal() {
1036 let mut env = HashMap::new();
1037 env.insert("NAME".into(), "World".into());
1038 let out = render_split(b"a\\{{ env:NAME }}b", 2, &env).unwrap();
1040 assert_eq!(out, "a{{ env:NAME }}b");
1041 }
1042
1043 #[test]
1044 fn escaped_opener_split_variants_stay_literal() {
1045 let mut env = HashMap::new();
1046 env.insert("NAME".into(), "World".into());
1047 for chunks in [
1048 vec![b"\\".as_slice(), b"{", b"{ env:NAME }}"],
1049 vec![b"\\{".as_slice(), b"{ env:NAME }}"],
1050 vec![b"\\{{ env:NAME ".as_slice(), b"}}"],
1051 ] {
1052 let mut expander = StreamingExpand::new(&[], &env);
1053 let mut out = Vec::new();
1054 for chunk in chunks {
1055 expander.process_bytes(chunk, &mut out).unwrap();
1056 }
1057 expander.flush(&mut out).unwrap();
1058 assert_eq!(
1059 String::from_utf8_lossy(&out),
1060 "{{ env:NAME }}",
1061 "chunks must not corrupt the escape"
1062 );
1063 }
1064 }
1065
1066 #[test]
1067 fn plain_opener_split_chunks_still_expands() {
1068 let mut env = HashMap::new();
1069 env.insert("NAME".into(), "World".into());
1070 let out = render_split(b"{{ env:NAME }}", 1, &env).unwrap();
1073 assert_eq!(out, "World");
1074 }
1075
1076 #[test]
1077 fn double_backslash_split_chunks_still_expands() {
1078 let mut env = HashMap::new();
1079 env.insert("NAME".into(), "World".into());
1080 let out = render_split(b"\\\\{{ env:NAME }}", 1, &env).unwrap();
1082 assert_eq!(out, "\\World");
1083 }
1084
1085 #[test]
1086 fn trailing_escape_brace_flushes_literal() {
1087 let env = HashMap::new();
1088 let expander = StreamingExpand::new(&[], &env);
1089 let result = expander.expand_string("end\\{").unwrap();
1090 assert_eq!(result, "end\\{");
1091 }
1092}