oxdock_core/
exec.rs

1use anyhow::{Context, Result, anyhow, bail};
2use std::collections::{HashMap, HashSet, VecDeque};
3use std::io::{self, Read, Write};
4use std::process::ExitStatus;
5use std::sync::{Arc, Condvar, Mutex};
6
7use oxdock_fs::{EntryKind, GuardedPath, PathResolver, WorkspaceFs, to_forward_slashes};
8use oxdock_parser::{IoStream, Step, StepKind, TemplateString, WorkspaceTarget};
9use oxdock_process::{
10    BackgroundHandle, BuiltinEnv, CommandContext, CommandOptions, CommandResult, CommandStderr,
11    CommandStdout, ProcessManager, SharedInput, SharedOutput, default_process_manager,
12    expand_command_env,
13};
14use sha2::{Digest, Sha256};
15
16#[derive(Clone)]
17enum PipeEndpoint {
18    Stream(SharedOutput),
19    Script(ScriptPipeEndpoint),
20    Inherit,
21}
22
23impl PipeEndpoint {
24    fn stream(writer: SharedOutput) -> Self {
25        PipeEndpoint::Stream(writer)
26    }
27
28    fn script(endpoint: ScriptPipeEndpoint) -> Self {
29        PipeEndpoint::Script(endpoint)
30    }
31
32    fn to_stream_handle(&self) -> StreamHandle {
33        match self {
34            PipeEndpoint::Stream(writer) => StreamHandle::Stream(writer.clone()),
35            PipeEndpoint::Script(endpoint) => StreamHandle::Stream(endpoint.stream_handle()),
36            PipeEndpoint::Inherit => StreamHandle::Inherit,
37        }
38    }
39}
40
41#[derive(Clone, Default)]
42struct PipeOutputs {
43    stdout: Option<PipeEndpoint>,
44    stderr: Option<PipeEndpoint>,
45}
46
47struct ScriptPipe {
48    inner: Arc<PipeInner>,
49    reader: SharedInput,
50}
51
52impl ScriptPipe {
53    fn new() -> Self {
54        let inner = Arc::new(PipeInner::new());
55        let reader: SharedInput = Arc::new(Mutex::new(PipeReader::new(inner.clone())));
56        Self { inner, reader }
57    }
58
59    fn reader(&self) -> SharedInput {
60        self.reader.clone()
61    }
62
63    fn endpoint(&self) -> ScriptPipeEndpoint {
64        ScriptPipeEndpoint::new(self.inner.clone())
65    }
66}
67
68#[derive(Clone)]
69struct ScriptPipeEndpoint {
70    inner: Arc<PipeInner>,
71}
72
73impl ScriptPipeEndpoint {
74    fn new(inner: Arc<PipeInner>) -> Self {
75        Self { inner }
76    }
77
78    fn stream_handle(&self) -> SharedOutput {
79        Arc::new(Mutex::new(PipeWriter::new(self.inner.clone())))
80    }
81}
82
83struct PipeInner {
84    state: Mutex<PipeState>,
85    ready: Condvar,
86}
87
88impl PipeInner {
89    fn new() -> Self {
90        Self {
91            state: Mutex::new(PipeState::new()),
92            ready: Condvar::new(),
93        }
94    }
95
96    fn attach_writer(&self) {
97        let mut state = self.lock_state();
98        state.writers += 1;
99        state.closed = false;
100    }
101
102    fn detach_writer(&self) {
103        let mut state = self.lock_state();
104        state.writers = state.writers.saturating_sub(1);
105        if state.writers == 0 {
106            state.closed = true;
107        }
108        drop(state);
109        self.ready.notify_all();
110    }
111
112    fn push_bytes(&self, data: &[u8]) {
113        let mut state = self.lock_state();
114        state.buffer.extend(data.iter().copied());
115        drop(state);
116        self.ready.notify_all();
117    }
118
119    fn read_into(&self, buf: &mut [u8]) -> io::Result<usize> {
120        if buf.is_empty() {
121            return Ok(0);
122        }
123        let mut state = self.lock_state();
124        loop {
125            if !state.buffer.is_empty() {
126                let mut read = 0;
127                while read < buf.len() && !state.buffer.is_empty() {
128                    if let Some(byte) = state.buffer.pop_front() {
129                        buf[read] = byte;
130                        read += 1;
131                    }
132                }
133                return Ok(read);
134            }
135            if state.closed {
136                return Ok(0);
137            }
138            state = self
139                .ready
140                .wait(state)
141                .map_err(|_| io::Error::other("pipe wait poisoned"))?;
142        }
143    }
144
145    fn lock_state(&self) -> std::sync::MutexGuard<'_, PipeState> {
146        self.state.lock().expect("script pipe state poisoned")
147    }
148}
149
150struct PipeState {
151    buffer: VecDeque<u8>,
152    writers: usize,
153    closed: bool,
154}
155
156impl PipeState {
157    fn new() -> Self {
158        Self {
159            buffer: VecDeque::new(),
160            writers: 0,
161            closed: false,
162        }
163    }
164}
165
166struct PipeReader {
167    inner: Arc<PipeInner>,
168}
169
170impl PipeReader {
171    fn new(inner: Arc<PipeInner>) -> Self {
172        Self { inner }
173    }
174}
175
176impl Read for PipeReader {
177    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
178        self.inner.read_into(buf)
179    }
180}
181
182struct PipeWriter {
183    inner: Arc<PipeInner>,
184}
185
186impl PipeWriter {
187    fn new(inner: Arc<PipeInner>) -> Self {
188        inner.attach_writer();
189        Self { inner }
190    }
191}
192
193impl Write for PipeWriter {
194    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
195        self.inner.push_bytes(buf);
196        Ok(buf.len())
197    }
198
199    fn flush(&mut self) -> io::Result<()> {
200        Ok(())
201    }
202}
203
204impl Drop for PipeWriter {
205    fn drop(&mut self) {
206        self.inner.detach_writer();
207    }
208}
209
210#[derive(Clone, Default)]
211pub struct ExecIo {
212    stdin: Option<SharedInput>,
213    stdout: Option<SharedOutput>,
214    stderr: Option<SharedOutput>,
215    input_pipes: HashMap<String, SharedInput>,
216    output_pipes: HashMap<String, PipeOutputs>,
217    inherit_env_overrides: HashMap<String, String>,
218    inherit_env_removed: HashSet<String>,
219}
220
221#[derive(Clone)]
222enum StreamHandle {
223    Inherit,
224    Stream(SharedOutput),
225}
226
227impl StreamHandle {
228    fn to_stdout(&self) -> CommandStdout {
229        match self {
230            StreamHandle::Stream(writer) => CommandStdout::Stream(writer.clone()),
231            StreamHandle::Inherit => CommandStdout::Inherit,
232        }
233    }
234
235    fn to_stderr(&self) -> CommandStderr {
236        match self {
237            StreamHandle::Stream(writer) => CommandStderr::Stream(writer.clone()),
238            StreamHandle::Inherit => CommandStderr::Inherit,
239        }
240    }
241}
242
243fn write_stdout<F>(handle: Option<StreamHandle>, op: F) -> Result<()>
244where
245    F: FnOnce(&mut dyn Write) -> Result<()>,
246{
247    if let Some(StreamHandle::Stream(writer)) = handle {
248        if let Ok(mut guard) = writer.lock() {
249            op(&mut *guard)?;
250        }
251        Ok(())
252    } else {
253        let mut stdout = io::stdout();
254        op(&mut stdout)
255    }
256}
257
258impl ExecIo {
259    pub fn new() -> Self {
260        Self::default()
261    }
262
263    pub fn set_stdin(&mut self, stdin: Option<SharedInput>) {
264        self.stdin = stdin;
265    }
266
267    pub fn set_stdout(&mut self, stdout: Option<SharedOutput>) {
268        self.stdout = stdout.clone();
269        if self.stderr.is_none() {
270            self.stderr = stdout;
271        }
272    }
273
274    pub fn set_stderr(&mut self, stderr: Option<SharedOutput>) {
275        self.stderr = stderr;
276    }
277
278    pub fn insert_inherit_env<S: Into<String>, V: Into<String>>(&mut self, key: S, value: V) {
279        let key = key.into();
280        self.inherit_env_removed.remove(&key);
281        self.inherit_env_overrides.insert(key, value.into());
282    }
283
284    pub fn remove_inherit_env<S: Into<String>>(&mut self, key: S) {
285        let key = key.into();
286        self.inherit_env_overrides.remove(&key);
287        self.inherit_env_removed.insert(key);
288    }
289
290    pub fn inherit_env_value(&self, key: &str) -> Option<&String> {
291        self.inherit_env_overrides.get(key)
292    }
293
294    pub fn inherit_env_is_removed(&self, key: &str) -> bool {
295        self.inherit_env_removed.contains(key)
296    }
297
298    pub fn insert_input_pipe<S: Into<String>>(&mut self, name: S, reader: SharedInput) {
299        self.input_pipes.insert(name.into(), reader);
300    }
301
302    pub fn insert_output_pipe<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
303        let entry = self.output_pipes.entry(name.into()).or_default();
304        entry.stdout = Some(PipeEndpoint::stream(writer.clone()));
305        entry.stderr = Some(PipeEndpoint::stream(writer));
306    }
307
308    pub fn insert_output_pipe_stdout<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
309        let entry = self.output_pipes.entry(name.into()).or_default();
310        entry.stdout = Some(PipeEndpoint::stream(writer));
311    }
312
313    pub fn insert_output_pipe_stderr<S: Into<String>>(&mut self, name: S, writer: SharedOutput) {
314        let entry = self.output_pipes.entry(name.into()).or_default();
315        entry.stderr = Some(PipeEndpoint::stream(writer));
316    }
317
318    pub fn insert_output_pipe_stdout_inherit<S: Into<String>>(&mut self, name: S) {
319        let entry = self.output_pipes.entry(name.into()).or_default();
320        entry.stdout = Some(PipeEndpoint::Inherit);
321    }
322
323    pub fn insert_output_pipe_stderr_inherit<S: Into<String>>(&mut self, name: S) {
324        let entry = self.output_pipes.entry(name.into()).or_default();
325        entry.stderr = Some(PipeEndpoint::Inherit);
326    }
327
328    fn ensure_script_pipe(&mut self, name: &str) {
329        if self.input_pipes.contains_key(name) || self.output_pipes.contains_key(name) {
330            return;
331        }
332
333        let pipe = ScriptPipe::new();
334        self.input_pipes.insert(name.to_string(), pipe.reader());
335        let endpoint = PipeEndpoint::script(pipe.endpoint());
336        let outputs = PipeOutputs {
337            stdout: Some(endpoint.clone()),
338            stderr: Some(endpoint),
339        };
340        self.output_pipes.insert(name.to_string(), outputs);
341    }
342
343    pub fn stdin(&self) -> Option<SharedInput> {
344        self.stdin.clone()
345    }
346
347    pub fn stdout(&self) -> Option<SharedOutput> {
348        self.stdout.clone()
349    }
350
351    pub fn stderr(&self) -> Option<SharedOutput> {
352        self.stderr.clone().or_else(|| self.stdout.clone())
353    }
354
355    pub fn input_pipe(&self, name: &str) -> Option<SharedInput> {
356        self.input_pipes.get(name).cloned()
357    }
358
359    fn output_pipe_stdout(&self, name: &str) -> Option<PipeEndpoint> {
360        self.output_pipes
361            .get(name)
362            .and_then(|pipe| pipe.stdout.clone())
363    }
364
365    fn output_pipe_stderr(&self, name: &str) -> Option<PipeEndpoint> {
366        self.output_pipes
367            .get(name)
368            .and_then(|pipe| pipe.stderr.clone())
369    }
370}
371
372fn assemble_default_io(stdin: Option<SharedInput>, stdout: Option<SharedOutput>) -> ExecIo {
373    let mut io = ExecIo::new();
374    io.set_stdin(stdin);
375    io.set_stdout(stdout.clone());
376    io.set_stderr(stdout);
377    io
378}
379
380struct ExecState<P: ProcessManager> {
381    fs: Box<dyn WorkspaceFs>,
382    cargo_target_dir: GuardedPath,
383    cwd: GuardedPath,
384    envs: HashMap<String, String>,
385    bg_children: Vec<P::Handle>,
386    scope_stack: Vec<ScopeSnapshot>,
387    io: ExecIo,
388}
389
390struct ScopeSnapshot {
391    cwd: GuardedPath,
392    root: GuardedPath,
393    envs: HashMap<String, String>,
394}
395
396impl<P: ProcessManager> ExecState<P> {
397    fn command_ctx(&self) -> Result<CommandContext> {
398        // Build a CommandContext snapshot for this step. The `cargo_target_dir`
399        // here is the executor default; if callers want to override it they
400        // must do so via the env map (e.g. ENV CARGO_TARGET_DIR=...), which
401        // apply_ctx respects when spawning processes.
402        Ok(CommandContext::new(
403            &self.cwd.clone().into(),
404            &self.envs,
405            &self.cargo_target_dir,
406            self.fs.root(),
407            self.fs.build_context(),
408        ))
409    }
410}
411
412fn expand_template(t: &TemplateString, ctx: &CommandContext) -> String {
413    expand_command_env(&t.0, ctx)
414}
415
416pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
417    run_steps_with_context(fs_root, fs_root, steps)
418}
419
420pub fn run_steps_with_context(
421    fs_root: &GuardedPath,
422    build_context: &GuardedPath,
423    steps: &[Step],
424) -> Result<()> {
425    run_steps_with_context_result(fs_root, build_context, steps, None, None).map(|_| ())
426}
427
428/// Execute the DSL and return the final working directory after all steps.
429pub fn run_steps_with_context_result(
430    fs_root: &GuardedPath,
431    build_context: &GuardedPath,
432    steps: &[Step],
433    stdin: Option<SharedInput>,
434    stdout: Option<SharedOutput>,
435) -> Result<GuardedPath> {
436    let io = assemble_default_io(stdin, stdout);
437    run_steps_with_context_result_with_io(fs_root, build_context, steps, io)
438}
439
440pub fn run_steps_with_context_result_with_io(
441    fs_root: &GuardedPath,
442    build_context: &GuardedPath,
443    steps: &[Step],
444    io: ExecIo,
445) -> Result<GuardedPath> {
446    match run_steps_inner(fs_root, build_context, steps, io) {
447        Ok(final_cwd) => Ok(final_cwd),
448        Err(err) => {
449            // Compose a single error message with the top cause plus a compact fs snapshot.
450            let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
451            let mut primary = chain
452                .first()
453                .cloned()
454                .unwrap_or_else(|| "unknown error".into());
455            let rest = if chain.len() > 1 {
456                let first_cause = chain[1].clone();
457                primary = format!("{primary} ({first_cause})");
458                if chain.len() > 2 {
459                    let causes = chain
460                        .iter()
461                        .skip(2)
462                        .map(|s| s.as_str())
463                        .collect::<Vec<_>>()
464                        .join("\n  ");
465                    format!("\ncauses:\n  {}", causes)
466                } else {
467                    String::new()
468                }
469            } else {
470                String::new()
471            };
472            let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
473            let tree = describe_dir(&fs, fs_root, 2, 24);
474            let snapshot = format!(
475                "filesystem snapshot (root {}):\n{}",
476                fs_root.display(),
477                tree
478            );
479            let msg = format!("{}{}\n{}", primary, rest, snapshot);
480            Err(anyhow::anyhow!(msg))
481        }
482    }
483}
484
485fn run_steps_inner(
486    fs_root: &GuardedPath,
487    build_context: &GuardedPath,
488    steps: &[Step],
489    io: ExecIo,
490) -> Result<GuardedPath> {
491    let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
492    resolver.set_workspace_root(build_context.clone());
493    run_steps_with_fs_with_io(Box::new(resolver), steps, io)
494}
495
496pub fn run_steps_with_fs(
497    fs: Box<dyn WorkspaceFs>,
498    steps: &[Step],
499    stdin: Option<SharedInput>,
500    stdout: Option<SharedOutput>,
501) -> Result<GuardedPath> {
502    let io = assemble_default_io(stdin, stdout);
503    run_steps_with_fs_with_io(fs, steps, io)
504}
505
506pub fn run_steps_with_fs_with_io(
507    fs: Box<dyn WorkspaceFs>,
508    steps: &[Step],
509    io: ExecIo,
510) -> Result<GuardedPath> {
511    run_steps_with_manager(fs, steps, default_process_manager(), io)
512}
513
514fn run_steps_with_manager<P: ProcessManager>(
515    fs: Box<dyn WorkspaceFs>,
516    steps: &[Step],
517    process: P,
518    io: ExecIo,
519) -> Result<GuardedPath> {
520    let fs_root = fs.root().clone();
521    let cwd = fs.root().clone();
522    let build_context = fs.build_context().clone();
523    let envs = BuiltinEnv::collect(&build_context).into_envs();
524    let mut state = ExecState {
525        fs,
526        cargo_target_dir: fs_root.join(".cargo-target")?,
527        cwd,
528        envs,
529        bg_children: Vec::new(),
530        scope_stack: Vec::new(),
531        io,
532    };
533
534    let _default_stdout = io::stdout();
535    let stdin = state.io.stdin();
536    let stdout = state.io.stdout().map(StreamHandle::Stream);
537    let stderr = state.io.stderr().map(StreamHandle::Stream);
538    let mut proc_mgr = process;
539    execute_steps(
540        &mut state,
541        &mut proc_mgr,
542        steps,
543        stdin,
544        false,
545        stdout,
546        stderr,
547        true,
548    )?;
549
550    Ok(state.cwd)
551}
552
553#[allow(clippy::too_many_arguments)]
554fn execute_steps<P: ProcessManager>(
555    state: &mut ExecState<P>,
556    process: &mut P,
557    steps: &[Step],
558    stdin: Option<SharedInput>,
559    expose_stdin: bool,
560    out: Option<StreamHandle>,
561    err: Option<StreamHandle>,
562    wait_at_end: bool,
563) -> Result<()> {
564    let fs_root = state.fs.root().clone();
565    let build_context = state.fs.build_context().clone();
566
567    // Inject stdin into envs if available and utf8
568    // Note: We cannot peek at the stream without consuming it.
569    // So we can't inject "stdin" env var if we are streaming.
570    // The user wants streaming, so we skip env injection if we have a stream.
571    // Or we only inject if it's not streaming?
572    // The previous implementation took &[u8], so it was always buffered.
573    // Now we have a stream. We can't buffer it just to put it in env.
574
575    // let old_stdin = state.envs.get("stdin").cloned();
576    // if let Some(data) = stdin {
577    //     if let Ok(s) = std::str::from_utf8(data) {
578    //         state.envs.insert("stdin".to_string(), s.to_string());
579    //     }
580    // }
581
582    let check_bg = |bg: &mut Vec<P::Handle>| -> Result<Option<ExitStatus>> {
583        let mut finished: Option<ExitStatus> = None;
584        for child in bg.iter_mut() {
585            if let Some(status) = child.try_wait()? {
586                finished = Some(status);
587                break;
588            }
589        }
590        if let Some(status) = finished {
591            // Tear down remaining background children.
592            for child in bg.iter_mut() {
593                if child.try_wait()?.is_none() {
594                    let _ = child.kill();
595                    let _ = child.wait();
596                }
597            }
598            bg.clear();
599            return Ok(Some(status));
600        }
601        Ok(None)
602    };
603
604    for (idx, step) in steps.iter().enumerate() {
605        if step.scope_enter > 0 {
606            for _ in 0..step.scope_enter {
607                state.scope_stack.push(ScopeSnapshot {
608                    cwd: state.cwd.clone(),
609                    root: state.fs.root().clone(),
610                    envs: state.envs.clone(),
611                });
612            }
613        }
614
615        let should_run = oxdock_parser::guard_option_allows(step.guard.as_ref(), &state.envs);
616        let step_result: Result<()> = if !should_run {
617            Ok(())
618        } else {
619            match &step.kind {
620                StepKind::InheritEnv { keys } => {
621                    for key in keys {
622                        if state.io.inherit_env_is_removed(key) {
623                            state.envs.remove(key);
624                            continue;
625                        }
626                        if let Some(value) = state.io.inherit_env_value(key).cloned() {
627                            state.envs.insert(key.clone(), value);
628                            continue;
629                        }
630                        if let Ok(value) = std::env::var(key) {
631                            state.envs.insert(key.clone(), value);
632                        }
633                    }
634                    Ok(())
635                }
636                StepKind::Workdir(path) => {
637                    let ctx = state.command_ctx()?;
638                    let rendered = expand_template(path, &ctx);
639                    state.cwd = state
640                        .fs
641                        .resolve_workdir(&state.cwd, &rendered)
642                        .with_context(|| format!("step {}: WORKDIR {}", idx + 1, rendered))?;
643                    Ok(())
644                }
645                StepKind::Workspace(target) => {
646                    match target {
647                        WorkspaceTarget::Snapshot => {
648                            state.fs.set_root(fs_root.clone());
649                            state.cwd = state.fs.root().clone();
650                        }
651                        WorkspaceTarget::Local => {
652                            state.fs.set_root(build_context.clone());
653                            state.cwd = state.fs.root().clone();
654                        }
655                    }
656                    Ok(())
657                }
658                StepKind::Env { key, value } => {
659                    let ctx = state.command_ctx()?;
660                    let rendered = expand_template(value, &ctx);
661                    state.envs.insert(key.clone(), rendered);
662                    Ok(())
663                }
664                StepKind::Run(cmd) => {
665                    let ctx = state.command_ctx()?;
666                    let rendered = expand_template(cmd, &ctx);
667                    let step_stdin = if expose_stdin { stdin.clone() } else { None };
668
669                    // Check for an environment variable that forces stdout inheritance.
670                    // This is useful when we want to bypass output capturing (e.g. for build steps)
671                    // and stream directly to the terminal, even if a capture stream was provided.
672                    let inherit_override = state
673                        .envs
674                        .get("OXDOCK_INHERIT_STDOUT")
675                        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
676                        .unwrap_or(false);
677
678                    if std::env::var("OXBOOK_DEBUG").is_ok() {
679                        eprintln!(
680                            "DEBUG: step RUN {} inherit_override={}",
681                            rendered, inherit_override
682                        );
683                    }
684
685                    let stdout_mode = if inherit_override {
686                        CommandStdout::Inherit
687                    } else {
688                        out.clone()
689                            .map(|handle| handle.to_stdout())
690                            .unwrap_or(CommandStdout::Inherit)
691                    };
692                    let stderr_mode = if inherit_override {
693                        CommandStderr::Inherit
694                    } else {
695                        err.clone()
696                            .map(|handle| handle.to_stderr())
697                            .unwrap_or(CommandStderr::Inherit)
698                    };
699
700                    let mut options = CommandOptions::foreground();
701                    options.stdin = step_stdin;
702                    options.stdout = stdout_mode;
703                    options.stderr = stderr_mode;
704                    match process
705                        .run_command(&ctx, &rendered, options)
706                        .with_context(|| format!("step {}: RUN {}", idx + 1, rendered))?
707                    {
708                        CommandResult::Completed => Ok(()),
709                        CommandResult::Captured(_) => {
710                            bail!(
711                                "step {}: RUN {} unexpectedly captured output",
712                                idx + 1,
713                                rendered
714                            )
715                        }
716                        CommandResult::Background(_) => {
717                            bail!(
718                                "step {}: RUN {} returned background handle",
719                                idx + 1,
720                                rendered
721                            )
722                        }
723                    }
724                }
725                StepKind::Echo(msg) => {
726                    let ctx = state.command_ctx()?;
727                    let rendered = expand_template(msg, &ctx);
728                    write_stdout(out.clone(), |writer| {
729                        writeln!(writer, "{}", rendered)?;
730                        Ok(())
731                    })?;
732                    Ok(())
733                }
734                StepKind::RunBg(cmd) => {
735                    let ctx = state.command_ctx()?;
736                    let rendered = expand_template(cmd, &ctx);
737                    let step_stdin = if expose_stdin { stdin.clone() } else { None };
738                    let stdout_mode = out
739                        .clone()
740                        .map(|handle| handle.to_stdout())
741                        .unwrap_or(CommandStdout::Inherit);
742                    let stderr_mode = err
743                        .clone()
744                        .map(|handle| handle.to_stderr())
745                        .unwrap_or(CommandStderr::Inherit);
746                    let mut options = CommandOptions::background();
747                    options.stdin = step_stdin;
748                    options.stdout = stdout_mode;
749                    options.stderr = stderr_mode;
750                    match process
751                        .run_command(&ctx, &rendered, options)
752                        .with_context(|| format!("step {}: RUN_BG {}", idx + 1, rendered))?
753                    {
754                        CommandResult::Background(handle) => {
755                            state.bg_children.push(handle);
756                            Ok(())
757                        }
758                        CommandResult::Completed => {
759                            bail!(
760                                "step {}: RUN_BG {} finished synchronously",
761                                idx + 1,
762                                rendered
763                            )
764                        }
765                        CommandResult::Captured(_) => {
766                            bail!(
767                                "step {}: RUN_BG {} attempted to capture output",
768                                idx + 1,
769                                rendered
770                            )
771                        }
772                    }
773                }
774                StepKind::Copy {
775                    from_current_workspace,
776                    from,
777                    to,
778                } => {
779                    let ctx = state.command_ctx()?;
780                    let from_rendered = expand_template(from, &ctx);
781                    let to_rendered = expand_template(to, &ctx);
782                    let from_abs = if *from_current_workspace {
783                        state
784                            .fs
785                            .resolve_copy_source_from_workspace(&from_rendered)
786                            .with_context(|| {
787                                format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
788                            })?
789                    } else {
790                        state
791                            .fs
792                            .resolve_copy_source(&from_rendered)
793                            .with_context(|| {
794                                format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
795                            })?
796                    };
797                    let to_abs = state
798                        .fs
799                        .resolve_write(&state.cwd, &to_rendered)
800                        .with_context(|| {
801                            format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
802                        })?;
803                    copy_entry(state.fs.as_ref(), &from_abs, &to_abs).with_context(|| {
804                        format!("step {}: COPY {} {}", idx + 1, from_rendered, to_rendered)
805                    })?;
806                    Ok(())
807                }
808                StepKind::CopyGit {
809                    rev,
810                    from,
811                    to,
812                    include_dirty,
813                } => {
814                    let ctx = state.command_ctx()?;
815                    let rev_rendered = expand_template(rev, &ctx);
816                    let from_rendered = expand_template(from, &ctx);
817                    let to_rendered = expand_template(to, &ctx);
818                    let to_abs = state
819                        .fs
820                        .resolve_write(&state.cwd, &to_rendered)
821                        .with_context(|| {
822                            format!(
823                                "step {}: COPY_GIT {} {} {}",
824                                idx + 1,
825                                rev_rendered,
826                                from_rendered,
827                                to_rendered
828                            )
829                        })?;
830                    state
831                        .fs
832                        .copy_from_git(&rev_rendered, &from_rendered, &to_abs, *include_dirty)
833                        .with_context(|| {
834                            format!(
835                                "step {}: COPY_GIT {} {} {}",
836                                idx + 1,
837                                rev_rendered,
838                                from_rendered,
839                                to_rendered
840                            )
841                        })?;
842                    Ok(())
843                }
844                StepKind::HashSha256 { path } => {
845                    let ctx = state.command_ctx()?;
846                    let rendered = expand_template(path, &ctx);
847                    let target = state
848                        .fs
849                        .resolve_read(&state.cwd, &rendered)
850                        .with_context(|| format!("step {}: HASH_SHA256 {}", idx + 1, rendered))?;
851                    let mut hasher = Sha256::new();
852                    hash_path(state.fs.as_ref(), &target, "", &mut hasher)?;
853                    let digest = hasher.finalize();
854                    write_stdout(out.clone(), |writer| {
855                        writeln!(writer, "{:x}", digest)?;
856                        Ok(())
857                    })?;
858                    Ok(())
859                }
860
861                StepKind::Symlink { from, to } => {
862                    let ctx = state.command_ctx()?;
863                    let from_rendered = expand_template(from, &ctx);
864                    let to_rendered = expand_template(to, &ctx);
865                    let to_abs = state
866                        .fs
867                        .resolve_write(&state.cwd, &to_rendered)
868                        .with_context(|| {
869                            format!(
870                                "step {}: SYMLINK {} {}",
871                                idx + 1,
872                                from_rendered,
873                                to_rendered
874                            )
875                        })?;
876                    let from_abs =
877                        state
878                            .fs
879                            .resolve_copy_source(&from_rendered)
880                            .with_context(|| {
881                                format!(
882                                    "step {}: SYMLINK {} {}",
883                                    idx + 1,
884                                    from_rendered,
885                                    to_rendered
886                                )
887                            })?;
888                    state.fs.symlink(&from_abs, &to_abs).with_context(|| {
889                        format!(
890                            "step {}: SYMLINK {} {}",
891                            idx + 1,
892                            from_rendered,
893                            to_rendered
894                        )
895                    })?;
896                    Ok(())
897                }
898                StepKind::Mkdir(path) => {
899                    let ctx = state.command_ctx()?;
900                    let rendered = expand_template(path, &ctx);
901                    let target = state
902                        .fs
903                        .resolve_write(&state.cwd, &rendered)
904                        .with_context(|| format!("step {}: MKDIR {}", idx + 1, rendered))?;
905                    state
906                        .fs
907                        .create_dir_all(&target)
908                        .with_context(|| format!("failed to create dir {}", target.display()))?;
909                    Ok(())
910                }
911                StepKind::Ls(arg) => {
912                    let ctx = state.command_ctx()?;
913                    let target_dir = if let Some(p) = arg {
914                        let rendered = expand_template(p, &ctx);
915                        state
916                            .fs
917                            .resolve_read(&state.cwd, &rendered)
918                            .with_context(|| format!("step {}: LS {}", idx + 1, rendered))?
919                    } else {
920                        state.cwd.clone()
921                    };
922                    let mut entries =
923                        state.fs.read_dir_entries(&target_dir).with_context(|| {
924                            format!("step {}: LS {}", idx + 1, target_dir.display())
925                        })?;
926                    entries.sort_by_key(|e| e.file_name());
927                    write_stdout(out.clone(), |writer| {
928                        writeln!(writer, "{}:", target_dir.display())?;
929                        for entry in &entries {
930                            writeln!(writer, "{}", entry.file_name().to_string_lossy())?;
931                        }
932                        Ok(())
933                    })?;
934                    Ok(())
935                }
936                StepKind::Cwd => {
937                    // Print the canonical (physical) current working directory to stdout.
938                    let real = canonical_cwd(state.fs.as_ref(), &state.cwd).with_context(|| {
939                        format!(
940                            "step {}: CWD failed to canonicalize {}",
941                            idx + 1,
942                            state.cwd.display()
943                        )
944                    })?;
945                    write_stdout(out.clone(), |writer| {
946                        writeln!(writer, "{}", real)?;
947                        Ok(())
948                    })?;
949                    Ok(())
950                }
951                StepKind::Read(path_opt) => {
952                    let data = if let Some(path) = path_opt {
953                        let ctx = state.command_ctx()?;
954                        let rendered = expand_template(path, &ctx);
955                        let target = state
956                            .fs
957                            .resolve_read(&state.cwd, &rendered)
958                            .with_context(|| format!("step {}: READ {}", idx + 1, rendered))?;
959                        state
960                            .fs
961                            .read_file(&target)
962                            .with_context(|| format!("failed to read {}", target.display()))?
963                    } else {
964                        let mut buf = Vec::new();
965                        if let Some(input_stream) = stdin.clone()
966                            && let Ok(mut guard) = input_stream.lock()
967                        {
968                            guard
969                                .read_to_end(&mut buf)
970                                .context("failed to read from stdin")?;
971                        }
972
973                        buf
974                    };
975                    write_stdout(out.clone(), |writer| {
976                        writer
977                            .write_all(&data)
978                            .context("failed to write to output")?;
979                        Ok(())
980                    })?;
981                    Ok(())
982                }
983                StepKind::Write { path, contents } => {
984                    let ctx = state.command_ctx()?;
985                    let path_rendered = expand_template(path, &ctx);
986                    let target = state
987                        .fs
988                        .resolve_write(&state.cwd, &path_rendered)
989                        .with_context(|| format!("step {}: WRITE {}", idx + 1, path_rendered))?;
990                    state.fs.ensure_parent_dir(&target).with_context(|| {
991                        format!("failed to create parent for {}", target.display())
992                    })?;
993                    if let Some(body) = contents {
994                        let rendered = expand_template(body, &ctx);
995                        state
996                            .fs
997                            .write_file(&target, rendered.as_bytes())
998                            .with_context(|| format!("failed to write {}", target.display()))?;
999                    } else {
1000                        let Some(input_stream) = stdin.clone() else {
1001                            bail!(
1002                                "step {}: WRITE {} requires stdin (use WITH_IO [stdin=...] WRITE)",
1003                                idx + 1,
1004                                path_rendered
1005                            );
1006                        };
1007                        let mut guard = input_stream
1008                            .lock()
1009                            .map_err(|_| anyhow!("failed to lock stdin for WRITE"))?;
1010                        let mut data = Vec::new();
1011                        guard
1012                            .read_to_end(&mut data)
1013                            .context("failed to read from stdin for WRITE")?;
1014                        drop(guard);
1015                        state
1016                            .fs
1017                            .write_file(&target, &data)
1018                            .with_context(|| format!("failed to write {}", target.display()))?;
1019                    }
1020                    Ok(())
1021                }
1022                StepKind::WithIo { bindings, cmd } => {
1023                    let inner_step = Step {
1024                        guard: None,
1025                        kind: *cmd.clone(),
1026                        scope_enter: 0,
1027                        scope_exit: 0,
1028                    };
1029                    let steps = vec![inner_step];
1030
1031                    let mut step_stdin = None;
1032                    let mut step_stdout = out.clone();
1033                    let mut step_stderr = err.clone();
1034                    let mut next_expose_stdin = false;
1035                    let mut seen_stdin = false;
1036                    let mut seen_stdout = false;
1037                    let mut seen_stderr = false;
1038
1039                    for binding in bindings {
1040                        if let Some(pipe) = &binding.pipe {
1041                            state.io.ensure_script_pipe(pipe);
1042                        }
1043                        match binding.stream {
1044                            IoStream::Stdin => {
1045                                if seen_stdin {
1046                                    bail!(
1047                                        "step {}: WITH_IO declared stdin more than once",
1048                                        idx + 1
1049                                    );
1050                                }
1051                                seen_stdin = true;
1052                                next_expose_stdin = true;
1053                                step_stdin = if let Some(pipe) = &binding.pipe {
1054                                    Some(state.io.input_pipe(pipe).ok_or_else(|| {
1055                                        anyhow!(
1056                                            "step {}: WITH_IO stdin pipe '{}' is undefined",
1057                                            idx + 1,
1058                                            pipe
1059                                        )
1060                                    })?)
1061                                } else {
1062                                    stdin.clone()
1063                                };
1064                            }
1065                            IoStream::Stdout => {
1066                                if seen_stdout {
1067                                    bail!(
1068                                        "step {}: WITH_IO declared stdout more than once",
1069                                        idx + 1
1070                                    );
1071                                }
1072                                seen_stdout = true;
1073                                step_stdout = if let Some(pipe) = &binding.pipe {
1074                                    Some(
1075                                        state
1076                                            .io
1077                                            .output_pipe_stdout(pipe)
1078                                            .ok_or_else(|| {
1079                                                anyhow!(
1080                                                    "step {}: WITH_IO stdout pipe '{}' is undefined",
1081                                                    idx + 1,
1082                                                    pipe
1083                                                )
1084                                            })?
1085                                            .to_stream_handle(),
1086                                    )
1087                                } else {
1088                                    out.clone()
1089                                };
1090                            }
1091                            IoStream::Stderr => {
1092                                if seen_stderr {
1093                                    bail!(
1094                                        "step {}: WITH_IO declared stderr more than once",
1095                                        idx + 1
1096                                    );
1097                                }
1098                                seen_stderr = true;
1099                                step_stderr = if let Some(pipe) = &binding.pipe {
1100                                    Some(
1101                                        state
1102                                            .io
1103                                            .output_pipe_stderr(pipe)
1104                                            .ok_or_else(|| {
1105                                                anyhow!(
1106                                                    "step {}: WITH_IO stderr pipe '{}' is undefined",
1107                                                    idx + 1,
1108                                                    pipe
1109                                                )
1110                                            })?
1111                                            .to_stream_handle(),
1112                                    )
1113                                } else {
1114                                    err.clone()
1115                                };
1116                            }
1117                        }
1118                    }
1119
1120                    execute_steps(
1121                        state,
1122                        process,
1123                        &steps,
1124                        step_stdin,
1125                        next_expose_stdin,
1126                        step_stdout,
1127                        step_stderr,
1128                        false,
1129                    )?;
1130                    Ok(())
1131                }
1132                StepKind::WithIoBlock { .. } => {
1133                    bail!("WITH_IO block should have been expanded during parsing")
1134                }
1135
1136                StepKind::Exit(code) => {
1137                    for child in state.bg_children.iter_mut() {
1138                        if child.try_wait()?.is_none() {
1139                            let _ = child.kill();
1140                            let _ = child.wait();
1141                        }
1142                    }
1143                    state.bg_children.clear();
1144                    bail!("EXIT requested with code {}", code);
1145                }
1146            }
1147        };
1148
1149        let restore_result = restore_scopes(state, step.scope_exit);
1150        step_result?;
1151        restore_result?;
1152        if let Some(status) = check_bg(&mut state.bg_children)? {
1153            if status.success() {
1154                return Ok(());
1155            } else {
1156                bail!("RUN_BG exited with status {}", status);
1157            }
1158        }
1159    }
1160
1161    if wait_at_end && !state.bg_children.is_empty() {
1162        let mut first = state.bg_children.remove(0);
1163        let status = first.wait()?;
1164        for child in state.bg_children.iter_mut() {
1165            if child.try_wait()?.is_none() {
1166                let _ = child.kill();
1167                let _ = child.wait();
1168            }
1169        }
1170        state.bg_children.clear();
1171        if status.success() {
1172            return Ok(());
1173        } else {
1174            bail!("RUN_BG exited with status {}", status);
1175        }
1176    }
1177
1178    Ok(())
1179}
1180
1181fn restore_scopes<P: ProcessManager>(state: &mut ExecState<P>, count: usize) -> Result<()> {
1182    for _ in 0..count {
1183        let snapshot = state
1184            .scope_stack
1185            .pop()
1186            .ok_or_else(|| anyhow!("scope stack underflow during pop"))?;
1187        state.fs.set_root(snapshot.root.clone());
1188        state.cwd = snapshot.cwd;
1189        state.envs = snapshot.envs;
1190    }
1191    Ok(())
1192}
1193
1194fn copy_entry(fs: &dyn WorkspaceFs, src: &GuardedPath, dst: &GuardedPath) -> Result<()> {
1195    match fs.entry_kind(src)? {
1196        EntryKind::Dir => {
1197            fs.copy_dir_recursive(src, dst)?;
1198        }
1199        EntryKind::File => {
1200            fs.ensure_parent_dir(dst)?;
1201            fs.copy_file(src, dst)?;
1202        }
1203    }
1204    Ok(())
1205}
1206
1207fn canonical_cwd(fs: &dyn WorkspaceFs, cwd: &GuardedPath) -> Result<String> {
1208    Ok(fs.canonicalize(cwd)?.display().to_string())
1209}
1210
1211fn describe_dir(
1212    fs: &dyn WorkspaceFs,
1213    root: &GuardedPath,
1214    max_depth: usize,
1215    max_entries: usize,
1216) -> String {
1217    fn helper(
1218        fs: &dyn WorkspaceFs,
1219        guard_root: &GuardedPath,
1220        path: &GuardedPath,
1221        depth: usize,
1222        max_depth: usize,
1223        left: &mut usize,
1224        out: &mut String,
1225    ) {
1226        if *left == 0 {
1227            return;
1228        }
1229        let indent = "  ".repeat(depth);
1230        if depth > 0 {
1231            out.push_str(&format!(
1232                "{}{}\n",
1233                indent,
1234                path.as_path()
1235                    .file_name()
1236                    .unwrap_or_default()
1237                    .to_string_lossy()
1238            ));
1239        }
1240        if depth >= max_depth {
1241            return;
1242        }
1243        let entries = match fs.read_dir_entries(path) {
1244            Ok(e) => e,
1245            Err(_) => return,
1246        };
1247        let mut names: Vec<_> = entries.into_iter().collect();
1248        names.sort_by_key(|a| a.file_name());
1249        for entry in names {
1250            if *left == 0 {
1251                return;
1252            }
1253            *left -= 1;
1254            let file_type = match entry.file_type() {
1255                Ok(ft) => ft,
1256                Err(_) => continue,
1257            };
1258            let p = entry.path();
1259            let guarded_child = match GuardedPath::new(guard_root.root(), &p) {
1260                Ok(child) => child,
1261                Err(_) => continue,
1262            };
1263            if file_type.is_dir() {
1264                helper(
1265                    fs,
1266                    guard_root,
1267                    &guarded_child,
1268                    depth + 1,
1269                    max_depth,
1270                    left,
1271                    out,
1272                );
1273            } else {
1274                out.push_str(&format!(
1275                    "{}  {}\n",
1276                    indent,
1277                    entry.file_name().to_string_lossy()
1278                ));
1279            }
1280        }
1281    }
1282
1283    let mut out = String::new();
1284    let mut left = max_entries;
1285    helper(fs, root, root, 0, max_depth, &mut left, &mut out);
1286    out
1287}
1288
1289fn hash_path(
1290    fs: &dyn WorkspaceFs,
1291    path: &GuardedPath,
1292    rel: &str,
1293    hasher: &mut Sha256,
1294) -> Result<()> {
1295    match fs.entry_kind(path)? {
1296        EntryKind::Dir => {
1297            hasher.update(b"D\0");
1298            hasher.update(rel.as_bytes());
1299            hasher.update(b"\0");
1300            let mut entries = fs.read_dir_entries(path)?;
1301            entries.sort_by_key(|entry| entry.file_name());
1302            for entry in entries {
1303                let name = entry.file_name().to_string_lossy().to_string();
1304                let child = path.join(&name)?;
1305                let child_rel = if rel.is_empty() {
1306                    name
1307                } else {
1308                    format!("{}/{}", rel, name)
1309                };
1310                let child_rel = to_forward_slashes(&child_rel);
1311                hash_path(fs, &child, &child_rel, hasher)?;
1312            }
1313        }
1314        EntryKind::File => {
1315            let data = fs.read_file(path)?;
1316            if rel.is_empty() {
1317                hasher.update(&data);
1318            } else {
1319                hasher.update(b"F\0");
1320                hasher.update(rel.as_bytes());
1321                hasher.update(b"\0");
1322                hasher.update(&data);
1323            }
1324        }
1325    }
1326    Ok(())
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use oxdock_fs::{GuardedPath, MockFs};
1333    use oxdock_parser::{Guard, GuardExpr, IoBinding, IoStream};
1334    use oxdock_process::{MockProcessManager, MockRunCall};
1335    use std::collections::HashMap;
1336
1337    #[test]
1338    fn run_records_env_and_cwd() {
1339        let root = GuardedPath::new_root_from_str(".").unwrap();
1340        let steps = vec![
1341            Step {
1342                guard: None,
1343                kind: StepKind::Env {
1344                    key: "FOO".into(),
1345                    value: "bar".into(),
1346                },
1347                scope_enter: 0,
1348                scope_exit: 0,
1349            },
1350            Step {
1351                guard: None,
1352                kind: StepKind::Run("echo hi".into()),
1353                scope_enter: 0,
1354                scope_exit: 0,
1355            },
1356        ];
1357        let mock = MockProcessManager::default();
1358        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1359        run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1360        let runs = mock.recorded_runs();
1361        assert_eq!(runs.len(), 1);
1362        let MockRunCall {
1363            script,
1364            cwd,
1365            envs,
1366            cargo_target_dir,
1367            ..
1368        } = &runs[0];
1369        assert_eq!(script, "echo hi");
1370        assert_eq!(cwd, root.as_path());
1371        assert_eq!(
1372            cargo_target_dir,
1373            &root.join(".cargo-target").unwrap().to_path_buf()
1374        );
1375        assert_eq!(envs.get("FOO"), Some(&"bar".into()));
1376    }
1377
1378    #[test]
1379    fn run_expands_env_values() {
1380        let root = GuardedPath::new_root_from_str(".").unwrap();
1381        let steps = vec![
1382            Step {
1383                guard: None,
1384                kind: StepKind::Env {
1385                    key: "FOO".into(),
1386                    value: "bar".into(),
1387                },
1388                scope_enter: 0,
1389                scope_exit: 0,
1390            },
1391            Step {
1392                guard: None,
1393                kind: StepKind::Run("echo {{ env:FOO }}".into()),
1394                scope_enter: 0,
1395                scope_exit: 0,
1396            },
1397        ];
1398        let mock = MockProcessManager::default();
1399        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1400        run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1401        let runs = mock.recorded_runs();
1402        assert_eq!(runs.len(), 1);
1403        assert_eq!(runs[0].script, "echo bar");
1404    }
1405
1406    #[test]
1407    fn run_bg_completion_short_circuits_pipeline() {
1408        let root = GuardedPath::new_root_from_str(".").unwrap();
1409        let steps = vec![
1410            Step {
1411                guard: None,
1412                kind: StepKind::RunBg("sleep".into()),
1413                scope_enter: 0,
1414                scope_exit: 0,
1415            },
1416            Step {
1417                guard: None,
1418                kind: StepKind::Run("echo after".into()),
1419                scope_enter: 0,
1420                scope_exit: 0,
1421            },
1422        ];
1423        let mock = MockProcessManager::default();
1424        mock.push_bg_plan(0, success_status());
1425        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1426        run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1427        assert!(
1428            mock.recorded_runs().is_empty(),
1429            "foreground run should not execute when RUN_BG completes early"
1430        );
1431        let spawns = mock.spawn_log();
1432        let spawned: Vec<_> = spawns.iter().map(|c| c.script.as_str()).collect();
1433        assert_eq!(spawned, vec!["sleep"]);
1434    }
1435
1436    #[test]
1437    fn exit_kills_background_processes() {
1438        let root = GuardedPath::new_root_from_str(".").unwrap();
1439        let steps = vec![
1440            Step {
1441                guard: None,
1442                kind: StepKind::RunBg("bg-task".into()),
1443                scope_enter: 0,
1444                scope_exit: 0,
1445            },
1446            Step {
1447                guard: None,
1448                kind: StepKind::Exit(5),
1449                scope_enter: 0,
1450                scope_exit: 0,
1451            },
1452        ];
1453        let mock = MockProcessManager::default();
1454        mock.push_bg_plan(usize::MAX, success_status());
1455        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1456        let err = run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap_err();
1457        assert!(
1458            err.to_string().contains("EXIT requested with code 5"),
1459            "unexpected error: {err}"
1460        );
1461        assert_eq!(mock.killed(), vec!["bg-task"]);
1462    }
1463
1464    #[test]
1465    fn symlink_errors_report_underlying_cause() {
1466        let temp = GuardedPath::tempdir().unwrap();
1467        let root = temp.as_guarded_path().clone();
1468        let steps = vec![
1469            Step {
1470                guard: None,
1471                kind: StepKind::Mkdir("client".into()),
1472                scope_enter: 0,
1473                scope_exit: 0,
1474            },
1475            Step {
1476                guard: None,
1477                kind: StepKind::Symlink {
1478                    from: "client".into(),
1479                    to: "client".into(),
1480                },
1481                scope_enter: 0,
1482                scope_exit: 0,
1483            },
1484        ];
1485        let err = run_steps(&root, &steps).unwrap_err();
1486        let msg = err.to_string();
1487        assert!(
1488            msg.contains("step 2: SYMLINK client client"),
1489            "error should include step context: {msg}"
1490        );
1491        assert!(
1492            msg.contains("SYMLINK destination already exists"),
1493            "error should surface underlying cause: {msg}"
1494        );
1495    }
1496
1497    #[test]
1498    fn guarded_run_waits_for_env_to_be_set() {
1499        let root = GuardedPath::new_root_from_str(".").unwrap();
1500        let guard = Guard::EnvEquals {
1501            key: "READY".into(),
1502            value: "1".into(),
1503            invert: false,
1504        };
1505        let steps = vec![
1506            Step {
1507                guard: Some(guard.clone().into()),
1508                kind: StepKind::Run("echo first".into()),
1509                scope_enter: 0,
1510                scope_exit: 0,
1511            },
1512            Step {
1513                guard: None,
1514                kind: StepKind::Env {
1515                    key: "READY".into(),
1516                    value: "1".into(),
1517                },
1518                scope_enter: 0,
1519                scope_exit: 0,
1520            },
1521            Step {
1522                guard: Some(guard.into()),
1523                kind: StepKind::Run("echo second".into()),
1524                scope_enter: 0,
1525                scope_exit: 0,
1526            },
1527        ];
1528        let mock = MockProcessManager::default();
1529        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1530        run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1531        let runs = mock.recorded_runs();
1532        assert_eq!(runs.len(), 1);
1533        assert_eq!(runs[0].script, "echo second");
1534    }
1535
1536    #[test]
1537    fn guard_groups_allow_any_matching_branch() {
1538        let root = GuardedPath::new_root_from_str(".").unwrap();
1539        let guard_alpha = Guard::EnvEquals {
1540            key: "MODE".into(),
1541            value: "alpha".into(),
1542            invert: false,
1543        };
1544        let guard_beta = Guard::EnvEquals {
1545            key: "MODE".into(),
1546            value: "beta".into(),
1547            invert: false,
1548        };
1549        let steps = vec![
1550            Step {
1551                guard: None,
1552                kind: StepKind::Env {
1553                    key: "MODE".into(),
1554                    value: "beta".into(),
1555                },
1556                scope_enter: 0,
1557                scope_exit: 0,
1558            },
1559            Step {
1560                guard: Some(GuardExpr::or(vec![guard_alpha.into(), guard_beta.into()])),
1561                kind: StepKind::Run("echo guarded".into()),
1562                scope_enter: 0,
1563                scope_exit: 0,
1564            },
1565        ];
1566        let mock = MockProcessManager::default();
1567        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1568        run_steps_with_manager(fs, &steps, mock.clone(), ExecIo::new()).unwrap();
1569        let runs = mock.recorded_runs();
1570        assert_eq!(runs.len(), 1);
1571        assert_eq!(runs[0].script, "echo guarded");
1572    }
1573
1574    #[test]
1575    fn with_io_pipe_routes_stdout_to_run_stdin() {
1576        let steps = vec![
1577            Step {
1578                guard: None,
1579                kind: StepKind::WithIo {
1580                    bindings: vec![IoBinding {
1581                        stream: IoStream::Stdout,
1582                        pipe: Some("shared".into()),
1583                    }],
1584                    cmd: Box::new(StepKind::Echo("hello".into())),
1585                },
1586                scope_enter: 0,
1587                scope_exit: 0,
1588            },
1589            Step {
1590                guard: None,
1591                kind: StepKind::WithIo {
1592                    bindings: vec![IoBinding {
1593                        stream: IoStream::Stdin,
1594                        pipe: Some("shared".into()),
1595                    }],
1596                    cmd: Box::new(StepKind::Run("cat".into())),
1597                },
1598                scope_enter: 0,
1599                scope_exit: 0,
1600            },
1601        ];
1602
1603        let fs = MockFs::new();
1604        let mut state = create_exec_state(fs);
1605        let mut proc = MockProcessManager::default();
1606        execute_steps(&mut state, &mut proc, &steps, None, false, None, None, true)
1607            .expect("pipeline executes");
1608
1609        let runs = proc.recorded_runs();
1610        assert_eq!(runs.len(), 1);
1611        let MockRunCall { stdin, .. } = &runs[0];
1612        assert_eq!(stdin.as_deref(), Some(b"hello\n".as_slice()));
1613    }
1614
1615    fn success_status() -> ExitStatus {
1616        exit_status_from_code(0)
1617    }
1618
1619    #[cfg(unix)]
1620    fn exit_status_from_code(code: i32) -> ExitStatus {
1621        use std::os::unix::process::ExitStatusExt;
1622        ExitStatusExt::from_raw(code << 8)
1623    }
1624
1625    #[cfg(windows)]
1626    fn exit_status_from_code(code: i32) -> ExitStatus {
1627        use std::os::windows::process::ExitStatusExt;
1628        ExitStatusExt::from_raw(code as u32)
1629    }
1630
1631    fn create_exec_state(fs: MockFs) -> ExecState<MockProcessManager> {
1632        let cargo = fs.root().join(".cargo-target").unwrap();
1633        ExecState {
1634            fs: Box::new(fs.clone()),
1635            cargo_target_dir: cargo,
1636            cwd: fs.root().clone(),
1637            envs: HashMap::new(),
1638            bg_children: Vec::new(),
1639            scope_stack: Vec::new(),
1640            io: ExecIo::new(),
1641        }
1642    }
1643
1644    fn run_with_mock_fs(steps: &[Step]) -> (GuardedPath, HashMap<String, Vec<u8>>) {
1645        let fs = MockFs::new();
1646        let mut state = create_exec_state(fs.clone());
1647        let mut proc = MockProcessManager::default();
1648        execute_steps(&mut state, &mut proc, steps, None, false, None, None, true).unwrap();
1649        (state.cwd, fs.snapshot())
1650    }
1651
1652    #[test]
1653    fn mock_fs_handles_workdir_and_write() {
1654        let steps = vec![
1655            Step {
1656                guard: None,
1657                kind: StepKind::Mkdir("app".into()),
1658                scope_enter: 0,
1659                scope_exit: 0,
1660            },
1661            Step {
1662                guard: None,
1663                kind: StepKind::Workdir("app".into()),
1664                scope_enter: 0,
1665                scope_exit: 0,
1666            },
1667            Step {
1668                guard: None,
1669                kind: StepKind::Write {
1670                    path: "out.txt".into(),
1671                    contents: Some("hi".into()),
1672                },
1673                scope_enter: 0,
1674                scope_exit: 0,
1675            },
1676            Step {
1677                guard: None,
1678                kind: StepKind::Read(Some("out.txt".into())),
1679                scope_enter: 0,
1680                scope_exit: 0,
1681            },
1682        ];
1683        let (_cwd, files) = run_with_mock_fs(&steps);
1684        let written = files
1685            .iter()
1686            .find(|(k, _)| k.ends_with("app/out.txt"))
1687            .map(|(_, v)| String::from_utf8_lossy(v).to_string());
1688        assert_eq!(written, Some("hi".into()));
1689    }
1690
1691    #[test]
1692    fn write_interpolates_env_values() {
1693        let steps = vec![
1694            Step {
1695                guard: None,
1696                kind: StepKind::Env {
1697                    key: "FOO".into(),
1698                    value: "bar".into(),
1699                },
1700                scope_enter: 0,
1701                scope_exit: 0,
1702            },
1703            Step {
1704                guard: None,
1705                kind: StepKind::Env {
1706                    key: "BAZ".into(),
1707                    value: "{{ env:FOO }}-baz".into(),
1708                },
1709                scope_enter: 0,
1710                scope_exit: 0,
1711            },
1712            Step {
1713                guard: None,
1714                kind: StepKind::Write {
1715                    path: "out.txt".into(),
1716                    contents: Some("val {{ env:BAZ }}".into()),
1717                },
1718                scope_enter: 0,
1719                scope_exit: 0,
1720            },
1721        ];
1722        let (_cwd, files) = run_with_mock_fs(&steps);
1723        let written = files
1724            .iter()
1725            .find(|(k, _)| k.ends_with("out.txt"))
1726            .map(|(_, v)| String::from_utf8_lossy(v).to_string());
1727        assert_eq!(written, Some("val bar-baz".into()));
1728    }
1729
1730    #[cfg_attr(
1731        miri,
1732        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1733    )]
1734    #[test]
1735    fn cat_and_capture_expand_env_paths() {
1736        let temp = GuardedPath::tempdir().expect("tempdir");
1737        let root = temp.as_guarded_path().clone();
1738        let steps = vec![
1739            Step {
1740                guard: None,
1741                kind: StepKind::Write {
1742                    path: "snippet.txt".into(),
1743                    contents: Some("payload".into()),
1744                },
1745                scope_enter: 0,
1746                scope_exit: 0,
1747            },
1748            Step {
1749                guard: None,
1750                kind: StepKind::Env {
1751                    key: "SNIPPET".into(),
1752                    value: "snippet.txt".into(),
1753                },
1754                scope_enter: 0,
1755                scope_exit: 0,
1756            },
1757            Step {
1758                guard: None,
1759                kind: StepKind::Env {
1760                    key: "OUT_FILE".into(),
1761                    value: "cat-{{ env:SNIPPET }}".into(),
1762                },
1763                scope_enter: 0,
1764                scope_exit: 0,
1765            },
1766            Step {
1767                guard: None,
1768                kind: StepKind::WithIo {
1769                    bindings: vec![IoBinding {
1770                        stream: IoStream::Stdout,
1771                        pipe: Some("cap-cat".to_string()),
1772                    }],
1773                    cmd: Box::new(StepKind::Read(Some("{{ env:SNIPPET }}".into()))),
1774                },
1775                scope_enter: 0,
1776                scope_exit: 0,
1777            },
1778            Step {
1779                guard: None,
1780                kind: StepKind::WithIo {
1781                    bindings: vec![IoBinding {
1782                        stream: IoStream::Stdin,
1783                        pipe: Some("cap-cat".to_string()),
1784                    }],
1785                    cmd: Box::new(StepKind::Write {
1786                        path: "{{ env:OUT_FILE }}".into(),
1787                        contents: None,
1788                    }),
1789                },
1790                scope_enter: 0,
1791                scope_exit: 0,
1792            },
1793        ];
1794        run_steps(&root, &steps).expect("capture with env paths succeeds");
1795        let resolver = PathResolver::new(root.as_path(), root.as_path()).expect("resolver");
1796        let captured_path = root.join("cat-snippet.txt").expect("capture path");
1797        let contents = resolver
1798            .read_to_string(&captured_path)
1799            .expect("read captured output");
1800        assert_eq!(contents, "payload");
1801    }
1802
1803    #[test]
1804    fn final_cwd_tracks_last_workdir() {
1805        let steps = vec![
1806            Step {
1807                guard: None,
1808                kind: StepKind::Write {
1809                    path: "temp.txt".into(),
1810                    contents: Some("123".into()),
1811                },
1812                scope_enter: 0,
1813                scope_exit: 0,
1814            },
1815            Step {
1816                guard: None,
1817                kind: StepKind::Workdir("sub".into()),
1818                scope_enter: 0,
1819                scope_exit: 0,
1820            },
1821        ];
1822        let (cwd, snapshot) = run_with_mock_fs(&steps);
1823        assert!(
1824            cwd.as_path().ends_with("sub"),
1825            "expected final cwd to match last WORKDIR, got {}",
1826            cwd.display()
1827        );
1828        let keys: Vec<_> = snapshot.keys().cloned().collect();
1829        assert!(
1830            keys.iter().any(|path| path.ends_with("temp.txt")),
1831            "WRITE should produce temp file, snapshot: {:?}",
1832            keys
1833        );
1834    }
1835
1836    #[test]
1837    fn mock_fs_normalizes_backslash_workdir() {
1838        let steps = vec![
1839            Step {
1840                guard: None,
1841                kind: StepKind::Mkdir("win\\nested".into()),
1842                scope_enter: 0,
1843                scope_exit: 0,
1844            },
1845            Step {
1846                guard: None,
1847                kind: StepKind::Workdir("win\\nested".into()),
1848                scope_enter: 0,
1849                scope_exit: 0,
1850            },
1851            Step {
1852                guard: None,
1853                kind: StepKind::Write {
1854                    path: "inner.txt".into(),
1855                    contents: Some("ok".into()),
1856                },
1857                scope_enter: 0,
1858                scope_exit: 0,
1859            },
1860        ];
1861        let (cwd, snapshot) = run_with_mock_fs(&steps);
1862        let cwd_display = cwd.display().to_string();
1863        assert!(
1864            cwd_display.ends_with("win\\nested") || cwd_display.ends_with("win/nested"),
1865            "expected cwd to normalize backslashes, got {cwd_display}"
1866        );
1867        assert!(
1868            snapshot
1869                .keys()
1870                .any(|path| path.ends_with("win/nested/inner.txt")),
1871            "expected file under normalized path, snapshot: {:?}",
1872            snapshot.keys()
1873        );
1874    }
1875
1876    #[cfg(windows)]
1877    #[test]
1878    fn mock_fs_rejects_absolute_windows_paths() {
1879        let steps = vec![Step {
1880            guard: None,
1881            kind: StepKind::Workdir(r"C:\outside".into()),
1882            scope_enter: 0,
1883            scope_exit: 0,
1884        }];
1885        let fs = MockFs::new();
1886        let mut state = create_exec_state(fs);
1887        let mut proc = MockProcessManager::default();
1888        let sink = Arc::new(Mutex::new(Vec::new()));
1889        let err = execute_steps(
1890            &mut state,
1891            &mut proc,
1892            &steps,
1893            None,
1894            false,
1895            Some(StreamHandle::Stream(sink.clone())),
1896            Some(StreamHandle::Stream(sink)),
1897            false,
1898        )
1899        .unwrap_err();
1900        let msg = format!("{err:#}");
1901        assert!(
1902            msg.contains("escapes allowed root"),
1903            "unexpected error for absolute Windows path: {msg}"
1904        );
1905    }
1906
1907    #[test]
1908    fn with_stdin_passes_content_to_run() {
1909        let steps = vec![Step {
1910            guard: None,
1911            kind: StepKind::WithIo {
1912                bindings: vec![IoBinding {
1913                    stream: IoStream::Stdin,
1914                    pipe: None,
1915                }],
1916                cmd: Box::new(StepKind::Run("cat".into())),
1917            },
1918            scope_enter: 0,
1919            scope_exit: 0,
1920        }];
1921
1922        let mock = MockProcessManager::default();
1923        let root = GuardedPath::new_root_from_str(".").unwrap();
1924        let fs = Box::new(PathResolver::new_guarded(root.clone(), root.clone()).unwrap());
1925
1926        let input = Arc::new(Mutex::new(std::io::Cursor::new(b"hello world".to_vec())));
1927
1928        let mut io_cfg = ExecIo::new();
1929        io_cfg.set_stdin(Some(input));
1930
1931        run_steps_with_manager(fs, &steps, mock.clone(), io_cfg).unwrap();
1932
1933        let runs = mock.recorded_runs();
1934        assert_eq!(runs.len(), 1);
1935        assert_eq!(runs[0].script, "cat");
1936        assert_eq!(runs[0].stdin, Some(b"hello world".to_vec()));
1937    }
1938}