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