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 delimiters: TemplateDelimiters,
48}
49
50impl StreamingExpand {
51 pub fn new(overrides: &[(String, String)], env: &HashMap<String, String>) -> Self {
54 Self {
55 buffer: Vec::with_capacity(256),
56 overrides: overrides.iter().cloned().collect(),
57 env: env.clone(),
58 vars: HashMap::new(),
59 in_placeholder: false,
60 pending_brace: false,
61 pending_close_brace: false,
62 delimiters: TemplateDelimiters::default(),
63 }
64 }
65
66 pub fn with_vars(mut self, vars: &HashMap<String, oxdock_parser::Value>) -> Self {
69 self.vars = vars.clone();
70 self
71 }
72
73 pub fn process_bytes(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<usize> {
76 if input.is_empty() {
77 return Ok(0);
78 }
79
80 let start_len = out.len();
81 let mut i = 0;
82
83 if self.pending_close_brace {
85 self.pending_close_brace = false;
86 if input[0] == self.delimiters.close[1] {
87 let key = extract_key(&self.buffer);
89 let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
90 out.extend_from_slice(value.as_bytes());
91 self.buffer.clear();
92 self.in_placeholder = false;
93 i = self.delimiters.close.len() - 1; } else {
95 self.buffer.push(self.delimiters.close[0]);
98 i = 0; }
100 }
101
102 if self.pending_brace {
104 self.pending_brace = false;
105 if input[0] == self.delimiters.open[1] {
106 self.in_placeholder = true;
108 self.buffer.clear();
109 i = 1; } else {
111 out.push(self.delimiters.open[0]);
113 }
114 }
115
116 if self.in_placeholder {
117 i = self.scan_placeholder(input, i, out)?;
119 }
120
121 while i < input.len() {
123 if input[i] == self.delimiters.open[0] {
124 if i + 1 < input.len() && input[i + 1] == self.delimiters.open[1] {
125 self.in_placeholder = true;
127 self.buffer.clear();
128 i += 2;
129 i = self.scan_placeholder(input, i, out)?;
130 } else if i + 1 == input.len() {
131 self.pending_brace = true;
133 i += 1;
134 } else {
135 out.push(self.delimiters.open[0]);
137 i += 1;
138 }
139 } else {
140 let start = i;
142 while i < input.len() && input[i] != self.delimiters.open[0] {
143 i += 1;
144 }
145 out.extend_from_slice(&input[start..i]);
146 }
147 }
148
149 Ok(out.len() - start_len)
150 }
151
152 pub fn flush(mut self, out: &mut Vec<u8>) -> Result<()> {
154 if self.pending_close_brace {
156 self.buffer.push(self.delimiters.close[0]);
157 self.pending_close_brace = false;
158 }
159 if self.pending_brace {
161 out.push(self.delimiters.open[0]);
162 self.pending_brace = false;
163 }
164 if self.in_placeholder {
166 out.extend_from_slice(self.delimiters.open);
167 self.in_placeholder = false;
168 }
169 out.extend_from_slice(&self.buffer);
171 self.buffer.clear();
172 Ok(())
173 }
174
175 pub fn expand_string(self, input: &str) -> Result<String> {
177 let mut out = Vec::with_capacity(input.len());
178 let mut expander = self;
179 expander.process_bytes(input.as_bytes(), &mut out)?;
180 expander.flush(&mut out)?;
181 Ok(String::from_utf8(out).unwrap_or_default())
182 }
183
184 fn scan_placeholder(&mut self, input: &[u8], mut i: usize, out: &mut Vec<u8>) -> Result<usize> {
187 while i < input.len() {
188 if input[i] == self.delimiters.close[0] {
189 if i + 1 < input.len() && input[i + 1] == self.delimiters.close[1] {
190 let key = extract_key(&self.buffer);
192 let value = lookup(&key, &self.overrides, &self.env, &self.vars)?;
193 out.extend_from_slice(value.as_bytes());
194 self.buffer.clear();
195 self.in_placeholder = false;
196 return Ok(i + 2);
197 }
198 if i + 1 == input.len() {
199 self.pending_close_brace = true;
201 return Ok(i + 1);
202 }
203 }
204 self.buffer.push(input[i]);
205 i += 1;
206
207 if self.buffer.len() > MAX_PLACEHOLDER_SCAN {
209 out.extend_from_slice(self.delimiters.open);
210 out.extend_from_slice(&self.buffer);
211 self.buffer.clear();
212 self.in_placeholder = false;
213 return Ok(i);
214 }
215 }
216 Ok(i)
217 }
218}
219
220fn extract_key(buffer: &[u8]) -> String {
222 String::from_utf8_lossy(buffer).trim().to_string()
223}
224
225fn lookup(
235 raw_key: &str,
236 overrides: &HashMap<String, String>,
237 env: &HashMap<String, String>,
238 vars: &HashMap<String, oxdock_parser::Value>,
239) -> Result<String> {
240 let key = raw_key.trim();
241
242 if let Some(val) = overrides.get(key) {
244 return Ok(val.clone());
245 }
246
247 if let Some(env_key) = key.strip_prefix("env:") {
249 if let Some(val) = overrides.get(env_key).or_else(|| env.get(env_key)) {
250 return Ok(val.clone());
251 }
252 let hint = if vars.contains_key(env_key) {
253 format!("; did you mean '${env_key}' (script variable)?")
254 } else {
255 String::new()
256 };
257 bail!("undefined environment variable: '{env_key}'{hint}");
258 }
259
260 if let Some(var_key) = key.strip_prefix('$') {
262 if var_key.contains('.') {
263 let parts: Vec<&str> = var_key.split('.').collect();
264 return resolve_key_path_strict(&parts, vars);
265 }
266 if let Some(val) = vars.get(var_key) {
267 return Ok(format_value_for_string(val));
268 }
269 let hint = if env.contains_key(var_key) {
270 format!("; did you mean 'env:{var_key}' (environment variable)?")
271 } else if overrides.contains_key(var_key) {
272 format!("; did you mean '{var_key}' (step override)?")
273 } else {
274 String::new()
275 };
276 bail!("undefined script variable: '${var_key}'{hint}");
277 }
278
279 if !key.is_empty()
281 && key
282 .chars()
283 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
284 {
285 let hint = if vars.contains_key(key) {
286 format!("; did you mean '${key}' (script variable)?")
287 } else if env.contains_key(key) {
288 format!("; did you mean 'env:{key}' (environment variable)?")
289 } else {
290 String::new()
291 };
292 bail!("missing required step override argument: '{key}'{hint}");
293 }
294 bail!(
295 "invalid placeholder format '{key}': script variables must start with '$' and environment variables with 'env:'"
296 );
297}
298
299fn resolve_key_path_strict(
304 parts: &[&str],
305 vars: &HashMap<String, oxdock_parser::Value>,
306) -> Result<String> {
307 let root_key = parts[0];
308 let mut current = vars
309 .get(root_key)
310 .ok_or_else(|| anyhow!("undefined script variable: '${root_key}'"))?;
311
312 for &segment in &parts[1..] {
313 match current {
314 oxdock_parser::Value::Map(map) => {
315 current = map.get(segment).ok_or_else(|| {
316 anyhow!("property '{segment}' not found on object '${root_key}'")
317 })?;
318 }
319 oxdock_parser::Value::List(list) => {
320 let idx: usize = segment.parse().map_err(|_| {
321 anyhow!("invalid array index '{segment}' on list '${root_key}'")
322 })?;
323 current = list.get(idx).ok_or_else(|| {
324 anyhow!(
325 "index {idx} out of bounds for list '${root_key}' (len: {})",
326 list.len()
327 )
328 })?;
329 }
330 _ => bail!("cannot access property '{segment}' on primitive value of '${root_key}'"),
331 }
332 }
333
334 Ok(format_value_for_string(current))
335}
336
337fn format_value_for_string(val: &oxdock_parser::Value) -> String {
339 match val {
340 oxdock_parser::Value::String(s) => s.clone(),
341 oxdock_parser::Value::Int(i) => i.to_string(),
342 oxdock_parser::Value::Bool(b) => b.to_string(),
343 oxdock_parser::Value::List(items) => items
344 .iter()
345 .map(format_value_for_string)
346 .collect::<Vec<_>>()
347 .join(" "),
348 oxdock_parser::Value::Map(map) => map
349 .iter()
350 .map(|(k, v)| format!("\"{}\": {}", k, format_value_for_string(v)))
351 .collect::<Vec<_>>()
352 .join(", "),
353 oxdock_parser::Value::TaskHandle(id) => format!("task#{}", id),
354 }
355}
356
357pub(crate) fn expand_with_lookup<F>(input: &str, mut lookup_fn: F) -> String
360where
361 F: FnMut(&str) -> Option<String>,
362{
363 let mut out = String::with_capacity(input.len());
364 let mut chars = input.chars().peekable();
365 while let Some(c) = chars.next() {
366 if c == '{' {
367 if let Some(&'{') = chars.peek() {
368 chars.next(); let mut content = String::new();
370 let mut closed = false;
371 let mut inner_chars = chars.clone();
373 while let Some(ch) = inner_chars.next() {
374 if ch == '}'
375 && let Some(&'}') = inner_chars.peek()
376 {
377 closed = true;
378 break;
379 }
380 content.push(ch);
381 }
382
383 if closed {
384 for _ in 0..content.chars().count() {
388 chars.next();
389 }
390 chars.next(); chars.next(); let key = content.trim();
394 if !key.is_empty() {
395 out.push_str(&lookup_fn(key).unwrap_or_default());
396 }
397 } else {
398 out.push('{');
399 out.push('{');
400 }
401 } else {
402 out.push('{');
403 }
404 } else {
405 out.push(c);
406 }
407 }
408 out
409}
410
411pub fn expand_script_env(input: &str, script_envs: &HashMap<String, String>) -> String {
412 expand_with_lookup(input, |name| {
413 if let Some(key) = name.strip_prefix("env:") {
414 script_envs
415 .get(key)
416 .cloned()
417 .or_else(|| std::env::var(key).ok())
418 } else {
419 None
420 }
421 })
422}
423
424pub fn expand_command_env(input: &str, ctx: &CommandContext) -> String {
425 expand_with_lookup(input, |name| {
426 if let Some(key) = name.strip_prefix("env:") {
427 ctx.envs().get(key).cloned()
428 } else {
429 None
430 }
431 })
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use std::collections::HashMap;
438
439 #[test]
440 fn basic_expansion() {
441 let mut env = HashMap::new();
442 env.insert("NAME".into(), "World".into());
443 let expander = StreamingExpand::new(&[], &env);
444 let result = expander.expand_string("Hello {{ env:NAME }}").unwrap();
445 assert_eq!(result, "Hello World");
446 }
447
448 #[test]
449 fn multiple_vars() {
450 let mut env = HashMap::new();
451 env.insert("A".into(), "X".into());
452 env.insert("B".into(), "Y".into());
453 let expander = StreamingExpand::new(&[], &env);
454 let result = expander
455 .expand_string("{{ env:A }} and {{ env:B }}")
456 .unwrap();
457 assert_eq!(result, "X and Y");
458 }
459
460 #[test]
461 fn missing_var() {
462 let env = HashMap::new();
463 let expander = StreamingExpand::new(&[], &env);
464 let result = expander.expand_string("{{ env:MISSING }}");
465 assert!(result.is_err());
466 assert!(
467 result
468 .unwrap_err()
469 .to_string()
470 .contains("undefined environment variable"),
471 "error should mention undefined environment variable"
472 );
473 }
474
475 #[test]
476 fn no_placeholders() {
477 let env = HashMap::new();
478 let expander = StreamingExpand::new(&[], &env);
479 let result = expander.expand_string("plain text").unwrap();
480 assert_eq!(result, "plain text");
481 }
482
483 #[test]
484 fn empty_input() {
485 let env = HashMap::new();
486 let expander = StreamingExpand::new(&[], &env);
487 let result = expander.expand_string("").unwrap();
488 assert_eq!(result, "");
489 }
490
491 #[test]
492 fn override_precedence() {
493 let mut env = HashMap::new();
494 env.insert("KEY".into(), "envval".into());
495 let overrides = vec![("KEY".into(), "override".into())];
496 let expander = StreamingExpand::new(&overrides, &env);
497 let result = expander.expand_string("{{ env:KEY }}").unwrap();
498 assert_eq!(result, "override");
499 }
500
501 #[test]
502 fn override_with_namespace() {
503 let mut env = HashMap::new();
504 env.insert("CRATE".into(), "envval".into());
505 let overrides = vec![("CRATE".into(), "override".into())];
506 let expander = StreamingExpand::new(&overrides, &env);
507 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
508 assert_eq!(result, "override");
509 }
510
511 #[test]
512 fn override_raw_key_match() {
513 let mut env = HashMap::new();
514 env.insert("CRATE".into(), "envval".into());
515 let overrides = vec![("env:CRATE".into(), "override".into())];
516 let expander = StreamingExpand::new(&overrides, &env);
517 let result = expander.expand_string("{{ env:CRATE }}").unwrap();
518 assert_eq!(result, "override");
519 }
520
521 #[test]
522 fn unclosed_placeholder() {
523 let env = HashMap::new();
524 let expander = StreamingExpand::new(&[], &env);
525 let result = expander.expand_string("{{ env:KEY").unwrap();
526 assert_eq!(result, "{{ env:KEY");
527 }
528
529 #[test]
530 fn unclosed_with_prefix() {
531 let env = HashMap::new();
532 let expander = StreamingExpand::new(&[], &env);
533 let result = expander.expand_string("Hello {{ env:KEY").unwrap();
534 assert_eq!(result, "Hello {{ env:KEY");
535 }
536
537 #[test]
538 fn buffer_limit_exceeded() {
539 let env = HashMap::new();
540 let expander = StreamingExpand::new(&[], &env);
541 let mut input = b"{{ ".to_vec();
543 input.extend(std::iter::repeat_n(b'x', 2000));
544 let result = expander
545 .expand_string(&String::from_utf8_lossy(&input))
546 .unwrap();
547 assert!(result.starts_with("{{ "));
549 assert!(result.len() > 1024);
550 }
551
552 #[test]
553 fn partial_across_chunks() {
554 let mut env = HashMap::new();
555 env.insert("NAME".into(), "World".into());
556 let mut expander = StreamingExpand::new(&[], &env);
557 let mut out = Vec::new();
558
559 expander.process_bytes(b"{{ env:NA", &mut out).unwrap();
561 expander.process_bytes(b"ME }}", &mut out).unwrap();
562 expander.flush(&mut out).unwrap();
563
564 assert_eq!(String::from_utf8_lossy(&out), "World");
565 }
566
567 #[test]
568 fn trailing_brace_across_chunks() {
569 let mut env = HashMap::new();
570 env.insert("NAME".into(), "World".into());
571 let mut expander = StreamingExpand::new(&[], &env);
572 let mut out = Vec::new();
573
574 expander.process_bytes(b"...", &mut out).unwrap();
576 expander.process_bytes(b"{", &mut out).unwrap();
577 expander.process_bytes(b"{env:NAME}}", &mut out).unwrap();
578 expander.flush(&mut out).unwrap();
579
580 assert_eq!(String::from_utf8_lossy(&out), "...World");
581 }
582
583 #[test]
584 fn trailing_brace_at_eof() {
585 let env = HashMap::new();
586 let mut expander = StreamingExpand::new(&[], &env);
587 let mut out = Vec::new();
588
589 expander.process_bytes(b"hello{", &mut out).unwrap();
590 expander.flush(&mut out).unwrap();
591
592 assert_eq!(String::from_utf8_lossy(&out), "hello{");
593 }
594
595 #[test]
596 fn immediate_flush_guarantee() {
597 let env = HashMap::new();
598 let mut expander = StreamingExpand::new(&[], &env);
599 let mut out = Vec::new();
600
601 let input = std::iter::repeat_n(b'x', 1024 * 1024).collect::<Vec<_>>();
603 expander.process_bytes(&input, &mut out).unwrap();
604 expander.flush(&mut out).unwrap();
605
606 assert_eq!(out.len(), 1024 * 1024);
607 }
608
609 #[test]
610 fn nested_braces() {
611 let mut env = HashMap::new();
612 env.insert("KEY{1}".into(), "val".into());
613 let expander = StreamingExpand::new(&[], &env);
614 let result = expander.expand_string("{{ env:KEY{1} }}").unwrap();
615 assert_eq!(result, "val");
616 }
617
618 #[test]
619 fn split_close_delimiter_across_chunks() {
620 let mut env = HashMap::new();
621 env.insert("NAME".into(), "World".into());
622 let mut expander = StreamingExpand::new(&[], &env);
623 let mut out = Vec::new();
624
625 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
627 expander.process_bytes(b"}}", &mut out).unwrap();
628 expander.flush(&mut out).unwrap();
629
630 assert_eq!(String::from_utf8_lossy(&out), "World");
631 }
632
633 #[test]
634 fn split_close_delimiter_with_trailing_content() {
635 let mut env = HashMap::new();
636 env.insert("NAME".into(), "World".into());
637 let mut expander = StreamingExpand::new(&[], &env);
638 let mut out = Vec::new();
639
640 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
642 expander.process_bytes(b"}} rest", &mut out).unwrap();
643 expander.flush(&mut out).unwrap();
644
645 assert_eq!(String::from_utf8_lossy(&out), "World rest");
646 }
647
648 #[test]
649 fn empty_input_preserves_pending_state() {
650 let mut env = HashMap::new();
651 env.insert("NAME".into(), "World".into());
652 let mut expander = StreamingExpand::new(&[], &env);
653 let mut out = Vec::new();
654
655 expander.process_bytes(b"{{ env:NAME", &mut out).unwrap();
657 expander.process_bytes(b"", &mut out).unwrap(); expander.process_bytes(b"}}", &mut out).unwrap();
659 expander.flush(&mut out).unwrap();
660
661 assert_eq!(String::from_utf8_lossy(&out), "World");
662 }
663
664 #[test]
665 fn missing_env_var_errors() {
666 let env = HashMap::new();
667 let expander = StreamingExpand::new(&[], &env);
668 let result = expander.expand_string("{{ env:UNDEFINED_VAR }}");
669 assert!(result.is_err());
670 let msg = result.unwrap_err().to_string();
671 assert!(msg.contains("undefined environment variable"), "got: {msg}");
672 assert!(msg.contains("UNDEFINED_VAR"), "got: {msg}");
673 }
674
675 #[test]
676 fn missing_script_var_errors() {
677 let env = HashMap::new();
678 let expander = StreamingExpand::new(&[], &env);
679 let result = expander.expand_string("{{ $undefined_var }}");
680 assert!(result.is_err());
681 let msg = result.unwrap_err().to_string();
682 assert!(msg.contains("undefined script variable"), "got: {msg}");
683 assert!(msg.contains("$undefined_var"), "got: {msg}");
684 }
685
686 #[test]
687 fn missing_key_in_map_errors() {
688 let mut vars = HashMap::new();
689 vars.insert(
690 "cfg".into(),
691 oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
692 "server".into(),
693 oxdock_parser::Value::Map(std::collections::BTreeMap::from([(
694 "port".into(),
695 oxdock_parser::Value::Int(8080),
696 )])),
697 )])),
698 );
699 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
700 let result = expander.expand_string("{{ $cfg.missing_key }}");
701 assert!(result.is_err());
702 let msg = result.unwrap_err().to_string();
703 assert!(
704 msg.contains("property 'missing_key' not found"),
705 "got: {msg}"
706 );
707 }
708
709 #[test]
710 fn out_of_bounds_array_index_errors() {
711 let mut vars = HashMap::new();
712 vars.insert(
713 "arr".into(),
714 oxdock_parser::Value::List(vec![oxdock_parser::Value::String("a".into())]),
715 );
716 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
717 let result = expander.expand_string("{{ $arr.5 }}");
718 assert!(result.is_err());
719 let msg = result.unwrap_err().to_string();
720 assert!(msg.contains("index 5 out of bounds"), "got: {msg}");
721 }
722
723 #[test]
724 fn type_mismatch_navigation_errors() {
725 let mut vars = HashMap::new();
726 vars.insert("name".into(), oxdock_parser::Value::String("alice".into()));
727 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
728 let result = expander.expand_string("{{ $name.sub_field }}");
729 assert!(result.is_err());
730 let msg = result.unwrap_err().to_string();
731 assert!(
732 msg.contains("cannot access property 'sub_field' on primitive"),
733 "got: {msg}"
734 );
735 }
736
737 #[test]
738 fn unprefixed_identifier_errors() {
739 let env = HashMap::new();
740 let expander = StreamingExpand::new(&[], &env);
741 let result = expander.expand_string("{{ bare_word }}");
742 assert!(result.is_err());
743 let msg = result.unwrap_err().to_string();
744 assert!(msg.contains("missing required step override"), "got: {msg}");
745 }
746
747 #[test]
750 fn script_var_does_not_fall_back_to_env() {
751 let mut env = HashMap::new();
752 env.insert("WHO".into(), "from-env".into());
753 let expander = StreamingExpand::new(&[], &env);
754 let result = expander.expand_string("{{ $WHO }}");
756 assert!(result.is_err());
757 let msg = result.unwrap_err().to_string();
758 assert!(msg.contains("undefined script variable"), "got: {msg}");
759 assert!(
760 msg.contains("did you mean 'env:WHO'"),
761 "hint should suggest env: prefix, got: {msg}"
762 );
763 }
764
765 #[test]
766 fn env_var_does_not_fall_back_to_vars() {
767 let mut vars = HashMap::new();
768 vars.insert(
769 "HOST".into(),
770 oxdock_parser::Value::String("from-var".into()),
771 );
772 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
773 let result = expander.expand_string("{{ env:HOST }}");
775 assert!(result.is_err());
776 let msg = result.unwrap_err().to_string();
777 assert!(msg.contains("undefined environment variable"), "got: {msg}");
778 assert!(
779 msg.contains("did you mean '$HOST'"),
780 "hint should suggest $ prefix, got: {msg}"
781 );
782 }
783
784 #[test]
785 fn step_override_does_not_fall_back_to_vars() {
786 let mut vars = HashMap::new();
787 vars.insert("PORT".into(), oxdock_parser::Value::Int(8080));
788 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
789 let result = expander.expand_string("{{ PORT }}");
791 assert!(result.is_err());
792 let msg = result.unwrap_err().to_string();
793 assert!(msg.contains("missing required step override"), "got: {msg}");
794 assert!(
795 msg.contains("did you mean '$PORT'"),
796 "hint should suggest $ prefix, got: {msg}"
797 );
798 }
799
800 #[test]
801 fn env_prefix_isolated_from_script_vars() {
802 let mut vars = HashMap::new();
803 vars.insert("MODE".into(), oxdock_parser::Value::String("dev".into()));
804 let expander = StreamingExpand::new(&[], &HashMap::new()).with_vars(&vars);
805 let result = expander.expand_string("{{ env:MODE }}");
807 assert!(result.is_err());
808 let msg = result.unwrap_err().to_string();
809 assert!(msg.contains("undefined environment variable"), "got: {msg}");
810 assert!(
811 msg.contains("did you mean '$MODE'"),
812 "hint should suggest $ prefix, got: {msg}"
813 );
814 }
815
816 #[test]
817 fn dollar_prefix_isolated_from_env() {
818 let mut env = HashMap::new();
819 env.insert("PORT".into(), "3000".into());
820 let expander = StreamingExpand::new(&[], &env);
821 let result = expander.expand_string("{{ $PORT }}");
823 assert!(result.is_err());
824 let msg = result.unwrap_err().to_string();
825 assert!(msg.contains("undefined script variable"), "got: {msg}");
826 assert!(
827 msg.contains("did you mean 'env:PORT'"),
828 "hint should suggest env: prefix, got: {msg}"
829 );
830 }
831
832 #[test]
833 fn empty_placeholder_errors() {
834 let expander = StreamingExpand::new(&[], &HashMap::new());
835 let result = expander.expand_string("{{ }}");
836 assert!(result.is_err());
837 let msg = result.unwrap_err().to_string();
838 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
839 }
840
841 #[test]
842 fn malformed_symbol_placeholder_errors() {
843 let expander = StreamingExpand::new(&[], &HashMap::new());
844 let result = expander.expand_string("{{ @invalid! }}");
845 assert!(result.is_err());
846 let msg = result.unwrap_err().to_string();
847 assert!(msg.contains("invalid placeholder format"), "got: {msg}");
848 }
849}