oxdock_core/
exec.rs

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