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