Skip to main content

relux_runtime/vm/
context.rs

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// --- FailPattern -----------------------------------------
14
15#[derive(Clone, Debug)]
16pub enum FailPattern {
17    Regex(Regex),
18    Literal(String),
19}
20
21// --- Captures --------------------------------------------
22
23/// Regex capture storage. Indexed captures are stored as "0", "1", etc.
24/// Named captures are stored by their group name.
25#[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    /// Look up by key (either numeric string for indexed, or name for named).
44    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    /// Borrow the underlying map, for the shared interpolation renderer
57    /// which resolves `${n}` against a `&HashMap<String, String>`.
58    pub fn as_map(&self) -> &HashMap<String, String> {
59        &self.map
60    }
61}
62
63// --- Scope -----------------------------------------------
64
65#[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
94// --- ShellState ------------------------------------------
95
96pub struct ShellState {
97    /// Local name of the shell within its current owning scope. At spawn
98    /// time this is the shell's declaration name; each `reset_for_export`
99    /// rewrites it to the key under which the source effect exposed the
100    /// shell to the parent.
101    pub name: String,
102
103    /// User-supplied alias from the parent caller's `start <Effect> as
104    /// <Alias>`. `None` when the caller did not alias, or when the shell
105    /// is in its own (origin) scope and no parent has imported it yet.
106    pub effect_alias: Option<String>,
107
108    /// Original effect-type name of the parent effect that owns this
109    /// shell from the current scope's POV. `None` symmetrically with
110    /// `effect_alias` (no parent -> no name).
111    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
133// --- CallFrame -------------------------------------------
134
135pub 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
143// --- ExecutionContext ------------------------------------
144
145pub 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    /// The id of the innermost span currently active in this context. Used
173    /// by every emission site so events reference the span they fired in.
174    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        // Never pop the bottom of the stack (the root passed to `new`).
187        if self.span_stack.len() > 1 {
188            self.span_stack.pop();
189        }
190    }
191
192    /// Reset the span stack so emissions are parented on `span`. Used when a
193    /// shell is reused across shell blocks: each block opens a fresh
194    /// ShellBlock span and the VM's events should reference that one rather
195    /// than the span from the shell's original construction.
196    pub fn set_block_span(&mut self, span: SpanId) {
197        self.span_stack = vec![span];
198    }
199
200    /// The scope chain + env that reproduce `lookup` precedence for the
201    /// current state, for the shared interpolation renderer. `scope_guard`
202    /// is the already-locked `scope.vars()`. Inside a `fn` call the barrier
203    /// hides shell/scope vars and the env fallback is the base env; outside
204    /// a call, shell vars shadow scope vars and an effect scope consults its
205    /// own (chained) env first.
206    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    /// Look up a variable by name. Follows the lookup chain per RFC R005.
221    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    /// Look up a capture reference (e.g. ${1}).
228    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    /// The current capture frame as a raw map, for the interpolation
237    /// renderer. Inside a `fn` call this is the call frame's captures;
238    /// otherwise the shell's.
239    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    /// Insert a `let` variable into the current context.
247    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    /// Assign to an existing variable. Returns `Some(prev)` with the prior
256    /// value if found and updated, `None` if the key was not found in any
257    /// scope.
258    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    /// Push a function call frame.
269    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    /// Pop the top function call frame.
289    pub fn pop_call(&mut self) {
290        self.call_stack.pop();
291    }
292
293    /// Get the effective timeout.
294    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    /// Set the timeout on the current context.
307    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    /// Get the current fail pattern.
316    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    /// Set the fail pattern on the current context.
324    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    /// Current display name for logging. Reflects the *current scope's*
333    /// view of the shell only - no chain accumulation across exports.
334    ///
335    /// Format:
336    /// - `<name>` when no parent effect imported this shell (origin scope or
337    ///   directly at test scope without aliasing through an effect).
338    /// - `<Effect>.<name>` when the parent imported the source effect without
339    ///   aliasing it.
340    /// - `<Alias>(<Effect>).<name>` when the parent used `start Effect as Alias`.
341    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    /// Reset for shell export (effect -> test/parent effect). Replaces the
350    /// shell's view with how the new (parent) scope sees it: the parent's
351    /// alias for the source effect, the source effect's original name, and
352    /// the local key under which it was exposed.
353    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        // timeout, fail_pattern are preserved
367    }
368
369    /// Set captures on the current context.
370    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    /// Whether we're inside a function call.
379    pub fn in_call(&self) -> bool {
380        !self.call_stack.is_empty()
381    }
382
383    /// The name of the innermost `fn` call frame, if we are inside one.
384    pub fn current_fn_name(&self) -> Option<&str> {
385        self.call_stack.last().map(|f| f.name.as_str())
386    }
387
388    /// Snapshot user-visible variables at the current point of execution,
389    /// for failure diagnostics. Excludes the layered process env (already in
390    /// `events.json :: env.bootstrap`) and matcher captures. Sorted by key
391    /// for stable JSON output.
392    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    /// Build the environment variables map for spawning a shell process.
413    /// For effects, the effect env already inherits the base env via LayeredEnv
414    /// parent chain, so only the effect env is needed.
415    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    // --- Lookup tests ------------------------------------
467
468    #[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        // Non-call: shell var, scope var, base env, absent - all via interp_chain.
521        {
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        // In-call: the barrier hides shell/scope vars; the frame arg and base env remain.
540        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        // Effect env chains overlay -> base (as in production), so interp_chain's
570        // single effect env resolves both the overlay var and the base env.
571        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    // --- Call stack barrier ------------------------------
609
610    #[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        // Can see arg
616        assert_eq!(ctx.lookup("arg").await, Some("argval".into()));
617        // Cannot see outer shell vars
618        assert_eq!(ctx.lookup("outer").await, None);
619        // Can see env
620        assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
621        ctx.pop_call();
622        // After pop, can see outer again
623        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); // barrier
633        ctx.pop_call();
634        assert_eq!(ctx.lookup("a").await, Some("1".into()));
635        ctx.pop_call();
636    }
637
638    // --- current_fn_name -----------------------------------
639
640    #[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    // --- Let insert --------------------------------------
656
657    #[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    // --- Assign ------------------------------------------
675
676    #[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    // --- Timeout -----------------------------------------
703
704    #[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    // --- Fail pattern ------------------------------------
729
730    #[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        // Call inherits shell's fail pattern
749        assert!(ctx.fail_pattern().is_some());
750        ctx.set_fail_pattern(None);
751        assert!(ctx.fail_pattern().is_none());
752        ctx.pop_call();
753        // Shell still has its pattern
754        assert!(ctx.fail_pattern().is_some());
755    }
756
757    // --- Name resolution ---------------------------------
758
759    #[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        // Each export step replaces the view; nothing accumulates.
785        let mut ctx = test_ctx();
786        ctx.shell.name = "inner".into();
787        // First export: Inner -> Outer (Outer's `start Inner as Dep`).
788        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        // Second export: Outer -> test (test's `start Outer as O`, exposed-as `wrapped`).
801        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    // --- Captures ----------------------------------------
811
812    #[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    // --- Reset for export --------------------------------
841
842    #[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        // timeout preserved
858        assert_eq!(
859            ctx.shell.timeout.as_ref().unwrap().raw_duration(),
860            Duration::from_secs(99)
861        );
862    }
863
864    // --- Effect scope with overlay -----------------------
865
866    #[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    // --- LayeredEnv chain bugs ---------------------------
889
890    #[tokio::test]
891    async fn effect_scope_lookup_walks_parent_layers() {
892        // Parent layer has BASE_PORT, child overlay has LABEL.
893        // lookup("BASE_PORT") should walk the chain and find it.
894        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        // lookup walks the chain - this works correctly
917        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        // Regression test: process_env() must include variables from parent
924        // LayeredEnv layers, not just the immediate layer.
925        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        // Child's own overlay should be present
949        assert_eq!(penv.get("LABEL"), Some(&"child".to_string()));
950        // Parent layer variable should also be present in the PTY env
951        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    // --- snapshot_user_vars ------------------------------
959
960    #[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        // Only the innermost call frame's vars are visible (matches lookup barrier).
980        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        // PATH is in the layered env, not in scope/shell vars - must be excluded.
991        assert!(snap.iter().all(|(k, _)| k != "PATH"));
992    }
993
994    // --- Captures unit tests -----------------------------
995
996    #[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}