Skip to main content

oxdock_core/exec/
mod.rs

1mod args;
2mod capture;
3mod engine;
4mod fs_ops;
5mod handlers;
6mod io;
7mod native;
8mod state;
9mod steps;
10#[cfg(test)]
11mod tests;
12mod typing;
13
14pub use self::engine::{Engine, EngineOutput};
15pub(crate) use self::handlers::{
16    dispatch_append, dispatch_assert_contains, dispatch_assert_eq, dispatch_assign,
17    dispatch_assign_async_step, dispatch_assign_capture_step, dispatch_async_block,
18    dispatch_await_capture_step, dispatch_await_step, dispatch_break, dispatch_call,
19    dispatch_cancel_step, dispatch_continue, dispatch_copy, dispatch_copy_git, dispatch_cwd,
20    dispatch_echo, dispatch_env, dispatch_exit, dispatch_expand, dispatch_for_loop,
21    dispatch_func_def, dispatch_hash_sha256, dispatch_if_then, dispatch_inherit_env, dispatch_ls,
22    dispatch_mkdir, dispatch_push_into_step, dispatch_read, dispatch_read_line, dispatch_return,
23    dispatch_run, dispatch_run_exec, dispatch_set, dispatch_sleep_step, dispatch_symlink,
24    dispatch_timeout_step, dispatch_while_loop, dispatch_with_io, dispatch_with_io_block,
25    dispatch_workdir, dispatch_workspace, dispatch_write,
26};
27pub use self::io::ExecIo;
28pub use self::io::PipeStream;
29pub use self::native::{
30    FuncKind, FuncMeta, FuncParam, FunctionRegistry, HostModule, HostRegistration, NativeFn,
31    OxDockFn, PureFn, builtin_function_metas, builtin_function_names, std_module_table,
32};
33pub use self::state::ExecState;
34pub use self::steps::StepCtx;
35pub use self::typing::{
36    OxDockType, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy, clone_shared,
37    drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed, fmt_inline,
38    fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor, unshare_boxed,
39    unshare_inline, unshare_shared,
40};
41
42use anyhow::Result;
43use oxdock_fs::{
44    GuardedPath, LazyGuardedTempDir, PathResolver, WorkspaceFs, reserve_cargo_scratch,
45};
46use oxdock_parser::Step;
47use oxdock_process::{
48    BuiltinEnv, DefaultProcessManager, ProcessManager, SharedInput, SharedOutput,
49    default_process_manager,
50};
51
52use std::collections::BTreeMap;
53use std::sync::Arc;
54
55use self::fs_ops::describe_dir;
56use self::io::{StreamHandle, assemble_default_io, teed_stderr, teed_stdout};
57use self::steps::execute_steps;
58
59/// Display text emitted by `CWD` while the snapshot root is selected but not
60/// yet materialized (issue #131). Single source of truth: the `cwd` handler
61/// prints this value, and the logic-test harness resolves the same value
62/// from `@SNAPSHOT_PENDING@` fixture tokens.
63pub const SNAPSHOT_PENDING_DISPLAY: &str = "<snapshot:pending>";
64
65/// Fallback tree body used when a materialized snapshot cannot be described
66/// while composing a run error (issue #131). Single source of truth for the
67/// lazy error path.
68pub const SNAPSHOT_TREE_UNAVAILABLE: &str = "<unavailable>";
69
70pub fn run_steps(fs_root: &GuardedPath, steps: &[Step]) -> Result<()> {
71    run_steps_with_context(fs_root, fs_root, steps)
72}
73
74pub fn run_steps_with_context(
75    fs_root: &GuardedPath,
76    build_context: &GuardedPath,
77    steps: &[Step],
78) -> Result<()> {
79    run_steps_with_context_result(fs_root, build_context, steps, None, None).map(|_| ())
80}
81
82/// Execute the DSL and return the final working directory after all steps.
83pub fn run_steps_with_context_result(
84    fs_root: &GuardedPath,
85    build_context: &GuardedPath,
86    steps: &[Step],
87    stdin: Option<SharedInput>,
88    stdout: Option<SharedOutput>,
89) -> Result<GuardedPath> {
90    let io = assemble_default_io(stdin, stdout);
91    run_steps_with_context_result_with_io(fs_root, build_context, steps, io)
92}
93
94pub fn run_steps_with_context_result_with_io(
95    fs_root: &GuardedPath,
96    build_context: &GuardedPath,
97    steps: &[Step],
98    io: ExecIo,
99) -> Result<GuardedPath> {
100    match run_steps_inner(fs_root, build_context, steps, io) {
101        Ok(final_cwd) => Ok(final_cwd),
102        Err(err) => {
103            let fs = PathResolver::new(fs_root.as_path(), build_context.as_path())?;
104            let tree = describe_dir(&fs, fs_root, 2, 24);
105            let snapshot = format!(
106                "filesystem snapshot (root {}):\n{}",
107                fs_root.display(),
108                tree
109            );
110            Err(compose_error_with_snapshot(err, snapshot))
111        }
112    }
113}
114
115/// Compose a single error message with the top cause plus a caller-provided
116/// filesystem-snapshot section. Shared by the eager and lazy runners so both
117/// render identical chains and only differ in the snapshot section.
118fn compose_error_with_snapshot(err: anyhow::Error, snapshot_section: String) -> anyhow::Error {
119    // Compose a single error message with the top cause plus a compact fs snapshot.
120    let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
121    let mut primary = chain
122        .first()
123        .cloned()
124        .unwrap_or_else(|| "unknown error".into());
125    let rest = if chain.len() > 1 {
126        let first_cause = chain[1].clone();
127        primary = format!("{primary} ({first_cause})");
128        if chain.len() > 2 {
129            let causes = chain
130                .iter()
131                .skip(2)
132                .map(|s| s.as_str())
133                .collect::<Vec<_>>()
134                .join("\n  ");
135            format!("\ncauses:\n  {}", causes)
136        } else {
137            String::new()
138        }
139    } else {
140        String::new()
141    };
142    let msg = format!("{}{}\n{}", primary, rest, snapshot_section);
143    anyhow::anyhow!(msg)
144}
145
146fn run_steps_inner(
147    fs_root: &GuardedPath,
148    build_context: &GuardedPath,
149    steps: &[Step],
150    io: ExecIo,
151) -> Result<GuardedPath> {
152    let mut resolver = PathResolver::new_guarded(fs_root.clone(), build_context.clone())?;
153    resolver.set_workspace_root(build_context.clone());
154    run_steps_with_fs_with_io(Box::new(resolver), steps, io)
155}
156
157/// Output of a lazily-executed run (issue #131): the final working directory
158/// (concretized as of return), shared ownership of the snapshot backing dir,
159/// and the filesystem handle for post-hoc convergence (e.g. concretizing the
160/// cwd after shell-entry materialization).
161pub struct LazyRunOutput {
162    pub final_cwd: GuardedPath,
163    pub snapshot: Arc<LazyGuardedTempDir>,
164    pub fs: Box<dyn WorkspaceFs>,
165    /// Top-level script variable bindings captured at `Flow::Done`, keyed by
166    /// variable name with deterministic ordering. Read-only introspection for
167    /// hosts that assert on in-memory evaluation without file round trips.
168    /// Ephemeral block scopes are excluded; see `run_steps_with_manager`.
169    pub bindings: BTreeMap<String, Value>,
170}
171
172/// Execute the DSL against a lazily-created snapshot: no temporary directory
173/// exists until the first snapshot-targeted resolution. The snapshot handle
174/// is shared with the resolver, so all clones observe the same directory.
175pub fn run_steps_with_lazy_snapshot(
176    build_context: &GuardedPath,
177    steps: &[Step],
178    io: ExecIo,
179) -> Result<LazyRunOutput> {
180    run_steps_with_lazy_snapshot_and_modules(build_context, steps, io, Vec::new(), Vec::new())
181}
182
183/// Same as [`run_steps_with_lazy_snapshot`], plus host modules and types
184/// (see [`run_steps_with_manager_with_modules`]). Lets binary hosts that
185/// run on the lazy snapshot path expose plugin surface without changing
186/// CLI semantics.
187pub fn run_steps_with_lazy_snapshot_and_modules(
188    build_context: &GuardedPath,
189    steps: &[Step],
190    io: ExecIo,
191    modules: Vec<HostModule<DefaultProcessManager>>,
192    types: Vec<&'static TypeDescriptor>,
193) -> Result<LazyRunOutput> {
194    let mut resolver = PathResolver::new_lazy(build_context.clone())?;
195    resolver.set_workspace_root(build_context.clone());
196    let snapshot = resolver.snapshot_handle();
197    let fs: Box<dyn WorkspaceFs> = Box::new(resolver);
198    match run_steps_with_manager_with_modules(
199        fs,
200        steps,
201        default_process_manager(),
202        io,
203        modules,
204        types,
205    ) {
206        Ok((final_cwd, fs, bindings)) => Ok(LazyRunOutput {
207            final_cwd,
208            snapshot,
209            fs,
210            bindings,
211        }),
212        Err(err) => Err(enrich_lazy_error(&snapshot, build_context, err)),
213    }
214}
215
216/// Error enrichment for lazy runs: a materialized snapshot gets the same
217/// filesystem-snapshot treatment as eager runs; a pending one reports that
218/// no snapshot directory was ever created instead of describing a tree.
219/// Chain rendering is identical to the eager path (shared composer).
220pub fn enrich_lazy_error(
221    snapshot: &Arc<LazyGuardedTempDir>,
222    build_context: &GuardedPath,
223    err: anyhow::Error,
224) -> anyhow::Error {
225    match snapshot.get() {
226        Some(concrete) => {
227            let tree = match PathResolver::new(concrete.as_path(), build_context.as_path()) {
228                Ok(describe_fs) => describe_dir(&describe_fs, concrete, 2, 24),
229                Err(_) => String::from(SNAPSHOT_TREE_UNAVAILABLE),
230            };
231            let snapshot_msg = format!(
232                "filesystem snapshot (root {}):\n{}",
233                concrete.display(),
234                tree
235            );
236            compose_error_with_snapshot(err, snapshot_msg)
237        }
238        None => compose_error_with_snapshot(
239            err,
240            String::from(
241                "filesystem snapshot: never materialized (no snapshot directory was created)",
242            ),
243        ),
244    }
245}
246
247pub fn run_steps_with_fs(
248    fs: Box<dyn WorkspaceFs>,
249    steps: &[Step],
250    stdin: Option<SharedInput>,
251    stdout: Option<SharedOutput>,
252) -> Result<GuardedPath> {
253    let io = assemble_default_io(stdin, stdout);
254    run_steps_with_fs_with_io(fs, steps, io)
255}
256
257pub fn run_steps_with_fs_with_io(
258    fs: Box<dyn WorkspaceFs>,
259    steps: &[Step],
260    io: ExecIo,
261) -> Result<GuardedPath> {
262    run_steps_with_manager(fs, steps, default_process_manager(), io).map(|(cwd, _, _)| cwd)
263}
264
265/// Base name of a qualified `MODULE::NAME` reference for human-facing
266/// errors. Single source lives in `oxdock-parser`; listings (`FUNCTIONS()`,
267/// `DESCRIBE`) keep the qualified form while step errors name the callable
268/// as written.
269pub(crate) use oxdock_parser::base_name;
270
271/// Host introspection entry point: execute the DSL against a caller-provided
272/// filesystem and return the final working directory, the filesystem handle,
273/// and the top-level script variable bindings captured at `Flow::Done`.
274/// Bindings are read from the root variable scope only, so ephemeral
275/// variables from `FUNC` bodies, `FOR`/`WHILE` iterations, and `ASYNC` blocks
276/// are excluded. On script failure the scope is discarded with the error and
277/// no bindings are returned.
278#[allow(clippy::type_complexity)]
279pub fn run_steps_with_manager<P: ProcessManager>(
280    fs: Box<dyn WorkspaceFs>,
281    steps: &[Step],
282    process: P,
283    io: ExecIo,
284) -> Result<(GuardedPath, Box<dyn WorkspaceFs>, BTreeMap<String, Value>)> {
285    run_steps_with_manager_with_modules(fs, steps, process, io, Vec::new(), Vec::new())
286}
287
288/// Same as [`run_steps_with_manager`], plus host modules and types. Each
289/// module's functions become callable as `MODULE::NAME` with full step
290/// context; each type descriptor becomes visible to `TYPES()` and valid for
291/// `LET $x: NAME` declarations carrying same-named opaque payloads.
292/// Authors should derive entries with `#[oxdock_func]` / `#[oxdock_type]`
293/// (see `oxdock-func-macro`) and group them into [`HostModule`] instead of
294/// hand-writing metadata and glue.
295#[allow(clippy::type_complexity)]
296pub fn run_steps_with_manager_with_modules<P: ProcessManager>(
297    fs: Box<dyn WorkspaceFs>,
298    steps: &[Step],
299    process: P,
300    io: ExecIo,
301    modules: Vec<HostModule<P>>,
302    types: Vec<&'static self::typing::TypeDescriptor>,
303) -> Result<(GuardedPath, Box<dyn WorkspaceFs>, BTreeMap<String, Value>)> {
304    let mut state = new_state(fs, io)?;
305    for module in modules {
306        state.register_module(module);
307    }
308    for descriptor in types {
309        state.register_type(descriptor);
310    }
311    finish_run(state, process, steps)
312}
313
314fn new_state<P: ProcessManager>(fs: Box<dyn WorkspaceFs>, io: ExecIo) -> Result<ExecState<P>> {
315    let cwd = fs.root().clone();
316    let build_context = fs.build_context().clone();
317    let mut envs = BuiltinEnv::collect(&build_context).into_envs();
318    for (key, value) in io.inherit_env_overrides() {
319        envs.insert(key.clone(), value.clone());
320    }
321    let envs = Arc::new(envs);
322    let assert_windows = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
323    let assert_windows_stderr = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
324    let exact_stdout = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
325    let mut state = ExecState {
326        fs,
327        cargo_scratch: reserve_cargo_scratch()?,
328        cwd,
329        envs,
330        bg_children: Vec::new(),
331        scope_stack: Vec::new(),
332        io,
333        assert_windows: assert_windows.clone(),
334        assert_windows_stderr: assert_windows_stderr.clone(),
335        exact_stdout: exact_stdout.clone(),
336        var_scopes: Vec::new(),
337        cancel_token: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
338        active_process: std::sync::Arc::new(std::sync::Mutex::new(None)),
339        named_tasks: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
340        next_task_id: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)),
341        inside_async: false,
342        keeper_expiry: None,
343        cancellable: false,
344        functions: self::native::FunctionRegistry::with_builtins(),
345        types: self::typing::startup_type_map(),
346        call_depth: 0,
347        // Root flow is task 0; worker ids start at 1 (see next_task_id).
348        task_id: 0,
349        _marker: std::marker::PhantomData,
350    };
351
352    // Push a global variable scope so top-level LET assignments are captured.
353    state.push_var_scope();
354
355    Ok(state)
356}
357
358#[allow(clippy::type_complexity)]
359fn finish_run<P: ProcessManager>(
360    mut state: ExecState<P>,
361    process: P,
362    steps: &[Step],
363) -> Result<(GuardedPath, Box<dyn WorkspaceFs>, BTreeMap<String, Value>)> {
364    let assert_windows = Arc::clone(&state.assert_windows);
365    let assert_windows_stderr = Arc::clone(&state.assert_windows_stderr);
366    let exact_stdout = Arc::clone(&state.exact_stdout);
367    let _default_stdout = std::io::stdout();
368    let stdin = state.io.stdin().into();
369    // Every emitted byte flows through the tee so stream assertions see both
370    // interpreter output and streamed child output, even when no capture
371    // sink was configured (forwarding to real stdout in that case).
372    let stdout = Some(StreamHandle::Stream(teed_stdout(
373        state.io.stdout(),
374        assert_windows,
375        exact_stdout,
376    )));
377    let stderr = state
378        .io
379        .stderr()
380        .map(|sink| StreamHandle::Stream(teed_stderr(Some(sink), assert_windows_stderr)));
381    let mut proc_mgr = process;
382    let flow = execute_steps(
383        &mut state,
384        &mut proc_mgr,
385        steps,
386        stdin,
387        false,
388        stdout,
389        stderr,
390        true,
391    )?;
392    match flow {
393        // Concretize on the way out so a bare pending anchor never escapes as
394        // the reported final directory (shell entry / OUT_DIR sync need real
395        // paths; a pending run reports the local root or concretizes after
396        // shell-entry materialization through the returned fs handle).
397        self::steps::Flow::Done => {
398            // Root-scope isolation: at Done all blocks have popped, so the
399            // first scope is the global one. Read it explicitly (rather than
400            // a flattened all-scopes view) and strip declared types so hosts
401            // see plain values.
402            let bindings: BTreeMap<String, Value> = state
403                .var_scopes
404                .first()
405                .map(|scope| {
406                    scope
407                        .iter()
408                        .map(|(k, (_, v))| (k.clone(), v.clone()))
409                        .collect()
410                })
411                .unwrap_or_default();
412            Ok((state.fs.concretize_cwd(&state.cwd), state.fs, bindings))
413        }
414        self::steps::Flow::Break { idx } => {
415            anyhow::bail!("step {}: BREAK outside loop", idx + 1)
416        }
417        self::steps::Flow::Continue { idx } => {
418            anyhow::bail!("step {}: CONTINUE outside loop", idx + 1)
419        }
420        self::steps::Flow::Return { idx, .. } => {
421            // Values only cross a boundary: a function call, an ASYNC
422            // task, or an inline LET block. With none enclosing, the
423            // pipeline top rejects it like any other escaped flow.
424            anyhow::bail!(
425                "step {}: RETURN outside function, ASYNC task, or LET block",
426                idx + 1
427            )
428        }
429    }
430}