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
11// ─── FailPattern ────────────────────────────────────────────
12
13#[derive(Clone, Debug)]
14pub enum FailPattern {
15    Regex(Regex),
16    Literal(String),
17}
18
19// ─── Captures ───────────────────────────────────────────────
20
21/// Regex capture storage. Indexed captures are stored as "0", "1", etc.
22/// Named captures are stored by their group name.
23#[derive(Debug, Default, Clone)]
24pub struct Captures {
25    map: HashMap<String, String>,
26}
27
28impl Captures {
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    pub fn get_indexed(&self, index: usize) -> Option<&str> {
34        self.map.get(&index.to_string()).map(String::as_str)
35    }
36
37    pub fn get_named(&self, name: &str) -> Option<&str> {
38        self.map.get(name).map(String::as_str)
39    }
40
41    /// Look up by key (either numeric string for indexed, or name for named).
42    pub fn get(&self, key: &str) -> Option<&str> {
43        self.map.get(key).map(String::as_str)
44    }
45
46    pub fn set(&mut self, key: String, value: String) {
47        self.map.insert(key, value);
48    }
49
50    pub fn clear(&mut self) {
51        self.map.clear();
52    }
53}
54
55// ─── Scope ──────────────────────────────────────────────────
56
57#[derive(Clone)]
58pub enum Scope {
59    Test {
60        name: String,
61        vars: Arc<Mutex<VarScope>>,
62        timeout: Option<IrTimeout>,
63    },
64    Effect {
65        name: String,
66        vars: Arc<Mutex<VarScope>>,
67        _timeout: Option<IrTimeout>,
68        env: Arc<LayeredEnv>,
69    },
70}
71
72impl Scope {
73    pub fn name(&self) -> &str {
74        match self {
75            Scope::Test { name, .. } | Scope::Effect { name, .. } => name,
76        }
77    }
78
79    pub fn vars(&self) -> &Arc<Mutex<VarScope>> {
80        match self {
81            Scope::Test { vars, .. } | Scope::Effect { vars, .. } => vars,
82        }
83    }
84}
85
86// ─── ShellState ─────────────────────────────────────────────
87
88pub struct ShellState {
89    pub name: String,
90    pub alias: Option<String>,
91    /// Accumulated name path from effect export chain.
92    /// Each export pushes `"EffectName.shell_name"` onto this prefix.
93    pub name_prefix: Vec<String>,
94    pub vars: VarScope,
95    pub captures: Captures,
96    pub timeout: Option<IrTimeout>,
97    pub fail_pattern: Option<FailPattern>,
98}
99
100impl ShellState {
101    pub fn new(name: String, alias: Option<String>) -> Self {
102        Self {
103            name,
104            alias,
105            name_prefix: Vec::new(),
106            vars: VarScope::new(),
107            captures: Captures::new(),
108            timeout: None,
109            fail_pattern: None,
110        }
111    }
112}
113
114// ─── CallFrame ──────────────────────────────────────────────
115
116pub struct CallFrame {
117    pub name: String,
118    pub vars: VarScope,
119    pub captures: Captures,
120    pub timeout: Option<IrTimeout>,
121    pub fail_pattern: Option<FailPattern>,
122}
123
124// ─── ExecutionContext ────────────────────────────────────────
125
126pub struct ExecutionContext {
127    pub scope: Scope,
128    pub shell: ShellState,
129    call_stack: Vec<CallFrame>,
130    pub default_timeout: IrTimeout,
131    pub env: Arc<LayeredEnv>,
132}
133
134impl ExecutionContext {
135    pub fn new(
136        scope: Scope,
137        shell: ShellState,
138        default_timeout: IrTimeout,
139        env: Arc<LayeredEnv>,
140    ) -> Self {
141        Self {
142            scope,
143            shell,
144            call_stack: Vec::new(),
145            default_timeout,
146            env,
147        }
148    }
149
150    /// Look up a variable by name. Follows the lookup chain per RFC R005.
151    pub async fn lookup(&self, key: &str) -> Option<String> {
152        if let Some(frame) = self.call_stack.last() {
153            // Inside a function call — hard barrier
154            if let Some(v) = frame.vars.get(key) {
155                return Some(v.to_string());
156            }
157            return self.env.get(key).map(str::to_string);
158        }
159
160        // Direct shell execution
161        if let Some(v) = self.shell.vars.get(key) {
162            return Some(v.to_string());
163        }
164        if let Some(v) = self.scope.vars().lock().await.get(key) {
165            return Some(v.to_string());
166        }
167        // Effect scope env walks the layered chain (overlays → base)
168        if let Scope::Effect { env, .. } = &self.scope
169            && let Some(v) = env.get(key)
170        {
171            return Some(v.to_string());
172        }
173        self.env.get(key).map(str::to_string)
174    }
175
176    /// Look up a capture reference (e.g. ${1}).
177    pub fn capture(&self, index: usize) -> Option<String> {
178        let key = index.to_string();
179        if let Some(frame) = self.call_stack.last() {
180            return frame.captures.get(&key).map(str::to_string);
181        }
182        self.shell.captures.get(&key).map(str::to_string)
183    }
184
185    /// Insert a `let` variable into the current context.
186    pub fn let_insert(&mut self, key: String, value: String) {
187        if let Some(frame) = self.call_stack.last_mut() {
188            frame.vars.insert(key, value);
189        } else {
190            self.shell.vars.insert(key, value);
191        }
192    }
193
194    /// Assign to an existing variable. Returns true if found and updated.
195    pub async fn assign(&mut self, key: &str, value: String) -> bool {
196        if let Some(frame) = self.call_stack.last_mut() {
197            return frame.vars.assign(key, value);
198        }
199        if self.shell.vars.assign(key, value.clone()) {
200            return true;
201        }
202        self.scope.vars().lock().await.assign(key, value)
203    }
204
205    /// Push a function call frame.
206    pub fn push_call(&mut self, name: String, args: Vec<(String, String)>) {
207        let (timeout, fail_pattern) = if let Some(frame) = self.call_stack.last() {
208            (frame.timeout.clone(), frame.fail_pattern.clone())
209        } else {
210            (self.shell.timeout.clone(), self.shell.fail_pattern.clone())
211        };
212        let mut vars = VarScope::new();
213        for (k, v) in args {
214            vars.insert(k, v);
215        }
216        self.call_stack.push(CallFrame {
217            name,
218            vars,
219            captures: Captures::new(),
220            timeout,
221            fail_pattern,
222        });
223    }
224
225    /// Pop the top function call frame.
226    pub fn pop_call(&mut self) {
227        self.call_stack.pop();
228    }
229
230    /// Get the effective timeout.
231    pub fn timeout(&self) -> &IrTimeout {
232        if let Some(frame) = self.call_stack.last()
233            && let Some(ref t) = frame.timeout
234        {
235            return t;
236        }
237        if let Some(ref t) = self.shell.timeout {
238            return t;
239        }
240        &self.default_timeout
241    }
242
243    /// Set the timeout on the current context.
244    pub fn set_timeout(&mut self, t: IrTimeout) {
245        if let Some(frame) = self.call_stack.last_mut() {
246            frame.timeout = Some(t);
247        } else {
248            self.shell.timeout = Some(t);
249        }
250    }
251
252    /// Get the current fail pattern.
253    pub fn fail_pattern(&self) -> Option<&FailPattern> {
254        if let Some(frame) = self.call_stack.last() {
255            return frame.fail_pattern.as_ref();
256        }
257        self.shell.fail_pattern.as_ref()
258    }
259
260    /// Set the fail pattern on the current context.
261    pub fn set_fail_pattern(&mut self, pattern: Option<FailPattern>) {
262        if let Some(frame) = self.call_stack.last_mut() {
263            frame.fail_pattern = pattern;
264        } else {
265            self.shell.fail_pattern = pattern;
266        }
267    }
268
269    /// Current display name for logging.
270    /// Builds the full qualified name from the effect export chain:
271    /// e.g. `SetupDb.db.Db.db.mydb` for a 2-level effect chain with alias `mydb`.
272    pub fn current_name(&self) -> String {
273        let tail = self.shell.alias.as_deref().unwrap_or(&self.shell.name);
274        if self.shell.name_prefix.is_empty() {
275            tail.to_string()
276        } else {
277            format!("{}.{}", self.shell.name_prefix.join("."), tail)
278        }
279    }
280
281    /// Reset for shell export (effect → test/parent effect).
282    /// Accumulates the current scope+shell name into the name prefix chain.
283    pub fn reset_for_export(&mut self, new_scope: Scope) {
284        // Push "EffectName.shell_name" onto the prefix before switching scope
285        let segment = format!("{}.{}", self.scope.name(), self.shell.name);
286        self.shell.name_prefix.push(segment);
287        self.scope = new_scope;
288        self.shell.vars = VarScope::new();
289        self.shell.captures = Captures::new();
290        // timeout, fail_pattern are preserved
291    }
292
293    /// Set captures on the current context.
294    pub fn set_captures(&mut self, captures: Captures) {
295        if let Some(frame) = self.call_stack.last_mut() {
296            frame.captures = captures;
297        } else {
298            self.shell.captures = captures;
299        }
300    }
301
302    /// Whether we're inside a function call.
303    pub fn in_call(&self) -> bool {
304        !self.call_stack.is_empty()
305    }
306
307    /// Build the environment variables map for spawning a shell process.
308    /// For effects, the effect env already inherits the base env via LayeredEnv
309    /// parent chain, so only the effect env is needed.
310    pub fn process_env(&self) -> Vec<(String, String)> {
311        let result: Vec<(String, String)> = match &self.scope {
312            Scope::Effect { env, .. } => env
313                .iter()
314                .map(|(k, v)| (k.to_string(), v.to_string()))
315                .collect(),
316            Scope::Test { .. } => self
317                .env
318                .iter()
319                .map(|(k, v)| (k.to_string(), v.to_string()))
320                .collect(),
321        };
322        result
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use relux_core::pure::Env;
330    use std::collections::HashMap;
331    use std::time::Duration;
332
333    fn test_env() -> Arc<LayeredEnv> {
334        let mut m = HashMap::new();
335        m.insert("PATH".into(), "/usr/bin".into());
336        Arc::new(LayeredEnv::from(Env::from_map(m)))
337    }
338
339    fn test_scope(name: &str) -> Scope {
340        Scope::Test {
341            name: name.into(),
342            vars: Arc::new(Mutex::new(VarScope::new())),
343            timeout: None,
344        }
345    }
346
347    fn test_shell(name: &str) -> ShellState {
348        ShellState::new(name.into(), None)
349    }
350
351    fn test_ctx() -> ExecutionContext {
352        ExecutionContext::new(
353            test_scope("my test"),
354            test_shell("sh"),
355            IrTimeout::tolerance(Duration::from_secs(5)),
356            test_env(),
357        )
358    }
359
360    // ─── Lookup tests ────────────────────────────────────────
361
362    #[tokio::test]
363    async fn lookup_shell_var() {
364        let mut ctx = test_ctx();
365        ctx.shell.vars.insert("x".into(), "hello".into());
366        assert_eq!(ctx.lookup("x").await, Some("hello".into()));
367    }
368
369    #[tokio::test]
370    async fn lookup_scope_var() {
371        let ctx = test_ctx();
372        ctx.scope
373            .vars()
374            .lock()
375            .await
376            .insert("g".into(), "global".into());
377        assert_eq!(ctx.lookup("g").await, Some("global".into()));
378    }
379
380    #[tokio::test]
381    async fn lookup_env_fallback() {
382        let ctx = test_ctx();
383        assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
384    }
385
386    #[tokio::test]
387    async fn lookup_missing() {
388        let ctx = test_ctx();
389        assert_eq!(ctx.lookup("NONEXISTENT").await, None);
390    }
391
392    #[tokio::test]
393    async fn lookup_shell_shadows_scope() {
394        let mut ctx = test_ctx();
395        ctx.scope
396            .vars()
397            .lock()
398            .await
399            .insert("x".into(), "scope".into());
400        ctx.shell.vars.insert("x".into(), "shell".into());
401        assert_eq!(ctx.lookup("x").await, Some("shell".into()));
402    }
403
404    // ─── Call stack barrier ──────────────────────────────────
405
406    #[tokio::test]
407    async fn call_frame_barrier() {
408        let mut ctx = test_ctx();
409        ctx.shell.vars.insert("outer".into(), "val".into());
410        ctx.push_call("fn".into(), vec![("arg".into(), "argval".into())]);
411        // Can see arg
412        assert_eq!(ctx.lookup("arg").await, Some("argval".into()));
413        // Cannot see outer shell vars
414        assert_eq!(ctx.lookup("outer").await, None);
415        // Can see env
416        assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
417        ctx.pop_call();
418        // After pop, can see outer again
419        assert_eq!(ctx.lookup("outer").await, Some("val".into()));
420    }
421
422    #[tokio::test]
423    async fn nested_calls_stack() {
424        let mut ctx = test_ctx();
425        ctx.push_call("f1".into(), vec![("a".into(), "1".into())]);
426        ctx.push_call("f2".into(), vec![("b".into(), "2".into())]);
427        assert_eq!(ctx.lookup("b").await, Some("2".into()));
428        assert_eq!(ctx.lookup("a").await, None); // barrier
429        ctx.pop_call();
430        assert_eq!(ctx.lookup("a").await, Some("1".into()));
431        ctx.pop_call();
432    }
433
434    // ─── Let insert ──────────────────────────────────────────
435
436    #[tokio::test]
437    async fn let_insert_in_shell() {
438        let mut ctx = test_ctx();
439        ctx.let_insert("x".into(), "v".into());
440        assert_eq!(ctx.lookup("x").await, Some("v".into()));
441    }
442
443    #[tokio::test]
444    async fn let_insert_in_call() {
445        let mut ctx = test_ctx();
446        ctx.push_call("fn".into(), vec![]);
447        ctx.let_insert("local".into(), "val".into());
448        assert_eq!(ctx.lookup("local").await, Some("val".into()));
449        ctx.pop_call();
450        assert_eq!(ctx.lookup("local").await, None);
451    }
452
453    // ─── Assign ──────────────────────────────────────────────
454
455    #[tokio::test]
456    async fn assign_in_shell() {
457        let mut ctx = test_ctx();
458        ctx.shell.vars.insert("x".into(), "old".into());
459        assert!(ctx.assign("x", "new".into()).await);
460        assert_eq!(ctx.lookup("x").await, Some("new".into()));
461    }
462
463    #[tokio::test]
464    async fn assign_missing_returns_false() {
465        let mut ctx = test_ctx();
466        assert!(!ctx.assign("nope", "val".into()).await);
467    }
468
469    #[tokio::test]
470    async fn assign_falls_through_to_scope() {
471        let mut ctx = test_ctx();
472        ctx.scope
473            .vars()
474            .lock()
475            .await
476            .insert("g".into(), "old".into());
477        assert!(ctx.assign("g", "new".into()).await);
478        assert_eq!(ctx.scope.vars().lock().await.get("g"), Some("new"));
479    }
480
481    // ─── Timeout ─────────────────────────────────────────────
482
483    #[test]
484    fn timeout_default_fallback() {
485        let ctx = test_ctx();
486        assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(5));
487    }
488
489    #[test]
490    fn timeout_shell_overrides_default() {
491        let mut ctx = test_ctx();
492        ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(10)));
493        assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(10));
494    }
495
496    #[test]
497    fn timeout_call_frame_overrides_shell() {
498        let mut ctx = test_ctx();
499        ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(10)));
500        ctx.push_call("fn".into(), vec![]);
501        ctx.set_timeout(IrTimeout::tolerance(Duration::from_secs(1)));
502        assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(1));
503        ctx.pop_call();
504        assert_eq!(ctx.timeout().raw_duration(), Duration::from_secs(10));
505    }
506
507    // ─── Fail pattern ────────────────────────────────────────
508
509    #[test]
510    fn fail_pattern_default_none() {
511        let ctx = test_ctx();
512        assert!(ctx.fail_pattern().is_none());
513    }
514
515    #[test]
516    fn fail_pattern_set_and_get() {
517        let mut ctx = test_ctx();
518        ctx.set_fail_pattern(Some(FailPattern::Literal("ERR".into())));
519        assert!(ctx.fail_pattern().is_some());
520    }
521
522    #[test]
523    fn fail_pattern_call_frame_isolated() {
524        let mut ctx = test_ctx();
525        ctx.set_fail_pattern(Some(FailPattern::Literal("shell".into())));
526        ctx.push_call("fn".into(), vec![]);
527        // Call inherits shell's fail pattern
528        assert!(ctx.fail_pattern().is_some());
529        ctx.set_fail_pattern(None);
530        assert!(ctx.fail_pattern().is_none());
531        ctx.pop_call();
532        // Shell still has its pattern
533        assert!(ctx.fail_pattern().is_some());
534    }
535
536    // ─── Name resolution ─────────────────────────────────────
537
538    #[test]
539    fn current_name_shell() {
540        let ctx = test_ctx();
541        assert_eq!(ctx.current_name(), "sh");
542    }
543
544    #[test]
545    fn current_name_alias() {
546        let mut ctx = test_ctx();
547        ctx.shell.alias = Some("mydb".into());
548        assert_eq!(ctx.current_name(), "mydb");
549    }
550
551    #[test]
552    fn current_name_with_prefix() {
553        let mut ctx = test_ctx();
554        ctx.shell.name_prefix = vec!["SetupDb.db".into(), "Db.db".into()];
555        ctx.shell.alias = Some("mydb".into());
556        assert_eq!(ctx.current_name(), "SetupDb.db.Db.db.mydb");
557    }
558
559    #[test]
560    fn current_name_with_prefix_no_alias() {
561        let mut ctx = test_ctx();
562        ctx.shell.name_prefix = vec!["Db.db".into()];
563        assert_eq!(ctx.current_name(), "Db.db.sh");
564    }
565
566    #[test]
567    fn current_name_accumulated_via_export() {
568        let mut ctx = test_ctx();
569        ctx.scope = Scope::Effect {
570            name: "Db".into(),
571            vars: Arc::new(Mutex::new(VarScope::new())),
572            _timeout: None,
573            env: Arc::new(LayeredEnv::root(Env::new())),
574        };
575        ctx.shell.name = "db".into();
576        // First export: Db.db → SetupDb
577        ctx.reset_for_export(Scope::Effect {
578            name: "SetupDb".into(),
579            vars: Arc::new(Mutex::new(VarScope::new())),
580            _timeout: None,
581            env: Arc::new(LayeredEnv::root(Env::new())),
582        });
583        assert_eq!(ctx.shell.name_prefix, vec!["Db.db"]);
584        // Second export: SetupDb.db → test
585        ctx.reset_for_export(test_scope("my test"));
586        assert_eq!(ctx.shell.name_prefix, vec!["Db.db", "SetupDb.db"]);
587        ctx.shell.alias = Some("mydb".into());
588        assert_eq!(ctx.current_name(), "Db.db.SetupDb.db.mydb");
589    }
590
591    // ─── Captures ────────────────────────────────────────────
592
593    #[test]
594    fn capture_in_shell() {
595        let mut ctx = test_ctx();
596        let mut caps = Captures::new();
597        caps.set("0".into(), "whole".into());
598        caps.set("1".into(), "first".into());
599        ctx.set_captures(caps);
600        assert_eq!(ctx.capture(0), Some("whole".into()));
601        assert_eq!(ctx.capture(1), Some("first".into()));
602        assert_eq!(ctx.capture(2), None);
603    }
604
605    #[test]
606    fn capture_in_call_frame() {
607        let mut ctx = test_ctx();
608        let mut shell_caps = Captures::new();
609        shell_caps.set("1".into(), "shell".into());
610        ctx.set_captures(shell_caps);
611
612        ctx.push_call("fn".into(), vec![]);
613        let mut fn_caps = Captures::new();
614        fn_caps.set("1".into(), "fn".into());
615        ctx.set_captures(fn_caps);
616        assert_eq!(ctx.capture(1), Some("fn".into()));
617        ctx.pop_call();
618        assert_eq!(ctx.capture(1), Some("shell".into()));
619    }
620
621    // ─── Reset for export ────────────────────────────────────
622
623    #[tokio::test]
624    async fn reset_for_export_clears_vars_and_captures() {
625        let mut ctx = test_ctx();
626        ctx.shell.vars.insert("x".into(), "v".into());
627        let mut caps = Captures::new();
628        caps.set("1".into(), "c".into());
629        ctx.set_captures(caps);
630        ctx.shell.timeout = Some(IrTimeout::tolerance(Duration::from_secs(99)));
631
632        let new_scope = test_scope("new test");
633        ctx.reset_for_export(new_scope);
634
635        assert_eq!(ctx.lookup("x").await, None);
636        assert_eq!(ctx.capture(1), None);
637        assert_eq!(ctx.scope.name(), "new test");
638        // timeout preserved
639        assert_eq!(
640            ctx.shell.timeout.as_ref().unwrap().raw_duration(),
641            Duration::from_secs(99)
642        );
643    }
644
645    // ─── Effect scope with overlay ───────────────────────────
646
647    #[tokio::test]
648    async fn effect_scope_overlay_lookup() {
649        let mut overlay_map = HashMap::new();
650        overlay_map.insert("PORT".into(), "5432".into());
651
652        let scope = Scope::Effect {
653            name: "Db".into(),
654            vars: Arc::new(Mutex::new(VarScope::new())),
655            _timeout: None,
656            env: Arc::new(LayeredEnv::root(Env::from_map(overlay_map))),
657        };
658        let shell = ShellState::new("db".into(), None);
659        let ctx = ExecutionContext::new(
660            scope,
661            shell,
662            IrTimeout::tolerance(Duration::from_secs(5)),
663            test_env(),
664        );
665        assert_eq!(ctx.lookup("PORT").await, Some("5432".into()));
666    }
667
668    // ─── LayeredEnv chain bugs ─────────────────────────────
669
670    #[tokio::test]
671    async fn effect_scope_lookup_walks_parent_layers() {
672        // Parent layer has BASE_PORT, child overlay has LABEL.
673        // lookup("BASE_PORT") should walk the chain and find it.
674        let mut base = Env::new();
675        base.insert("BASE_PORT".into(), "5432".into());
676        let root = Arc::new(LayeredEnv::root(base));
677
678        let mut overlay = Env::new();
679        overlay.insert("LABEL".into(), "child".into());
680        let child_env = Arc::new(LayeredEnv::child(root, overlay));
681
682        let scope = Scope::Effect {
683            name: "Child".into(),
684            vars: Arc::new(Mutex::new(VarScope::new())),
685            _timeout: None,
686            env: child_env,
687        };
688        let shell = ShellState::new("s".into(), None);
689        let ctx = ExecutionContext::new(
690            scope,
691            shell,
692            IrTimeout::tolerance(Duration::from_secs(5)),
693            test_env(),
694        );
695        // lookup walks the chain — this works correctly
696        assert_eq!(ctx.lookup("BASE_PORT").await, Some("5432".into()));
697        assert_eq!(ctx.lookup("LABEL").await, Some("child".into()));
698    }
699
700    #[test]
701    fn process_env_includes_parent_layer_variables() {
702        // Regression test: process_env() must include variables from parent
703        // LayeredEnv layers, not just the immediate layer.
704        let mut base = Env::new();
705        base.insert("BASE_PORT".into(), "5432".into());
706        let root = Arc::new(LayeredEnv::root(base));
707
708        let mut overlay = Env::new();
709        overlay.insert("LABEL".into(), "child".into());
710        let child_env = Arc::new(LayeredEnv::child(root, overlay));
711
712        let scope = Scope::Effect {
713            name: "Child".into(),
714            vars: Arc::new(Mutex::new(VarScope::new())),
715            _timeout: None,
716            env: child_env,
717        };
718        let shell = ShellState::new("s".into(), None);
719        let ctx = ExecutionContext::new(
720            scope,
721            shell,
722            IrTimeout::tolerance(Duration::from_secs(5)),
723            test_env(),
724        );
725        let penv: HashMap<String, String> = ctx.process_env().into_iter().collect();
726        // Child's own overlay should be present
727        assert_eq!(penv.get("LABEL"), Some(&"child".to_string()));
728        // Parent layer variable should also be present in the PTY env
729        assert_eq!(
730            penv.get("BASE_PORT"),
731            Some(&"5432".to_string()),
732            "process_env must include variables from parent LayeredEnv layers"
733        );
734    }
735
736    // ─── Captures unit tests ────────────────────────────────
737
738    #[test]
739    fn captures_new_is_empty() {
740        let c = Captures::new();
741        assert_eq!(c.get_indexed(0), None);
742        assert_eq!(c.get_named("foo"), None);
743    }
744
745    #[test]
746    fn captures_set_and_get_indexed() {
747        let mut c = Captures::new();
748        c.set("0".into(), "whole".into());
749        c.set("1".into(), "first".into());
750        assert_eq!(c.get_indexed(0), Some("whole"));
751        assert_eq!(c.get_indexed(1), Some("first"));
752        assert_eq!(c.get_indexed(2), None);
753    }
754
755    #[test]
756    fn captures_set_and_get_named() {
757        let mut c = Captures::new();
758        c.set("host".into(), "localhost".into());
759        assert_eq!(c.get_named("host"), Some("localhost"));
760        assert_eq!(c.get_named("port"), None);
761    }
762
763    #[test]
764    fn captures_get_generic() {
765        let mut c = Captures::new();
766        c.set("1".into(), "idx".into());
767        c.set("name".into(), "named".into());
768        assert_eq!(c.get("1"), Some("idx"));
769        assert_eq!(c.get("name"), Some("named"));
770    }
771
772    #[test]
773    fn captures_clear() {
774        let mut c = Captures::new();
775        c.set("1".into(), "val".into());
776        c.clear();
777        assert_eq!(c.get("1"), None);
778    }
779
780    #[test]
781    fn captures_clone() {
782        let mut c = Captures::new();
783        c.set("1".into(), "val".into());
784        let cloned = c.clone();
785        assert_eq!(cloned.get("1"), Some("val"));
786    }
787}