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#[derive(Clone, Debug)]
14pub enum FailPattern {
15 Regex(Regex),
16 Literal(String),
17}
18
19#[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 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#[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
86pub struct ShellState {
89 pub name: String,
90 pub alias: Option<String>,
91 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
114pub 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
124pub 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 pub async fn lookup(&self, key: &str) -> Option<String> {
152 if let Some(frame) = self.call_stack.last() {
153 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 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 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 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 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 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 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 pub fn pop_call(&mut self) {
227 self.call_stack.pop();
228 }
229
230 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 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 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 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 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 pub fn reset_for_export(&mut self, new_scope: Scope) {
284 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 }
292
293 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 pub fn in_call(&self) -> bool {
304 !self.call_stack.is_empty()
305 }
306
307 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 #[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 #[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 assert_eq!(ctx.lookup("arg").await, Some("argval".into()));
413 assert_eq!(ctx.lookup("outer").await, None);
415 assert_eq!(ctx.lookup("PATH").await, Some("/usr/bin".into()));
417 ctx.pop_call();
418 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); ctx.pop_call();
430 assert_eq!(ctx.lookup("a").await, Some("1".into()));
431 ctx.pop_call();
432 }
433
434 #[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 #[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 #[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 #[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 assert!(ctx.fail_pattern().is_some());
529 ctx.set_fail_pattern(None);
530 assert!(ctx.fail_pattern().is_none());
531 ctx.pop_call();
532 assert!(ctx.fail_pattern().is_some());
534 }
535
536 #[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 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 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 #[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 #[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 assert_eq!(
640 ctx.shell.timeout.as_ref().unwrap().raw_duration(),
641 Duration::from_secs(99)
642 );
643 }
644
645 #[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 #[tokio::test]
671 async fn effect_scope_lookup_walks_parent_layers() {
672 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 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 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 assert_eq!(penv.get("LABEL"), Some(&"child".to_string()));
728 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 #[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}