oxdock_core/
exec.rs

1use anyhow::{Context, Result, anyhow, bail};
2use std::collections::HashMap;
3use std::io::{self, Write};
4use std::process::ExitStatus;
5
6use oxdock_fs::{EntryKind, GuardedPath, PathResolver, WorkspaceFs};
7use oxdock_parser::{Step, StepKind, WorkspaceTarget};
8use oxdock_process::{
9    BackgroundHandle, BuiltinEnv, CommandContext, ProcessManager, default_process_manager,
10    expand_command_env,
11};
12
13struct ExecState<P: ProcessManager> {
14    fs: Box<dyn WorkspaceFs>,
15    cargo_target_dir: GuardedPath,
16    cwd: GuardedPath,
17    envs: HashMap<String, String>,
18    bg_children: Vec<P::Handle>,
19    scope_stack: Vec<ScopeSnapshot>,
20}
21
22struct ScopeSnapshot {
23    cwd: GuardedPath,
24    root: GuardedPath,
25    envs: HashMap<String, String>,
26}
27
28impl<P: ProcessManager> ExecState<P> {
29    fn command_ctx(&self) -> CommandContext {
30        CommandContext::new(
31            &self.cwd.clone().into(),
32            &self.envs,
33            &self.cargo_target_dir,
34            self.fs.root(),
35            self.fs.build_context(),
36        )
37    }
38}
39
40pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
41    run_steps_with_context(fs_root, fs_root, steps)
42}
43
44pub fn run_steps_with_context(
45    fs_root: &GuardedPath,
46    build_context: &GuardedPath,
47    steps: &[Step],
48) -> Result<()> {
49    run_steps_with_context_result(fs_root, build_context, steps).map(|_| ())
50}
51
52/// Execute the DSL and return the final working directory after all steps.
53pub fn run_steps_with_context_result(
54    fs_root: &GuardedPath,
55    build_context: &GuardedPath,
56    steps: &[Step],
57) -> Result<GuardedPath> {
58    match run_steps_inner(fs_root, build_context, steps) {
59        Ok(final_cwd) => Ok(final_cwd),
60        Err(err) => {
61            // Compose a single error message with the top cause plus a compact fs snapshot.
62            let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
63            let mut primary = chain
64                .first()
65                .cloned()
66                .unwrap_or_else(|| "unknown error".into());
67            let rest = if chain.len() > 1 {
68                let first_cause = chain[1].clone();
69                primary = format!("{primary} ({first_cause})");
70                if chain.len() > 2 {
71                    let causes = chain
72                        .iter()
73                        .skip(2)
74                        .map(|s| s.as_str())
75                        .collect::<Vec<_>>()
76                        .join("\n  ");
77                    format!("\ncauses:\n  {}", causes)
78                } else {
79                    String::new()
80                }
81            } else {
82                String::new()
83            };
84            let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
85            let tree = describe_dir(&fs, fs_root, 2, 24);
86            let snapshot = format!(
87                "filesystem snapshot (root {}):\n{}",
88                fs_root.display(),
89                tree
90            );
91            let msg = format!("{}{}\n{}", primary, rest, snapshot);
92            Err(anyhow::anyhow!(msg))
93        }
94    }
95}
96
97fn run_steps_inner(
98    fs_root: &GuardedPath,
99    build_context: &GuardedPath,
100    steps: &[Step],
101) -> Result<GuardedPath> {
102    let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
103    resolver.set_workspace_root(build_context.clone());
104    run_steps_with_fs(Box::new(resolver), steps)
105}
106
107pub fn run_steps_with_fs(fs: Box<dyn WorkspaceFs>, steps: &[Step]) -> Result<GuardedPath> {
108    run_steps_with_manager(fs, steps, default_process_manager())
109}
110
111fn run_steps_with_manager<P: ProcessManager>(
112    fs: Box<dyn WorkspaceFs>,
113    steps: &[Step],
114    process: P,
115) -> Result<GuardedPath> {
116    let fs_root = fs.root().clone();
117    let cwd = fs.root().clone();
118    let build_context = fs.build_context().clone();
119    let envs = BuiltinEnv::collect(&build_context).into_envs();
120    let mut state = ExecState {
121        fs,
122        cargo_target_dir: fs_root.join(".cargo-target")?,
123        cwd,
124        envs,
125        bg_children: Vec::new(),
126        scope_stack: Vec::new(),
127    };
128
129    let mut stdout = io::stdout();
130    let mut proc_mgr = process;
131    execute_steps(&mut state, &mut proc_mgr, steps, false, &mut stdout)?;
132
133    Ok(state.cwd)
134}
135
136fn execute_steps<P: ProcessManager>(
137    state: &mut ExecState<P>,
138    process: &mut P,
139    steps: &[Step],
140    capture_output: bool,
141    out: &mut dyn Write,
142) -> Result<()> {
143    let fs_root = state.fs.root().clone();
144    let build_context = state.fs.build_context().clone();
145
146    let check_bg = |bg: &mut Vec<P::Handle>| -> Result<Option<ExitStatus>> {
147        let mut finished: Option<ExitStatus> = None;
148        for child in bg.iter_mut() {
149            if let Some(status) = child.try_wait()? {
150                finished = Some(status);
151                break;
152            }
153        }
154        if let Some(status) = finished {
155            // Tear down remaining background children.
156            for child in bg.iter_mut() {
157                if child.try_wait()?.is_none() {
158                    let _ = child.kill();
159                    let _ = child.wait();
160                }
161            }
162            bg.clear();
163            return Ok(Some(status));
164        }
165        Ok(None)
166    };
167
168    for (idx, step) in steps.iter().enumerate() {
169        if step.scope_enter > 0 {
170            for _ in 0..step.scope_enter {
171                state.scope_stack.push(ScopeSnapshot {
172                    cwd: state.cwd.clone(),
173                    root: state.fs.root().clone(),
174                    envs: state.envs.clone(),
175                });
176            }
177        }
178
179        let should_run = oxdock_parser::guards_allow_any(&step.guards, &state.envs);
180        let step_result = if !should_run {
181            Ok(())
182        } else {
183            (|| {
184                match &step.kind {
185                    StepKind::Workdir(path) => {
186                        state.cwd = state
187                            .fs
188                            .resolve_workdir(&state.cwd, path)
189                            .with_context(|| format!("step {}: WORKDIR {}", idx + 1, path))?;
190                    }
191                    StepKind::Workspace(target) => match target {
192                        WorkspaceTarget::Snapshot => {
193                            state.fs.set_root(fs_root.clone());
194                            state.cwd = state.fs.root().clone();
195                        }
196                        WorkspaceTarget::Local => {
197                            state.fs.set_root(build_context.clone());
198                            state.cwd = state.fs.root().clone();
199                        }
200                    },
201                    StepKind::Env { key, value } => {
202                        let ctx = state.command_ctx();
203                        let rendered = expand_command_env(value, &ctx);
204                        state.envs.insert(key.clone(), rendered);
205                    }
206                    StepKind::Run(cmd) => {
207                        let ctx = state.command_ctx();
208                        let rendered = expand_command_env(cmd, &ctx);
209                        if capture_output {
210                            let output = process
211                                .run_capture(&ctx, &rendered)
212                                .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?;
213                            out.write_all(&output)?;
214                        } else {
215                            process
216                                .run(&ctx, &rendered)
217                                .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?;
218                        }
219                    }
220                    StepKind::Echo(msg) => {
221                        let ctx = state.command_ctx();
222                        let rendered = expand_command_env(msg, &ctx);
223                        writeln!(out, "{}", rendered)?;
224                    }
225                    StepKind::RunBg(cmd) => {
226                        if capture_output {
227                            bail!("RUN_BG is not supported inside CAPTURE");
228                        }
229                        let ctx = state.command_ctx();
230                        let rendered = expand_command_env(cmd, &ctx);
231                        let child = process
232                            .spawn_bg(&ctx, &rendered)
233                            .with_context(|| format!("step {}: RUN_BG {}", idx + 1, rendered))?;
234                        state.bg_children.push(child);
235                    }
236                    StepKind::Copy { from, to } => {
237                        let from_abs = state
238                            .fs
239                            .resolve_copy_source(from)
240                            .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
241                        let to_abs = state
242                            .fs
243                            .resolve_write(&state.cwd, to)
244                            .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
245                        copy_entry(state.fs.as_ref(), &from_abs, &to_abs)
246                            .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
247                    }
248                    StepKind::CopyGit { rev, from, to } => {
249                        let to_abs = state.fs.resolve_write(&state.cwd, to).with_context(|| {
250                            format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
251                        })?;
252                        state
253                            .fs
254                            .copy_from_git(rev, from, &to_abs)
255                            .with_context(|| {
256                                format!("step {}: COPY_GIT {} {} {}", idx + 1, rev, from, to)
257                            })?;
258                    }
259
260                    StepKind::Symlink { from, to } => {
261                        let to_abs = state.fs.resolve_write(&state.cwd, to).with_context(|| {
262                            format!("step {}: SYMLINK {} {}", idx + 1, from, to)
263                        })?;
264                        let from_abs = state.fs.resolve_copy_source(from).with_context(|| {
265                            format!("step {}: SYMLINK {} {}", idx + 1, from, to)
266                        })?;
267                        state.fs.symlink(&from_abs, &to_abs).with_context(|| {
268                            format!("step {}: SYMLINK {} {}", idx + 1, from, to)
269                        })?;
270                    }
271                    StepKind::Mkdir(path) => {
272                        let target = state
273                            .fs
274                            .resolve_write(&state.cwd, path)
275                            .with_context(|| format!("step {}: MKDIR {}", idx + 1, path))?;
276                        state.fs.create_dir_all(&target).with_context(|| {
277                            format!("failed to create dir {}", target.display())
278                        })?;
279                    }
280                    StepKind::Ls(path_opt) => {
281                        let dir = if let Some(p) = path_opt.as_deref() {
282                            state
283                                .fs
284                                .resolve_read(&state.cwd, p)
285                                .with_context(|| format!("step {}: LS {}", idx + 1, p))?
286                        } else {
287                            state.cwd.clone()
288                        };
289                        let mut entries = state
290                            .fs
291                            .read_dir_entries(&dir)
292                            .with_context(|| format!("failed to read dir {}", dir.display()))?;
293                        entries.sort_by_key(|a| a.file_name());
294                        writeln!(out, "{}:", dir.display())?;
295                        for entry in entries {
296                            writeln!(out, "{}", entry.file_name().to_string_lossy())?;
297                        }
298                    }
299                    StepKind::Cwd => {
300                        // Print the canonical (physical) current working directory to stdout.
301                        let real =
302                            canonical_cwd(state.fs.as_ref(), &state.cwd).with_context(|| {
303                                format!(
304                                    "step {}: CWD failed to canonicalize {}",
305                                    idx + 1,
306                                    state.cwd.display()
307                                )
308                            })?;
309                        writeln!(out, "{}", real)?;
310                    }
311                    StepKind::Cat(path) => {
312                        let target = state
313                            .fs
314                            .resolve_read(&state.cwd, path)
315                            .with_context(|| format!("step {}: CAT {}", idx + 1, path))?;
316                        let data = state
317                            .fs
318                            .read_file(&target)
319                            .with_context(|| format!("failed to read {}", target.display()))?;
320                        out.write_all(&data).with_context(|| {
321                            format!("failed to write {} to stdout", target.display())
322                        })?;
323                    }
324                    StepKind::Write { path, contents } => {
325                        let target = state
326                            .fs
327                            .resolve_write(&state.cwd, path)
328                            .with_context(|| format!("step {}: WRITE {}", idx + 1, path))?;
329                        state.fs.ensure_parent_dir(&target).with_context(|| {
330                            format!("failed to create parent for {}", target.display())
331                        })?;
332                        let ctx = state.command_ctx();
333                        let rendered = expand_command_env(contents, &ctx);
334                        state
335                            .fs
336                            .write_file(&target, rendered.as_bytes())
337                            .with_context(|| format!("failed to write {}", target.display()))?;
338                    }
339                    StepKind::Capture { path, cmd } => {
340                        let target = state
341                            .fs
342                            .resolve_write(&state.cwd, path)
343                            .with_context(|| format!("step {}: CAPTURE {}", idx + 1, path))?;
344                        state.fs.ensure_parent_dir(&target).with_context(|| {
345                            format!("failed to create parent for {}", target.display())
346                        })?;
347                        let steps = oxdock_parser::parse_script(cmd)
348                            .with_context(|| format!("step {}: CAPTURE parse failed", idx + 1))?;
349                        if steps.len() != 1 {
350                            bail!("CAPTURE expects exactly one instruction");
351                        }
352                        let mut sub_state = ExecState {
353                            fs: {
354                                let mut resolver = PathResolver::new(
355                                    state.fs.root().as_path(),
356                                    state.fs.build_context().as_path(),
357                                )?;
358                                resolver.set_workspace_root(state.fs.build_context().clone());
359                                Box::new(resolver)
360                            },
361                            cargo_target_dir: state.cargo_target_dir.clone(),
362                            cwd: state.cwd.clone(),
363                            envs: state.envs.clone(),
364                            bg_children: Vec::new(),
365                            scope_stack: Vec::new(),
366                        };
367                        let mut sub_process = process.clone();
368                        let mut buf: Vec<u8> = Vec::new();
369                        execute_steps(&mut sub_state, &mut sub_process, &steps, true, &mut buf)?;
370                        state
371                            .fs
372                            .write_file(&target, &buf)
373                            .with_context(|| format!("failed to write {}", target.display()))?;
374                    }
375                    StepKind::Exit(code) => {
376                        for child in state.bg_children.iter_mut() {
377                            if child.try_wait()?.is_none() {
378                                let _ = child.kill();
379                                let _ = child.wait();
380                            }
381                        }
382                        state.bg_children.clear();
383                        bail!("EXIT requested with code {}", code);
384                    }
385                }
386                Ok(())
387            })()
388        };
389
390        let restore_result = restore_scopes(state, step.scope_exit);
391        step_result?;
392        restore_result?;
393        if let Some(status) = check_bg(&mut state.bg_children)? {
394            if status.success() {
395                return Ok(());
396            } else {
397                bail!("RUN_BG exited with status {}", status);
398            }
399        }
400    }
401
402    if !state.bg_children.is_empty() {
403        let mut first = state.bg_children.remove(0);
404        let status = first.wait()?;
405        for child in state.bg_children.iter_mut() {
406            if child.try_wait()?.is_none() {
407                let _ = child.kill();
408                let _ = child.wait();
409            }
410        }
411        state.bg_children.clear();
412        if status.success() {
413            return Ok(());
414        } else {
415            bail!("RUN_BG exited with status {}", status);
416        }
417    }
418
419    Ok(())
420}
421
422fn restore_scopes<P: ProcessManager>(state: &mut ExecState<P>, count: usize) -> Result<()> {
423    for _ in 0..count {
424        let snapshot = state
425            .scope_stack
426            .pop()
427            .ok_or_else(|| anyhow!("scope stack underflow during pop"))?;
428        state.fs.set_root(snapshot.root.clone());
429        state.cwd = snapshot.cwd;
430        state.envs = snapshot.envs;
431    }
432    Ok(())
433}
434
435fn copy_entry(fs: &dyn WorkspaceFs, src: &GuardedPath, dst: &GuardedPath) -> Result<()> {
436    match fs.entry_kind(src)? {
437        EntryKind::Dir => {
438            fs.copy_dir_recursive(src, dst)?;
439        }
440        EntryKind::File => {
441            fs.ensure_parent_dir(dst)?;
442            fs.copy_file(src, dst)?;
443        }
444    }
445    Ok(())
446}
447
448fn canonical_cwd(fs: &dyn WorkspaceFs, cwd: &GuardedPath) -> Result<String> {
449    Ok(fs.canonicalize(cwd)?.display().to_string())
450}
451
452fn describe_dir(
453    fs: &dyn WorkspaceFs,
454    root: &GuardedPath,
455    max_depth: usize,
456    max_entries: usize,
457) -> String {
458    fn helper(
459        fs: &dyn WorkspaceFs,
460        guard_root: &GuardedPath,
461        path: &GuardedPath,
462        depth: usize,
463        max_depth: usize,
464        left: &mut usize,
465        out: &mut String,
466    ) {
467        if *left == 0 {
468            return;
469        }
470        let indent = "  ".repeat(depth);
471        if depth > 0 {
472            out.push_str(&format!(
473                "{}{}\n",
474                indent,
475                path.as_path()
476                    .file_name()
477                    .unwrap_or_default()
478                    .to_string_lossy()
479            ));
480        }
481        if depth >= max_depth {
482            return;
483        }
484        let entries = match fs.read_dir_entries(path) {
485            Ok(e) => e,
486            Err(_) => return,
487        };
488        let mut names: Vec<_> = entries.into_iter().collect();
489        names.sort_by_key(|a| a.file_name());
490        for entry in names {
491            if *left == 0 {
492                return;
493            }
494            *left -= 1;
495            let file_type = match entry.file_type() {
496                Ok(ft) => ft,
497                Err(_) => continue,
498            };
499            let p = entry.path();
500            let guarded_child = match GuardedPath::new(guard_root.root(), &p) {
501                Ok(child) => child,
502                Err(_) => continue,
503            };
504            if file_type.is_dir() {
505                helper(
506                    fs,
507                    guard_root,
508                    &guarded_child,
509                    depth + 1,
510                    max_depth,
511                    left,
512                    out,
513                );
514            } else {
515                out.push_str(&format!(
516                    "{}  {}\n",
517                    indent,
518                    entry.file_name().to_string_lossy()
519                ));
520            }
521        }
522    }
523
524    let mut out = String::new();
525    let mut left = max_entries;
526    helper(fs, root, root, 0, max_depth, &mut left, &mut out);
527    out
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use oxdock_fs::{GuardedPath, MockFs};
534    use oxdock_parser::Guard;
535    use oxdock_process::{MockProcessManager, MockRunCall};
536    use std::collections::HashMap;
537
538    #[test]
539    fn run_records_env_and_cwd() {
540        let root = GuardedPath::new_root_from_str(".").unwrap();
541        let steps = vec![
542            Step {
543                guards: Vec::new(),
544                kind: StepKind::Env {
545                    key: "FOO".into(),
546                    value: "bar".into(),
547                },
548                scope_enter: 0,
549                scope_exit: 0,
550            },
551            Step {
552                guards: Vec::new(),
553                kind: StepKind::Run("echo hi".into()),
554                scope_enter: 0,
555                scope_exit: 0,
556            },
557        ];
558        let mock = MockProcessManager::default();
559        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
560        run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
561        let runs = mock.recorded_runs();
562        assert_eq!(runs.len(), 1);
563        let MockRunCall {
564            script,
565            cwd,
566            envs,
567            cargo_target_dir,
568        } = &runs[0];
569        assert_eq!(script, "echo hi");
570        assert_eq!(cwd, root.as_path());
571        assert_eq!(
572            cargo_target_dir,
573            &root.join(".cargo-target").unwrap().to_path_buf()
574        );
575        assert_eq!(envs.get("FOO"), Some(&"bar".into()));
576    }
577
578    #[test]
579    fn run_expands_env_values() {
580        let root = GuardedPath::new_root_from_str(".").unwrap();
581        let steps = vec![
582            Step {
583                guards: Vec::new(),
584                kind: StepKind::Env {
585                    key: "FOO".into(),
586                    value: "bar".into(),
587                },
588                scope_enter: 0,
589                scope_exit: 0,
590            },
591            Step {
592                guards: Vec::new(),
593                kind: StepKind::Run("echo ${FOO}".into()),
594                scope_enter: 0,
595                scope_exit: 0,
596            },
597        ];
598        let mock = MockProcessManager::default();
599        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
600        run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
601        let runs = mock.recorded_runs();
602        assert_eq!(runs.len(), 1);
603        assert_eq!(runs[0].script, "echo bar");
604    }
605
606    #[test]
607    fn run_bg_completion_short_circuits_pipeline() {
608        let root = GuardedPath::new_root_from_str(".").unwrap();
609        let steps = vec![
610            Step {
611                guards: Vec::new(),
612                kind: StepKind::RunBg("sleep".into()),
613                scope_enter: 0,
614                scope_exit: 0,
615            },
616            Step {
617                guards: Vec::new(),
618                kind: StepKind::Run("echo after".into()),
619                scope_enter: 0,
620                scope_exit: 0,
621            },
622        ];
623        let mock = MockProcessManager::default();
624        mock.push_bg_plan(0, success_status());
625        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
626        run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
627        assert!(
628            mock.recorded_runs().is_empty(),
629            "foreground run should not execute when RUN_BG completes early"
630        );
631        let spawns = mock.spawn_log();
632        let spawned: Vec<_> = spawns.iter().map(|c| c.script.as_str()).collect();
633        assert_eq!(spawned, vec!["sleep"]);
634    }
635
636    #[test]
637    fn exit_kills_background_processes() {
638        let root = GuardedPath::new_root_from_str(".").unwrap();
639        let steps = vec![
640            Step {
641                guards: Vec::new(),
642                kind: StepKind::RunBg("bg-task".into()),
643                scope_enter: 0,
644                scope_exit: 0,
645            },
646            Step {
647                guards: Vec::new(),
648                kind: StepKind::Exit(5),
649                scope_enter: 0,
650                scope_exit: 0,
651            },
652        ];
653        let mock = MockProcessManager::default();
654        mock.push_bg_plan(usize::MAX, success_status());
655        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
656        let err = run_steps_with_manager(fs, &steps, mock.clone()).unwrap_err();
657        assert!(
658            err.to_string().contains("EXIT requested with code 5"),
659            "unexpected error: {err}"
660        );
661        assert_eq!(mock.killed(), vec!["bg-task"]);
662    }
663
664    #[test]
665    fn symlink_errors_report_underlying_cause() {
666        let temp = GuardedPath::tempdir().unwrap();
667        let root = temp.as_guarded_path().clone();
668        let steps = vec![
669            Step {
670                guards: Vec::new(),
671                kind: StepKind::Mkdir("client".into()),
672                scope_enter: 0,
673                scope_exit: 0,
674            },
675            Step {
676                guards: Vec::new(),
677                kind: StepKind::Symlink {
678                    from: "client".into(),
679                    to: "client".into(),
680                },
681                scope_enter: 0,
682                scope_exit: 0,
683            },
684        ];
685        let err = run_steps(&root, &steps).unwrap_err();
686        let msg = err.to_string();
687        assert!(
688            msg.contains("step 2: SYMLINK client client"),
689            "error should include step context: {msg}"
690        );
691        assert!(
692            msg.contains("SYMLINK destination already exists"),
693            "error should surface underlying cause: {msg}"
694        );
695    }
696
697    #[test]
698    fn guarded_run_waits_for_env_to_be_set() {
699        let root = GuardedPath::new_root_from_str(".").unwrap();
700        let guard = Guard::EnvEquals {
701            key: "READY".into(),
702            value: "1".into(),
703            invert: false,
704        };
705        let steps = vec![
706            Step {
707                guards: vec![vec![guard.clone()]],
708                kind: StepKind::Run("echo first".into()),
709                scope_enter: 0,
710                scope_exit: 0,
711            },
712            Step {
713                guards: Vec::new(),
714                kind: StepKind::Env {
715                    key: "READY".into(),
716                    value: "1".into(),
717                },
718                scope_enter: 0,
719                scope_exit: 0,
720            },
721            Step {
722                guards: vec![vec![guard]],
723                kind: StepKind::Run("echo second".into()),
724                scope_enter: 0,
725                scope_exit: 0,
726            },
727        ];
728        let mock = MockProcessManager::default();
729        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
730        run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
731        let runs = mock.recorded_runs();
732        assert_eq!(runs.len(), 1);
733        assert_eq!(runs[0].script, "echo second");
734    }
735
736    #[test]
737    fn guard_groups_allow_any_matching_branch() {
738        let root = GuardedPath::new_root_from_str(".").unwrap();
739        let guard_alpha = Guard::EnvEquals {
740            key: "MODE".into(),
741            value: "alpha".into(),
742            invert: false,
743        };
744        let guard_beta = Guard::EnvEquals {
745            key: "MODE".into(),
746            value: "beta".into(),
747            invert: false,
748        };
749        let steps = vec![
750            Step {
751                guards: Vec::new(),
752                kind: StepKind::Env {
753                    key: "MODE".into(),
754                    value: "beta".into(),
755                },
756                scope_enter: 0,
757                scope_exit: 0,
758            },
759            Step {
760                guards: vec![vec![guard_alpha], vec![guard_beta]],
761                kind: StepKind::Run("echo guarded".into()),
762                scope_enter: 0,
763                scope_exit: 0,
764            },
765        ];
766        let mock = MockProcessManager::default();
767        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
768        run_steps_with_manager(fs, &steps, mock.clone()).unwrap();
769        let runs = mock.recorded_runs();
770        assert_eq!(runs.len(), 1);
771        assert_eq!(runs[0].script, "echo guarded");
772    }
773
774    #[test]
775    fn capture_rejects_multiple_instructions() {
776        let root = GuardedPath::new_root_from_str(".").unwrap();
777        let capture = Step {
778            guards: Vec::new(),
779            kind: StepKind::Capture {
780                path: "out.txt".into(),
781                cmd: "WRITE one 1; WRITE two 2".into(),
782            },
783            scope_enter: 0,
784            scope_exit: 0,
785        };
786        let mock = MockProcessManager::default();
787        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
788        let err = run_steps_with_manager(fs, &[capture], mock).unwrap_err();
789        assert!(
790            err.to_string()
791                .contains("CAPTURE expects exactly one instruction"),
792            "unexpected error: {err}"
793        );
794    }
795
796    fn success_status() -> ExitStatus {
797        exit_status_from_code(0)
798    }
799
800    #[cfg(unix)]
801    fn exit_status_from_code(code: i32) -> ExitStatus {
802        use std::os::unix::process::ExitStatusExt;
803        ExitStatusExt::from_raw(code << 8)
804    }
805
806    #[cfg(windows)]
807    fn exit_status_from_code(code: i32) -> ExitStatus {
808        use std::os::windows::process::ExitStatusExt;
809        ExitStatusExt::from_raw(code as u32)
810    }
811
812    fn create_exec_state(fs: MockFs) -> ExecState<MockProcessManager> {
813        let cargo = fs.root().join(".cargo-target").unwrap();
814        ExecState {
815            fs: Box::new(fs.clone()),
816            cargo_target_dir: cargo,
817            cwd: fs.root().clone(),
818            envs: HashMap::new(),
819            bg_children: Vec::new(),
820            scope_stack: Vec::new(),
821        }
822    }
823
824    fn run_with_mock_fs(steps: &[Step]) -> (GuardedPath, HashMap<String, Vec<u8>>) {
825        let fs = MockFs::new();
826        let mut state = create_exec_state(fs.clone());
827        let mut proc = MockProcessManager::default();
828        let mut sink = Vec::new();
829        execute_steps(&mut state, &mut proc, steps, false, &mut sink).unwrap();
830        (state.cwd, fs.snapshot())
831    }
832
833    #[test]
834    fn mock_fs_handles_workdir_and_write() {
835        let steps = vec![
836            Step {
837                guards: Vec::new(),
838                kind: StepKind::Mkdir("app".into()),
839                scope_enter: 0,
840                scope_exit: 0,
841            },
842            Step {
843                guards: Vec::new(),
844                kind: StepKind::Workdir("app".into()),
845                scope_enter: 0,
846                scope_exit: 0,
847            },
848            Step {
849                guards: Vec::new(),
850                kind: StepKind::Write {
851                    path: "out.txt".into(),
852                    contents: "hi".into(),
853                },
854                scope_enter: 0,
855                scope_exit: 0,
856            },
857            Step {
858                guards: Vec::new(),
859                kind: StepKind::Cat("out.txt".into()),
860                scope_enter: 0,
861                scope_exit: 0,
862            },
863        ];
864        let (_cwd, files) = run_with_mock_fs(&steps);
865        let written = files
866            .iter()
867            .find(|(k, _)| k.ends_with("app/out.txt"))
868            .map(|(_, v)| String::from_utf8_lossy(v).to_string());
869        assert_eq!(written, Some("hi".into()));
870    }
871
872    #[test]
873    fn write_interpolates_env_values() {
874        let steps = vec![
875            Step {
876                guards: Vec::new(),
877                kind: StepKind::Env {
878                    key: "FOO".into(),
879                    value: "bar".into(),
880                },
881                scope_enter: 0,
882                scope_exit: 0,
883            },
884            Step {
885                guards: Vec::new(),
886                kind: StepKind::Env {
887                    key: "BAZ".into(),
888                    value: "${FOO}-baz".into(),
889                },
890                scope_enter: 0,
891                scope_exit: 0,
892            },
893            Step {
894                guards: Vec::new(),
895                kind: StepKind::Write {
896                    path: "out.txt".into(),
897                    contents: "val ${BAZ}".into(),
898                },
899                scope_enter: 0,
900                scope_exit: 0,
901            },
902        ];
903        let (_cwd, files) = run_with_mock_fs(&steps);
904        let written = files
905            .iter()
906            .find(|(k, _)| k.ends_with("out.txt"))
907            .map(|(_, v)| String::from_utf8_lossy(v).to_string());
908        assert_eq!(written, Some("val bar-baz".into()));
909    }
910
911    #[test]
912    fn final_cwd_tracks_last_workdir() {
913        let steps = vec![
914            Step {
915                guards: Vec::new(),
916                kind: StepKind::Write {
917                    path: "temp.txt".into(),
918                    contents: "123".into(),
919                },
920                scope_enter: 0,
921                scope_exit: 0,
922            },
923            Step {
924                guards: Vec::new(),
925                kind: StepKind::Workdir("sub".into()),
926                scope_enter: 0,
927                scope_exit: 0,
928            },
929        ];
930        let (cwd, snapshot) = run_with_mock_fs(&steps);
931        assert!(
932            cwd.as_path().ends_with("sub"),
933            "expected final cwd to match last WORKDIR, got {}",
934            cwd.display()
935        );
936        let keys: Vec<_> = snapshot.keys().cloned().collect();
937        assert!(
938            keys.iter().any(|path| path.ends_with("temp.txt")),
939            "WRITE should produce temp file, snapshot: {:?}",
940            keys
941        );
942    }
943
944    #[test]
945    fn mock_fs_normalizes_backslash_workdir() {
946        let steps = vec![
947            Step {
948                guards: Vec::new(),
949                kind: StepKind::Mkdir("win\\nested".into()),
950                scope_enter: 0,
951                scope_exit: 0,
952            },
953            Step {
954                guards: Vec::new(),
955                kind: StepKind::Workdir("win\\nested".into()),
956                scope_enter: 0,
957                scope_exit: 0,
958            },
959            Step {
960                guards: Vec::new(),
961                kind: StepKind::Write {
962                    path: "inner.txt".into(),
963                    contents: "ok".into(),
964                },
965                scope_enter: 0,
966                scope_exit: 0,
967            },
968        ];
969        let (cwd, snapshot) = run_with_mock_fs(&steps);
970        let cwd_display = cwd.display().to_string();
971        assert!(
972            cwd_display.ends_with("win\\nested") || cwd_display.ends_with("win/nested"),
973            "expected cwd to normalize backslashes, got {cwd_display}"
974        );
975        assert!(
976            snapshot
977                .keys()
978                .any(|path| path.ends_with("win/nested/inner.txt")),
979            "expected file under normalized path, snapshot: {:?}",
980            snapshot.keys()
981        );
982    }
983
984    #[cfg(windows)]
985    #[test]
986    fn mock_fs_rejects_absolute_windows_paths() {
987        let steps = vec![Step {
988            guards: Vec::new(),
989            kind: StepKind::Workdir(r"C:\outside".into()),
990            scope_enter: 0,
991            scope_exit: 0,
992        }];
993        let fs = MockFs::new();
994        let mut state = create_exec_state(fs);
995        let mut proc = MockProcessManager::default();
996        let mut sink = Vec::new();
997        let err = execute_steps(&mut state, &mut proc, &steps, false, &mut sink).unwrap_err();
998        let msg = format!("{err:#}");
999        assert!(
1000            msg.contains("escapes allowed root"),
1001            "unexpected error for absolute Windows path: {msg}"
1002        );
1003    }
1004}