1use std::collections::HashMap;
2use std::sync::Arc;
3
4use regex::Regex;
5use tokio::sync::Mutex;
6
7use relux_core::pure::LayeredEnv;
8use relux_core::pure::VarScope;
9use relux_ir::IrTimeout;
10
11use crate::observe::structured::SpanId;
12
13#[derive(Clone, Debug)]
16pub enum FailPattern {
17 Regex(Regex),
18 Literal(String),
19}
20
21#[derive(Debug, Default, Clone)]
26pub struct Captures {
27 map: HashMap<String, String>,
28}
29
30impl Captures {
31 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub fn get_indexed(&self, index: usize) -> Option<&str> {
36 self.map.get(&index.to_string()).map(String::as_str)
37 }
38
39 pub fn get_named(&self, name: &str) -> Option<&str> {
40 self.map.get(name).map(String::as_str)
41 }
42
43 pub fn get(&self, key: &str) -> Option<&str> {
45 self.map.get(key).map(String::as_str)
46 }
47
48 pub fn set(&mut self, key: String, value: String) {
49 self.map.insert(key, value);
50 }
51
52 pub fn clear(&mut self) {
53 self.map.clear();
54 }
55
56 pub fn as_map(&self) -> &HashMap<String, String> {
59 &self.map
60 }
61}
62
63#[derive(Clone)]
66pub enum Scope {
67 Test {
68 name: String,
69 vars: Arc<Mutex<VarScope>>,
70 timeout: Option<IrTimeout>,
71 },
72 Effect {
73 name: String,
74 vars: Arc<Mutex<VarScope>>,
75 _timeout: Option<IrTimeout>,
76 env: Arc<LayeredEnv>,
77 },
78}
79
80impl Scope {
81 pub fn name(&self) -> &str {
82 match self {
83 Scope::Test { name, .. } | Scope::Effect { name, .. } => name,
84 }
85 }
86
87 pub fn vars(&self) -> &Arc<Mutex<VarScope>> {
88 match self {
89 Scope::Test { vars, .. } | Scope::Effect { vars, .. } => vars,
90 }
91 }
92}
93
94pub struct ShellState {
97 pub name: String,
102
103 pub effect_alias: Option<String>,
107
108 pub effect_name: Option<String>,
112
113 pub vars: VarScope,
114 pub captures: Captures,
115 pub timeout: Option<IrTimeout>,
116 pub fail_pattern: Option<FailPattern>,
117}
118
119impl ShellState {
120 pub fn new(name: String) -> Self {
121 Self {
122 name,
123 effect_alias: None,
124 effect_name: None,
125 vars: VarScope::new(),
126 captures: Captures::new(),
127 timeout: None,
128 fail_pattern: None,
129 }
130 }
131}
132
133pub struct CallFrame {
136 pub name: String,
137 pub vars: VarScope,
138 pub captures: Captures,
139 pub timeout: Option<IrTimeout>,
140 pub fail_pattern: Option<FailPattern>,
141}
142
143pub struct ExecutionContext {
146 pub scope: Scope,
147 pub shell: ShellState,
148 call_stack: Vec<CallFrame>,
149 span_stack: Vec<SpanId>,
150 pub default_timeout: IrTimeout,
151 pub env: Arc<LayeredEnv>,
152}
153
154impl ExecutionContext {
155 pub fn new(
156 scope: Scope,
157 shell: ShellState,
158 default_timeout: IrTimeout,
159 env: Arc<LayeredEnv>,
160 parent_span: SpanId,
161 ) -> Self {
162 Self {
163 scope,
164 shell,
165 call_stack: Vec::new(),
166 span_stack: vec![parent_span],
167 default_timeout,
168 env,
169 }
170 }
171
172 pub fn current_span(&self) -> SpanId {
175 *self
176 .span_stack
177 .last()
178 .expect("span_stack always has at least one entry")
179 }
180
181 pub fn push_span(&mut self, id: SpanId) {
182 self.span_stack.push(id);
183 }
184
185 pub fn pop_span(&mut self) {
186 if self.span_stack.len() > 1 {
188 self.span_stack.pop();
189 }
190 }
191
192 pub fn set_block_span(&mut self, span: SpanId) {
197 self.span_stack = vec![span];
198 }
199
200 pub fn interp_chain<'a>(
207 &'a self,
208 scope_guard: &'a VarScope,
209 ) -> (Vec<&'a VarScope>, &'a LayeredEnv) {
210 if let Some(frame) = self.call_stack.last() {
211 return (vec![&frame.vars], &self.env);
212 }
213 let env: &LayeredEnv = match &self.scope {
214 Scope::Effect { env, .. } => env,
215 Scope::Test { .. } => &self.env,
216 };
217 (vec![&self.shell.vars, scope_guard], env)
218 }
219
220 pub async fn lookup(&self, key: &str) -> Option<String> {
222 let guard = self.scope.vars().lock().await;
223 let (scopes, env) = self.interp_chain(&guard);
224 relux_core::pure::lookup_var(&scopes, env, key)
225 }
226
227 pub fn capture(&self, index: usize) -> Option<String> {
229 let key = index.to_string();
230 if let Some(frame) = self.call_stack.last() {
231 return frame.captures.get(&key).map(str::to_string);
232 }
233 self.shell.captures.get(&key).map(str::to_string)
234 }
235
236 pub fn current_captures_map(&self) -> &HashMap<String, String> {
240 match self.call_stack.last() {
241 Some(frame) => frame.captures.as_map(),
242 None => self.shell.captures.as_map(),
243 }
244 }
245
246 pub fn let_insert(&mut self, key: String, value: String) {
248 if let Some(frame) = self.call_stack.last_mut() {
249 frame.vars.insert(key, value);
250 } else {
251 self.shell.vars.insert(key, value);
252 }
253 }
254
255 pub async fn assign(&mut self, key: &str, value: String) -> Option<String> {
259 if let Some(frame) = self.call_stack.last_mut() {
260 return frame.vars.assign(key, value);
261 }
262 if let Some(prev) = self.shell.vars.assign(key, value.clone()) {
263 return Some(prev);
264 }
265 self.scope.vars().lock().await.assign(key, value)
266 }
267
268 pub fn push_call(&mut self, name: String, args: Vec<(String, String)>) {
270 let (timeout, fail_pattern) = if let Some(frame) = self.call_stack.last() {
271 (frame.timeout.clone(), frame.fail_pattern.clone())
272 } else {
273 (self.shell.timeout.clone(), self.shell.fail_pattern.clone())
274 };
275 let mut vars = VarScope::new();
276 for (k, v) in args {
277 vars.insert(k, v);
278 }
279 self.call_stack.push(CallFrame {
280 name,
281 vars,
282 captures: Captures::new(),
283 timeout,
284 fail_pattern,
285 });
286 }
287
288 pub fn pop_call(&mut self) {
290 self.call_stack.pop();
291 }
292
293 pub fn timeout(&self) -> &IrTimeout {
295 if let Some(frame) = self.call_stack.last()
296 && let Some(ref t) = frame.timeout
297 {
298 return t;
299 }
300 if let Some(ref t) = self.shell.timeout {
301 return t;
302 }
303 &self.default_timeout
304 }
305
306 pub fn set_timeout(&mut self, t: IrTimeout) {
308 if let Some(frame) = self.call_stack.last_mut() {
309 frame.timeout = Some(t);
310 } else {
311 self.shell.timeout = Some(t);
312 }
313 }
314
315 pub fn fail_pattern(&self) -> Option<&FailPattern> {
317 if let Some(frame) = self.call_stack.last() {
318 return frame.fail_pattern.as_ref();
319 }
320 self.shell.fail_pattern.as_ref()
321 }
322
323 pub fn set_fail_pattern(&mut self, pattern: Option<FailPattern>) {
325 if let Some(frame) = self.call_stack.last_mut() {
326 frame.fail_pattern = pattern;
327 } else {
328 self.shell.fail_pattern = pattern;
329 }
330 }
331
332 pub fn current_name(&self) -> String {
342 match (&self.shell.effect_name, &self.shell.effect_alias) {
343 (None, _) => self.shell.name.clone(),
344 (Some(eff), None) => format!("{eff}.{}", self.shell.name),
345 (Some(eff), Some(ali)) => format!("{ali}({eff}).{}", self.shell.name),
346 }
347 }
348
349 pub fn reset_for_export(
354 &mut self,
355 new_scope: Scope,
356 parent_alias: Option<String>,
357 parent_effect_name: Option<String>,
358 shell_local_name: String,
359 ) {
360 self.shell.effect_alias = parent_alias;
361 self.shell.effect_name = parent_effect_name;
362 self.shell.name = shell_local_name;
363 self.scope = new_scope;
364 self.shell.vars = VarScope::new();
365 self.shell.captures = Captures::new();
366 }
368
369 pub fn set_captures(&mut self, captures: Captures) {
371 if let Some(frame) = self.call_stack.last_mut() {
372 frame.captures = captures;
373 } else {
374 self.shell.captures = captures;
375 }
376 }
377
378 pub fn in_call(&self) -> bool {
380 !self.call_stack.is_empty()
381 }
382
383 pub fn current_fn_name(&self) -> Option<&str> {
385 self.call_stack.last().map(|f| f.name.as_str())
386 }
387
388 pub async fn snapshot_user_vars(&self) -> Vec<(String, String)> {
393 let mut out: Vec<(String, String)> = Vec::new();
394 if let Some(frame) = self.call_stack.last() {
395 for (k, v) in frame.vars.iter() {
396 out.push((k.to_string(), v.to_string()));
397 }
398 } else {
399 for (k, v) in self.shell.vars.iter() {
400 out.push((k.to_string(), v.to_string()));
401 }
402 let scope_vars = self.scope.vars().lock().await;
403 for (k, v) in scope_vars.iter() {
404 out.push((k.to_string(), v.to_string()));
405 }
406 }
407 out.sort_by(|a, b| a.0.cmp(&b.0));
408 out.dedup_by(|a, b| a.0 == b.0);
409 out
410 }
411
412 pub fn process_env(&self) -> Vec<(String, String)> {
416 let result: Vec<(String, String)> = match &self.scope {
417 Scope::Effect { env, .. } => env
418 .iter()
419 .map(|(k, v)| (k.to_string(), v.to_string()))
420 .collect(),
421 Scope::Test { .. } => self
422 .env
423 .iter()
424 .map(|(k, v)| (k.to_string(), v.to_string()))
425 .collect(),
426 };
427 result
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use relux_core::pure::Env;
435 use std::collections::HashMap;
436 use std::time::Duration;
437
438 fn test_env() -> Arc<LayeredEnv> {
439 let mut m = HashMap::new();
440 m.insert("PATH".into(), "/usr/bin".into());
441 Arc::new(LayeredEnv::from(Env::from_map(m)))
442 }
443
444 fn test_scope(name: &str) -> Scope {
445 Scope::Test {
446 name: name.into(),
447 vars: Arc::new(Mutex::new(VarScope::new())),
448 timeout: None,
449 }
450 }
451
452 fn test_shell(name: &str) -> ShellState {
453 ShellState::new(name.into())
454 }
455
456 fn test_ctx() -> ExecutionContext {
457 ExecutionContext::new(
458 test_scope("my test"),
459 test_shell("sh"),
460 IrTimeout::tolerance(Duration::from_secs(5)),
461 test_env(),
462 0,
463 )
464 }
465
466 #[tokio::test]
469 async fn lookup_shell_var() {
470 let mut ctx = test_ctx();
471 ctx.shell.vars.insert("x".into(), "hello".into());
472 assert_eq!(ctx.lookup("x").await, Some("hello".into()));
473 }
474
475 #[tokio::test]
476 async fn lookup_scope_var() {
477 let ctx = test_ctx();
478 ctx.scope
479 .vars()
480 .lock()
481 .await
482 .insert("g".into(), "global".into());
483 assert_eq!(ctx.lookup("g").await, Some("global".into()));
484 }
485
486 #[tokio::test]
487 async fn lookup_env_fallback() {
488 let ctx = test_ctx();
489 assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
490 }
491
492 #[tokio::test]
493 async fn lookup_missing() {
494 let ctx = test_ctx();
495 assert_eq!(ctx.lookup("NONEXISTENT").await, None);
496 }
497
498 #[tokio::test]
499 async fn lookup_shell_shadows_scope() {
500 let mut ctx = test_ctx();
501 ctx.scope
502 .vars()
503 .lock()
504 .await
505 .insert("x".into(), "scope".into());
506 ctx.shell.vars.insert("x".into(), "shell".into());
507 assert_eq!(ctx.lookup("x").await, Some("shell".into()));
508 }
509
510 #[tokio::test]
511 async fn interp_chain_resolves_shell_scope_and_call_frame() {
512 let mut ctx = test_ctx();
513 ctx.shell.vars.insert("s".into(), "shell_val".into());
514 ctx.scope
515 .vars()
516 .lock()
517 .await
518 .insert("g".into(), "scope_val".into());
519
520 {
522 let guard = ctx.scope.vars().lock().await;
523 let (scopes, env) = ctx.interp_chain(&guard);
524 assert_eq!(
525 relux_core::pure::lookup_var(&scopes, env, "s"),
526 Some("shell_val".to_string())
527 );
528 assert_eq!(
529 relux_core::pure::lookup_var(&scopes, env, "g"),
530 Some("scope_val".to_string())
531 );
532 assert_eq!(
533 relux_core::pure::lookup_var(&scopes, env, "PATH"),
534 Some("/usr/bin".to_string())
535 );
536 assert_eq!(relux_core::pure::lookup_var(&scopes, env, "absent"), None);
537 }
538
539 ctx.push_call("fn".into(), vec![("arg".into(), "argval".into())]);
541 {
542 let guard = ctx.scope.vars().lock().await;
543 let (scopes, env) = ctx.interp_chain(&guard);
544 assert_eq!(
545 relux_core::pure::lookup_var(&scopes, env, "arg"),
546 Some("argval".to_string())
547 );
548 assert_eq!(
549 relux_core::pure::lookup_var(&scopes, env, "s"),
550 None,
551 "barrier hides shell var"
552 );
553 assert_eq!(
554 relux_core::pure::lookup_var(&scopes, env, "g"),
555 None,
556 "barrier hides scope var"
557 );
558 assert_eq!(
559 relux_core::pure::lookup_var(&scopes, env, "PATH"),
560 Some("/usr/bin".to_string())
561 );
562 assert_eq!(relux_core::pure::lookup_var(&scopes, env, "absent"), None);
563 }
564 ctx.pop_call();
565 }
566
567 #[tokio::test]
568 async fn interp_chain_resolves_effect_overlay_and_base() {
569 let mut base = Env::new();
572 base.insert("PATH".into(), "/usr/bin".into());
573 let root = Arc::new(LayeredEnv::root(base));
574 let mut overlay = Env::new();
575 overlay.insert("PORT".into(), "5432".into());
576 let effect_env = Arc::new(LayeredEnv::child(root, overlay));
577
578 let scope = Scope::Effect {
579 name: "Db".into(),
580 vars: Arc::new(Mutex::new(VarScope::new())),
581 _timeout: None,
582 env: effect_env,
583 };
584 let shell = ShellState::new("db".into());
585 let ctx = ExecutionContext::new(
586 scope,
587 shell,
588 IrTimeout::tolerance(Duration::from_secs(5)),
589 test_env(),
590 0,
591 );
592
593 let guard = ctx.scope.vars().lock().await;
594 let (scopes, env) = ctx.interp_chain(&guard);
595 assert_eq!(
596 relux_core::pure::lookup_var(&scopes, env, "PORT"),
597 Some("5432".to_string()),
598 "overlay var resolves"
599 );
600 assert_eq!(
601 relux_core::pure::lookup_var(&scopes, env, "PATH"),
602 Some("/usr/bin".to_string()),
603 "base env resolves through the effect env's parent chain"
604 );
605 assert_eq!(relux_core::pure::lookup_var(&scopes, env, "absent"), None);
606 }
607
608 #[tokio::test]
611 async fn call_frame_barrier() {
612 let mut ctx = test_ctx();
613 ctx.shell.vars.insert("outer".into(), "val".into());
614 ctx.push_call("fn".into(), vec![("arg".into(), "argval".into())]);
615 assert_eq!(ctx.lookup("arg").await, Some("argval".into()));
617 assert_eq!(ctx.lookup("outer").await, None);
619 assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
621 ctx.pop_call();
622 assert_eq!(ctx.lookup("outer").await, Some("val".into()));
624 }
625
626 #[tokio::test]
627 async fn nested_calls_stack() {
628 let mut ctx = test_ctx();
629 ctx.push_call("f1".into(), vec![("a".into(), "1".into())]);
630 ctx.push_call("f2".into(), vec![("b".into(), "2".into())]);
631 assert_eq!(ctx.lookup("b").await, Some("2".into()));
632 assert_eq!(ctx.lookup("a").await, None); ctx.pop_call();
634 assert_eq!(ctx.lookup("a").await, Some("1".into()));
635 ctx.pop_call();
636 }
637
638 #[test]
641 fn current_fn_name_none_outside_call() {
642 let ctx = test_ctx();
643 assert_eq!(ctx.current_fn_name(), None);
644 }
645
646 #[test]
647 fn current_fn_name_inside_call() {
648 let mut ctx = test_ctx();
649 ctx.push_call("helper".into(), vec![]);
650 assert_eq!(ctx.current_fn_name(), Some("helper"));
651 ctx.pop_call();
652 assert_eq!(ctx.current_fn_name(), None);
653 }
654
655 #[tokio::test]
658 async fn let_insert_in_shell() {
659 let mut ctx = test_ctx();
660 ctx.let_insert("x".into(), "v".into());
661 assert_eq!(ctx.lookup("x").await, Some("v".into()));
662 }
663
664 #[tokio::test]
665 async fn let_insert_in_call() {
666 let mut ctx = test_ctx();
667 ctx.push_call("fn".into(), vec![]);
668 ctx.let_insert("local".into(), "val".into());
669 assert_eq!(ctx.lookup("local").await, Some("val".into()));
670 ctx.pop_call();
671 assert_eq!(ctx.lookup("local").await, None);
672 }
673
674 #[tokio::test]
677 async fn assign_in_shell() {
678 let mut ctx = test_ctx();
679 ctx.shell.vars.insert("x".into(), "old".into());
680 assert_eq!(ctx.assign("x", "new".into()).await, Some("old".into()));
681 assert_eq!(ctx.lookup("x").await, Some("new".into()));
682 }
683
684 #[tokio::test]
685 async fn assign_missing_returns_none() {
686 let mut ctx = test_ctx();
687 assert_eq!(ctx.assign("nope", "val".into()).await, None);
688 }
689
690 #[tokio::test]
691 async fn assign_falls_through_to_scope() {
692 let mut ctx = test_ctx();
693 ctx.scope
694 .vars()
695 .lock()
696 .await
697 .insert("g".into(), "old".into());
698 assert_eq!(ctx.assign("g", "new".into()).await, Some("old".into()));
699 assert_eq!(ctx.scope.vars().lock().await.get("g"), Some("new"));
700 }
701
702 #[test]
705 fn timeout_default_fallback() {
706 let ctx = test_ctx();
707 assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(5));
708 }
709
710 #[test]
711 fn timeout_shell_overrides_default() {
712 let mut ctx = test_ctx();
713 ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(10)));
714 assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(10));
715 }
716
717 #[test]
718 fn timeout_call_frame_overrides_shell() {
719 let mut ctx = test_ctx();
720 ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(10)));
721 ctx.push_call("fn".into(), vec![]);
722 ctx.set_timeout(IrTimeout::tolerance(Duration::from_secs(1)));
723 assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(1));
724 ctx.pop_call();
725 assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(10));
726 }
727
728 #[test]
731 fn fail_pattern_default_none() {
732 let ctx = test_ctx();
733 assert!(ctx.fail_pattern().is_none());
734 }
735
736 #[test]
737 fn fail_pattern_set_and_get() {
738 let mut ctx = test_ctx();
739 ctx.set_fail_pattern(Some(FailPattern::Literal("ERR".into())));
740 assert!(ctx.fail_pattern().is_some());
741 }
742
743 #[test]
744 fn fail_pattern_call_frame_isolated() {
745 let mut ctx = test_ctx();
746 ctx.set_fail_pattern(Some(FailPattern::Literal("shell".into())));
747 ctx.push_call("fn".into(), vec![]);
748 assert!(ctx.fail_pattern().is_some());
750 ctx.set_fail_pattern(None);
751 assert!(ctx.fail_pattern().is_none());
752 ctx.pop_call();
753 assert!(ctx.fail_pattern().is_some());
755 }
756
757 #[test]
760 fn current_name_bare() {
761 let ctx = test_ctx();
762 assert_eq!(ctx.current_name(), "sh");
763 }
764
765 #[test]
766 fn current_name_effect_no_alias() {
767 let mut ctx = test_ctx();
768 ctx.shell.effect_name = Some("Setup".into());
769 ctx.shell.name = "psql".into();
770 assert_eq!(ctx.current_name(), "Setup.psql");
771 }
772
773 #[test]
774 fn current_name_effect_with_alias() {
775 let mut ctx = test_ctx();
776 ctx.shell.effect_name = Some("Setup".into());
777 ctx.shell.effect_alias = Some("Db".into());
778 ctx.shell.name = "psql".into();
779 assert_eq!(ctx.current_name(), "Db(Setup).psql");
780 }
781
782 #[test]
783 fn current_name_replaced_by_export_chain() {
784 let mut ctx = test_ctx();
786 ctx.shell.name = "inner".into();
787 ctx.reset_for_export(
789 Scope::Effect {
790 name: "Outer".into(),
791 vars: Arc::new(Mutex::new(VarScope::new())),
792 _timeout: None,
793 env: Arc::new(LayeredEnv::root(Env::new())),
794 },
795 Some("Dep".into()),
796 Some("Inner".into()),
797 "inner".into(),
798 );
799 assert_eq!(ctx.current_name(), "Dep(Inner).inner");
800 ctx.reset_for_export(
802 test_scope("my test"),
803 Some("O".into()),
804 Some("Outer".into()),
805 "wrapped".into(),
806 );
807 assert_eq!(ctx.current_name(), "O(Outer).wrapped");
808 }
809
810 #[test]
813 fn capture_in_shell() {
814 let mut ctx = test_ctx();
815 let mut caps = Captures::new();
816 caps.set("0".into(), "whole".into());
817 caps.set("1".into(), "first".into());
818 ctx.set_captures(caps);
819 assert_eq!(ctx.capture(0), Some("whole".into()));
820 assert_eq!(ctx.capture(1), Some("first".into()));
821 assert_eq!(ctx.capture(2), None);
822 }
823
824 #[test]
825 fn capture_in_call_frame() {
826 let mut ctx = test_ctx();
827 let mut shell_caps = Captures::new();
828 shell_caps.set("1".into(), "shell".into());
829 ctx.set_captures(shell_caps);
830
831 ctx.push_call("fn".into(), vec![]);
832 let mut fn_caps = Captures::new();
833 fn_caps.set("1".into(), "fn".into());
834 ctx.set_captures(fn_caps);
835 assert_eq!(ctx.capture(1), Some("fn".into()));
836 ctx.pop_call();
837 assert_eq!(ctx.capture(1), Some("shell".into()));
838 }
839
840 #[tokio::test]
843 async fn reset_for_export_clears_vars_and_captures() {
844 let mut ctx = test_ctx();
845 ctx.shell.vars.insert("x".into(), "v".into());
846 let mut caps = Captures::new();
847 caps.set("1".into(), "c".into());
848 ctx.set_captures(caps);
849 ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(99)));
850
851 let new_scope = test_scope("new test");
852 ctx.reset_for_export(new_scope, None, None, "sh".into());
853
854 assert_eq!(ctx.lookup("x").await, None);
855 assert_eq!(ctx.capture(1), None);
856 assert_eq!(ctx.scope.name(), "new test");
857 assert_eq!(
859 ctx.shell.timeout.as_ref().unwrap().raw_duration(),
860 Duration::from_secs(99)
861 );
862 }
863
864 #[tokio::test]
867 async fn effect_scope_overlay_lookup() {
868 let mut overlay_map = HashMap::new();
869 overlay_map.insert("PORT".into(), "5432".into());
870
871 let scope = Scope::Effect {
872 name: "Db".into(),
873 vars: Arc::new(Mutex::new(VarScope::new())),
874 _timeout: None,
875 env: Arc::new(LayeredEnv::root(Env::from_map(overlay_map))),
876 };
877 let shell = ShellState::new("db".into());
878 let ctx = ExecutionContext::new(
879 scope,
880 shell,
881 IrTimeout::tolerance(Duration::from_secs(5)),
882 test_env(),
883 0,
884 );
885 assert_eq!(ctx.lookup("PORT").await, Some("5432".into()));
886 }
887
888 #[tokio::test]
891 async fn effect_scope_lookup_walks_parent_layers() {
892 let mut base = Env::new();
895 base.insert("BASE_PORT".into(), "5432".into());
896 let root = Arc::new(LayeredEnv::root(base));
897
898 let mut overlay = Env::new();
899 overlay.insert("LABEL".into(), "child".into());
900 let child_env = Arc::new(LayeredEnv::child(root, overlay));
901
902 let scope = Scope::Effect {
903 name: "Child".into(),
904 vars: Arc::new(Mutex::new(VarScope::new())),
905 _timeout: None,
906 env: child_env,
907 };
908 let shell = ShellState::new("s".into());
909 let ctx = ExecutionContext::new(
910 scope,
911 shell,
912 IrTimeout::tolerance(Duration::from_secs(5)),
913 test_env(),
914 0,
915 );
916 assert_eq!(ctx.lookup("BASE_PORT").await, Some("5432".into()));
918 assert_eq!(ctx.lookup("LABEL").await, Some("child".into()));
919 }
920
921 #[test]
922 fn process_env_includes_parent_layer_variables() {
923 let mut base = Env::new();
926 base.insert("BASE_PORT".into(), "5432".into());
927 let root = Arc::new(LayeredEnv::root(base));
928
929 let mut overlay = Env::new();
930 overlay.insert("LABEL".into(), "child".into());
931 let child_env = Arc::new(LayeredEnv::child(root, overlay));
932
933 let scope = Scope::Effect {
934 name: "Child".into(),
935 vars: Arc::new(Mutex::new(VarScope::new())),
936 _timeout: None,
937 env: child_env,
938 };
939 let shell = ShellState::new("s".into());
940 let ctx = ExecutionContext::new(
941 scope,
942 shell,
943 IrTimeout::tolerance(Duration::from_secs(5)),
944 test_env(),
945 0,
946 );
947 let penv: HashMap<String, String> = ctx.process_env().into_iter().collect();
948 assert_eq!(penv.get("LABEL"), Some(&"child".to_string()));
950 assert_eq!(
952 penv.get("BASE_PORT"),
953 Some(&"5432".to_string()),
954 "process_env must include variables from parent LayeredEnv layers"
955 );
956 }
957
958 #[tokio::test]
961 async fn snapshot_user_vars_in_shell_scope() {
962 let mut ctx = test_ctx();
963 ctx.shell.vars.insert("a".into(), "1".into());
964 ctx.scope.vars().lock().await.insert("b".into(), "2".into());
965 let snap = ctx.snapshot_user_vars().await;
966 assert_eq!(
967 snap,
968 vec![("a".into(), "1".into()), ("b".into(), "2".into())]
969 );
970 }
971
972 #[tokio::test]
973 async fn snapshot_user_vars_in_call_frame_only() {
974 let mut ctx = test_ctx();
975 ctx.shell.vars.insert("outer".into(), "v".into());
976 ctx.push_call("fn".into(), vec![("arg".into(), "av".into())]);
977 ctx.let_insert("local".into(), "lv".into());
978 let snap = ctx.snapshot_user_vars().await;
979 assert_eq!(
981 snap,
982 vec![("arg".into(), "av".into()), ("local".into(), "lv".into()),]
983 );
984 }
985
986 #[tokio::test]
987 async fn snapshot_user_vars_excludes_env() {
988 let ctx = test_ctx();
989 let snap = ctx.snapshot_user_vars().await;
990 assert!(snap.iter().all(|(k, _)| k != "PATH"));
992 }
993
994 #[test]
997 fn captures_new_is_empty() {
998 let c = Captures::new();
999 assert_eq!(c.get_indexed(0), None);
1000 assert_eq!(c.get_named("foo"), None);
1001 }
1002
1003 #[test]
1004 fn captures_set_and_get_indexed() {
1005 let mut c = Captures::new();
1006 c.set("0".into(), "whole".into());
1007 c.set("1".into(), "first".into());
1008 assert_eq!(c.get_indexed(0), Some("whole"));
1009 assert_eq!(c.get_indexed(1), Some("first"));
1010 assert_eq!(c.get_indexed(2), None);
1011 }
1012
1013 #[test]
1014 fn captures_set_and_get_named() {
1015 let mut c = Captures::new();
1016 c.set("host".into(), "localhost".into());
1017 assert_eq!(c.get_named("host"), Some("localhost"));
1018 assert_eq!(c.get_named("port"), None);
1019 }
1020
1021 #[test]
1022 fn captures_get_generic() {
1023 let mut c = Captures::new();
1024 c.set("1".into(), "idx".into());
1025 c.set("name".into(), "named".into());
1026 assert_eq!(c.get("1"), Some("idx"));
1027 assert_eq!(c.get("name"), Some("named"));
1028 }
1029
1030 #[test]
1031 fn captures_clear() {
1032 let mut c = Captures::new();
1033 c.set("1".into(), "val".into());
1034 c.clear();
1035 assert_eq!(c.get("1"), None);
1036 }
1037
1038 #[test]
1039 fn captures_clone() {
1040 let mut c = Captures::new();
1041 c.set("1".into(), "val".into());
1042 let cloned = c.clone();
1043 assert_eq!(cloned.get("1"), Some("val"));
1044 }
1045}