Skip to main content

oxdock_core/exec/
mod.rs

1mod args;
2mod capture;
3mod fs_ops;
4mod handlers;
5mod io;
6mod pipe;
7mod state;
8mod steps;
9#[cfg(test)]
10mod tests;
11
12pub(crate) use self::handlers::{
13    dispatch_append, dispatch_assert_contains, dispatch_assert_eq, dispatch_assign,
14    dispatch_assign_async_step, dispatch_assign_capture_step, dispatch_async_block,
15    dispatch_await_capture_step, dispatch_await_step, dispatch_break, dispatch_call,
16    dispatch_cancel_step, dispatch_continue, dispatch_copy, dispatch_copy_git, dispatch_cwd,
17    dispatch_echo, dispatch_env, dispatch_exit, dispatch_expand, dispatch_for_loop,
18    dispatch_func_def, dispatch_hash_sha256, dispatch_if_then, dispatch_inherit_env, dispatch_ls,
19    dispatch_mkdir, dispatch_read, dispatch_read_line, dispatch_return, dispatch_run,
20    dispatch_run_exec, dispatch_set, dispatch_sleep_step, dispatch_symlink, dispatch_timeout_step,
21    dispatch_while_loop, dispatch_with_io, dispatch_with_io_block, dispatch_workdir,
22    dispatch_workspace, dispatch_write,
23};
24pub use self::io::ExecIo;
25pub(crate) use self::steps::StepCtx;
26
27use anyhow::Result;
28use oxdock_fs::{
29    GuardedPath, LazyGuardedTempDir, PathResolver, WorkspaceFs, reserve_cargo_scratch,
30};
31use oxdock_parser::{Step, Value};
32use oxdock_process::{
33    BuiltinEnv, ProcessManager, SharedInput, SharedOutput, default_process_manager,
34};
35
36use std::collections::BTreeMap;
37use std::sync::Arc;
38
39use self::fs_ops::describe_dir;
40use self::io::{StreamHandle, assemble_default_io, teed_stderr, teed_stdout};
41use self::state::ExecState;
42use self::steps::execute_steps;
43
44/// Display text emitted by `CWD` while the snapshot root is selected but not
45/// yet materialized (issue #131). Single source of truth: the `cwd` handler
46/// prints this value, and the logic-test harness resolves the same value
47/// from `@SNAPSHOT_PENDING@` fixture tokens.
48pub const SNAPSHOT_PENDING_DISPLAY: &str = "<snapshot:pending>";
49
50/// Fallback tree body used when a materialized snapshot cannot be described
51/// while composing a run error (issue #131). Single source of truth for the
52/// lazy error path.
53pub const SNAPSHOT_TREE_UNAVAILABLE: &str = "<unavailable>";
54
55pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
56    run_steps_with_context(fs_root, fs_root, steps)
57}
58
59pub fn run_steps_with_context(
60    fs_root: &GuardedPath,
61    build_context: &GuardedPath,
62    steps: &[Step],
63) -> Result<()> {
64    run_steps_with_context_result(fs_root, build_context, steps, None, None).map(|_| ())
65}
66
67/// Execute the DSL and return the final working directory after all steps.
68pub fn run_steps_with_context_result(
69    fs_root: &GuardedPath,
70    build_context: &GuardedPath,
71    steps: &[Step],
72    stdin: Option<SharedInput>,
73    stdout: Option<SharedOutput>,
74) -> Result<GuardedPath> {
75    let io = assemble_default_io(stdin, stdout);
76    run_steps_with_context_result_with_io(fs_root, build_context, steps, io)
77}
78
79pub fn run_steps_with_context_result_with_io(
80    fs_root: &GuardedPath,
81    build_context: &GuardedPath,
82    steps: &[Step],
83    io: ExecIo,
84) -> Result<GuardedPath> {
85    match run_steps_inner(fs_root, build_context, steps, io) {
86        Ok(final_cwd) => Ok(final_cwd),
87        Err(err) => {
88            let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
89            let tree = describe_dir(&fs, fs_root, 2, 24);
90            let snapshot = format!(
91                "filesystem snapshot (root {}):\n{}",
92                fs_root.display(),
93                tree
94            );
95            Err(compose_error_with_snapshot(err, snapshot))
96        }
97    }
98}
99
100/// Compose a single error message with the top cause plus a caller-provided
101/// filesystem-snapshot section. Shared by the eager and lazy runners so both
102/// render identical chains and only differ in the snapshot section.
103fn compose_error_with_snapshot(err: anyhow::Error, snapshot_section: String) -> anyhow::Error {
104    // Compose a single error message with the top cause plus a compact fs snapshot.
105    let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
106    let mut primary = chain
107        .first()
108        .cloned()
109        .unwrap_or_else(|| "unknown error".into());
110    let rest = if chain.len() > 1 {
111        let first_cause = chain[1].clone();
112        primary = format!("{primary} ({first_cause})");
113        if chain.len() > 2 {
114            let causes = chain
115                .iter()
116                .skip(2)
117                .map(|s| s.as_str())
118                .collect::<Vec<_>>()
119                .join("\n  ");
120            format!("\ncauses:\n  {}", causes)
121        } else {
122            String::new()
123        }
124    } else {
125        String::new()
126    };
127    let msg = format!("{}{}\n{}", primary, rest, snapshot_section);
128    anyhow::anyhow!(msg)
129}
130
131fn run_steps_inner(
132    fs_root: &GuardedPath,
133    build_context: &GuardedPath,
134    steps: &[Step],
135    io: ExecIo,
136) -> Result<GuardedPath> {
137    let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
138    resolver.set_workspace_root(build_context.clone());
139    run_steps_with_fs_with_io(Box::new(resolver), steps, io)
140}
141
142/// Output of a lazily-executed run (issue #131): the final working directory
143/// (concretized as of return), shared ownership of the snapshot backing dir,
144/// and the filesystem handle for post-hoc convergence (e.g. concretizing the
145/// cwd after shell-entry materialization).
146pub struct LazyRunOutput {
147    pub final_cwd: GuardedPath,
148    pub snapshot: Arc<LazyGuardedTempDir>,
149    pub fs: Box<dyn WorkspaceFs>,
150    /// Top-level script variable bindings captured at `Flow::Done`, keyed by
151    /// variable name with deterministic ordering. Read-only introspection for
152    /// hosts that assert on in-memory evaluation without file round trips.
153    /// Ephemeral block scopes are excluded; see `run_steps_with_manager`.
154    pub bindings: BTreeMap<String, Value>,
155}
156
157/// Execute the DSL against a lazily-created snapshot: no temporary directory
158/// exists until the first snapshot-targeted resolution. The snapshot handle
159/// is shared with the resolver, so all clones observe the same directory.
160pub fn run_steps_with_lazy_snapshot(
161    build_context: &GuardedPath,
162    steps: &[Step],
163    io: ExecIo,
164) -> Result<LazyRunOutput> {
165    let mut resolver = PathResolver::new_lazy(build_context.clone())?;
166    resolver.set_workspace_root(build_context.clone());
167    let snapshot = resolver.snapshot_handle();
168    let fs: Box<dyn WorkspaceFs> = Box::new(resolver);
169    match run_steps_with_manager(fs, steps, default_process_manager(), io) {
170        Ok((final_cwd, fs, bindings)) => Ok(LazyRunOutput {
171            final_cwd,
172            snapshot,
173            fs,
174            bindings,
175        }),
176        Err(err) => Err(enrich_lazy_error(&snapshot, build_context, err)),
177    }
178}
179
180/// Error enrichment for lazy runs: a materialized snapshot gets the same
181/// filesystem-snapshot treatment as eager runs; a pending one reports that
182/// no snapshot directory was ever created instead of describing a tree.
183/// Chain rendering is identical to the eager path (shared composer).
184pub fn enrich_lazy_error(
185    snapshot: &Arc<LazyGuardedTempDir>,
186    build_context: &GuardedPath,
187    err: anyhow::Error,
188) -> anyhow::Error {
189    match snapshot.get() {
190        Some(concrete) => {
191            let tree = match PathResolver::new(concrete.as_path(), build_context.as_path()) {
192                Ok(describe_fs) => describe_dir(&describe_fs, concrete, 2, 24),
193                Err(_) => String::from(SNAPSHOT_TREE_UNAVAILABLE),
194            };
195            let snapshot_msg = format!(
196                "filesystem snapshot (root {}):\n{}",
197                concrete.display(),
198                tree
199            );
200            compose_error_with_snapshot(err, snapshot_msg)
201        }
202        None => compose_error_with_snapshot(
203            err,
204            String::from(
205                "filesystem snapshot: never materialized (no snapshot directory was created)",
206            ),
207        ),
208    }
209}
210
211pub fn run_steps_with_fs(
212    fs: Box<dyn WorkspaceFs>,
213    steps: &[Step],
214    stdin: Option<SharedInput>,
215    stdout: Option<SharedOutput>,
216) -> Result<GuardedPath> {
217    let io = assemble_default_io(stdin, stdout);
218    run_steps_with_fs_with_io(fs, steps, io)
219}
220
221pub fn run_steps_with_fs_with_io(
222    fs: Box<dyn WorkspaceFs>,
223    steps: &[Step],
224    io: ExecIo,
225) -> Result<GuardedPath> {
226    run_steps_with_manager(fs, steps, default_process_manager(), io).map(|(cwd, _, _)| cwd)
227}
228
229/// Host introspection entry point: execute the DSL against a caller-provided
230/// filesystem and return the final working directory, the filesystem handle,
231/// and the top-level script variable bindings captured at `Flow::Done`.
232/// Bindings are read from the root variable scope only, so ephemeral
233/// variables from `FUNC` bodies, `FOR`/`WHILE` iterations, and `ASYNC` blocks
234/// are excluded. On script failure the scope is discarded with the error and
235/// no bindings are returned.
236#[allow(clippy::type_complexity)]
237pub fn run_steps_with_manager<P: ProcessManager>(
238    fs: Box<dyn WorkspaceFs>,
239    steps: &[Step],
240    process: P,
241    io: ExecIo,
242) -> Result<(GuardedPath, Box<dyn WorkspaceFs>, BTreeMap<String, Value>)> {
243    let cwd = fs.root().clone();
244    let build_context = fs.build_context().clone();
245    let mut envs = BuiltinEnv::collect(&build_context).into_envs();
246    for (key, value) in io.inherit_env_overrides() {
247        envs.insert(key.clone(), value.clone());
248    }
249    let envs = Arc::new(envs);
250    let assert_windows = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
251    let assert_windows_stderr = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
252    let exact_stdout = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
253    let mut state = ExecState {
254        fs,
255        cargo_scratch: reserve_cargo_scratch()?,
256        cwd,
257        envs,
258        bg_children: Vec::new(),
259        scope_stack: Vec::new(),
260        io,
261        assert_windows: assert_windows.clone(),
262        assert_windows_stderr: assert_windows_stderr.clone(),
263        exact_stdout: exact_stdout.clone(),
264        var_scopes: Vec::new(),
265        cancel_token: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
266        active_process: std::sync::Arc::new(std::sync::Mutex::new(None)),
267        named_tasks: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
268        next_task_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
269        inside_async: false,
270        keeper_expiry: None,
271        cancellable: false,
272        funcs: std::sync::Arc::new(std::collections::HashMap::new()),
273        host_funcs: std::sync::Arc::new(std::collections::HashMap::new()),
274        call_depth: 0,
275        _marker: std::marker::PhantomData,
276    };
277
278    // Push a global variable scope so top-level LET assignments are captured.
279    state.push_var_scope();
280
281    let _default_stdout = std::io::stdout();
282    let stdin = state.io.stdin().into();
283    // Every emitted byte flows through the tee so stream assertions see both
284    // interpreter output and streamed child output, even when no capture
285    // sink was configured (forwarding to real stdout in that case).
286    let stdout = Some(StreamHandle::Stream(teed_stdout(
287        state.io.stdout(),
288        assert_windows,
289        exact_stdout,
290    )));
291    let stderr = state
292        .io
293        .stderr()
294        .map(|sink| StreamHandle::Stream(teed_stderr(Some(sink), assert_windows_stderr)));
295    let mut proc_mgr = process;
296    let flow = execute_steps(
297        &mut state,
298        &mut proc_mgr,
299        steps,
300        stdin,
301        false,
302        stdout,
303        stderr,
304        true,
305    )?;
306    match flow {
307        // Concretize on the way out so a bare pending anchor never escapes as
308        // the reported final directory (shell entry / OUT_DIR sync need real
309        // paths; a pending run reports the local root or concretizes after
310        // shell-entry materialization through the returned fs handle).
311        self::steps::Flow::Done => {
312            // Root-scope isolation: at Done all blocks have popped, so the
313            // first scope is the global one. Read it explicitly (rather than
314            // a flattened all-scopes view) and strip TypeKind so hosts see
315            // plain values.
316            let bindings: BTreeMap<String, Value> = state
317                .var_scopes
318                .first()
319                .map(|scope| {
320                    scope
321                        .iter()
322                        .map(|(k, (_, v))| (k.clone(), v.clone()))
323                        .collect()
324                })
325                .unwrap_or_default();
326            Ok((state.fs.concretize_cwd(&state.cwd), state.fs, bindings))
327        }
328        self::steps::Flow::Break { idx } => {
329            anyhow::bail!("step {}: BREAK outside loop", idx + 1)
330        }
331        self::steps::Flow::Continue { idx } => {
332            anyhow::bail!("step {}: CONTINUE outside loop", idx + 1)
333        }
334        self::steps::Flow::Return { idx, .. } => {
335            anyhow::bail!("step {}: RETURN outside function", idx + 1)
336        }
337    }
338}