Skip to main content

oxdock_cli/
lib.rs

1use anyhow::{Context, Result, bail};
2use oxdock_fs::{
3    GuardedPath, LazyGuardedTempDir, PathResolver, WorkspaceFs, discover_workspace_root,
4    env as oxdock_env, init_temp_gc,
5};
6#[cfg(windows)]
7use oxdock_process::CommandBuilder;
8use oxdock_process::{DefaultProcessManager, SharedInput};
9use std::env;
10use std::io::{self, IsTerminal, Read};
11use std::sync::{Arc, Mutex};
12
13pub use oxdock_core::{
14    Engine, EngineOutput, ExecState, FuncKind, FuncMeta, FuncParam, HostModule, HostRegistration,
15    NativeFn, OxDockFn, OxDockType, PureFn, StepCtx, TypeDescriptor, Value, parse_script,
16    parse_script_with_modules, run_steps, run_steps_with_context, run_steps_with_context_result,
17    run_steps_with_manager_with_modules,
18};
19use oxdock_core::{ExecIo, run_steps_with_lazy_snapshot_and_modules};
20pub use oxdock_parser::{Guard, Step, StepKind};
21pub use oxdock_process::shell_program;
22use std::collections::BTreeMap;
23
24mod endpoints;
25pub use endpoints::{EndpointFlags, build_registry};
26use oxdock_net_plugin::EndpointRegistry;
27
28/// Host modules bundled into the CLI runner. The base build exposes STD
29/// plus the NET virtual-endpoint toolkit (`NET_LISTEN`, `NET_ACCEPT`,
30/// `NET_CLOSE`, `NET_CONNECT`); `--features ssh` additionally registers
31/// the SSH server and client (`SSH_SERVE`, `SSH_ACCEPT`, `SSH_DEQUEUE`,
32/// `SSH_PUMP_CHANNEL`, `SSH_CLOSE`, `SSH_CONNECT`, `SSH_PUMP`) from
33/// oxdock-ssh-plugin.
34#[cfg(feature = "ssh")]
35fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
36    cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
37}
38
39/// Host modules bundled into the CLI runner (base build: STD and NET).
40#[cfg(not(feature = "ssh"))]
41fn cli_host_modules() -> Vec<HostModule<DefaultProcessManager>> {
42    cli_host_modules_with(&Arc::new(EndpointRegistry::new(false)))
43}
44
45/// Host modules resolving virtual endpoints through `registry`: the CLI
46/// builds it from `--listen`/`-p`/`--offline` before parsing so bind
47/// conflicts fail fast.
48#[cfg(feature = "ssh")]
49fn cli_host_modules_with(
50    registry: &Arc<EndpointRegistry>,
51) -> Vec<HostModule<DefaultProcessManager>> {
52    vec![
53        oxdock_net_plugin::module_with_endpoints(Arc::clone(registry)),
54        oxdock_ssh_plugin::module_with_endpoints(Arc::clone(registry)),
55    ]
56}
57
58/// Host modules bundled into the CLI runner (base build: STD and NET).
59#[cfg(not(feature = "ssh"))]
60fn cli_host_modules_with(
61    registry: &Arc<EndpointRegistry>,
62) -> Vec<HostModule<DefaultProcessManager>> {
63    vec![oxdock_net_plugin::module_with_endpoints(Arc::clone(
64        registry,
65    ))]
66}
67
68/// Host types bundled into the CLI runner alongside [`cli_host_modules`].
69#[cfg(feature = "ssh")]
70fn cli_host_types() -> Vec<&'static TypeDescriptor> {
71    vec![
72        oxdock_net_plugin::NetListenerTag::descriptor(),
73        oxdock_ssh_plugin::SshServerTag::descriptor(),
74        oxdock_ssh_plugin::SshSessionTag::descriptor(),
75    ]
76}
77
78/// Host types bundled into the CLI runner (base build: NET only).
79#[cfg(not(feature = "ssh"))]
80fn cli_host_types() -> Vec<&'static TypeDescriptor> {
81    vec![oxdock_net_plugin::NetListenerTag::descriptor()]
82}
83
84/// Parse a CLI script against STD plus any bundled host modules. Without
85/// extra modules this is exactly `parse_script`, so base-build behavior
86/// never changes.
87fn parse_cli_script(script: &str) -> Result<Vec<Step>> {
88    let modules = cli_host_modules();
89    if modules.is_empty() {
90        parse_script(script)
91    } else {
92        let mut engine = Engine::new();
93        for module in modules {
94            engine.register_module(module);
95        }
96        parse_script_with_modules(script, engine.module_table())
97    }
98}
99
100pub fn run() -> Result<()> {
101    init_temp_gc();
102    let workspace_root = discover_workspace_root().context("guard workspace root")?;
103
104    let mut args = std::env::args().skip(1);
105    // `--help`/`-h` surfaces as the usage text in the parse error (parse must
106    // not exit the process itself: it is public library API). Print it and
107    // succeed so the binary exits 0.
108    let opts = match Options::parse(&mut args, &workspace_root) {
109        Ok(opts) => opts,
110        Err(err) if err.to_string() == usage() => {
111            print!("{err}");
112            return Ok(());
113        }
114        Err(err) => return Err(err),
115    };
116    execute(opts, workspace_root)
117}
118
119#[derive(Debug, Clone)]
120pub enum ScriptSource {
121    Path(GuardedPath),
122    Stdin,
123}
124
125#[derive(Debug, Clone)]
126pub struct Options {
127    pub script: ScriptSource,
128    pub shell: bool,
129    pub endpoints: EndpointFlags,
130}
131
132impl Options {
133    pub fn parse(
134        args: &mut impl Iterator<Item = String>,
135        workspace_root: &GuardedPath,
136    ) -> Result<Self> {
137        use lexopt::Arg::{Long, Short, Value};
138
139        let mut script: Option<ScriptSource> = None;
140        let mut shell = false;
141        let mut endpoints = EndpointFlags::default();
142        let mut set_script = |source: ScriptSource, origin: &str| -> Result<()> {
143            if script.is_some() {
144                bail!("script given multiple times ({origin})");
145            }
146            script = Some(source);
147            Ok(())
148        };
149        // Pass the stream unfiltered: an explicit empty value (for
150        // example `--script ""`) is a real token boundary, so stripping
151        // empties up front would shift every following value. Bare empty
152        // positionals are skipped in the `Value` arm instead.
153        let mut parser = lexopt::Parser::from_args(args.by_ref());
154        while let Some(arg) = parser.next()? {
155            match arg {
156                Long("script") => {
157                    let path = value_string(
158                        parser
159                            .value()
160                            .map_err(|_| anyhow::anyhow!("--script requires a path"))?,
161                    )?;
162                    if path.is_empty() {
163                        bail!("--script requires a path");
164                    }
165                    if path == "-" {
166                        set_script(ScriptSource::Stdin, "--script -")?;
167                    } else {
168                        set_script(
169                            ScriptSource::Path(
170                                workspace_root
171                                    .join(&path)
172                                    .with_context(|| format!("guard script path {path}"))?,
173                            ),
174                            "--script",
175                        )?;
176                    }
177                }
178                Long("shell") => {
179                    shell = true;
180                }
181                Long("listen") => {
182                    let raw = value_string(
183                        parser
184                            .value()
185                            .map_err(|_| anyhow::anyhow!("--listen requires an address"))?,
186                    )?;
187                    endpoints.listens.push(endpoints::parse_listen_arg(&raw)?);
188                }
189                Short('p') => {
190                    let raw = value_string(
191                        parser
192                            .value()
193                            .map_err(|_| anyhow::anyhow!("-p requires outer:inner"))?,
194                    )?;
195                    endpoints
196                        .publishes
197                        .push(endpoints::parse_publish_arg(&raw)?);
198                }
199                Long("offline") => {
200                    endpoints.offline = true;
201                }
202                Long("help") | Short('h') => {
203                    bail!("{}", usage());
204                }
205                Value(value) => {
206                    let text = value_string(value)?;
207                    if text.is_empty() {
208                        continue;
209                    }
210                    if text == "-" {
211                        set_script(ScriptSource::Stdin, "positional `-`")?;
212                    } else {
213                        set_script(
214                            ScriptSource::Path(
215                                workspace_root
216                                    .join(&text)
217                                    .with_context(|| format!("guard script path {text}"))?,
218                            ),
219                            "positional argument",
220                        )?;
221                    }
222                }
223                Long(other) => bail!("unexpected flag: --{other}"),
224                Short(other) => bail!("unexpected flag: -{other}"),
225            }
226        }
227
228        let script = script.unwrap_or(ScriptSource::Stdin);
229
230        Ok(Self {
231            script,
232            shell,
233            endpoints,
234        })
235    }
236}
237
238/// Option/positional text out of a lexopt value. Inputs arrive as
239/// `String`, so non-UTF8 is unreachable in practice; fail loudly anyway.
240fn value_string(value: std::ffi::OsString) -> Result<String> {
241    value
242        .into_string()
243        .map_err(|_| anyhow::anyhow!("argument must be UTF-8"))
244}
245
246/// Human-readable CLI usage, printed for `--help`/`-h`.
247pub fn usage() -> String {
248    let version = env!("CARGO_PKG_VERSION");
249    let description = env!("CARGO_PKG_DESCRIPTION");
250    indoc::formatdoc! {"
251        oxdock {version} — {description}
252        Usage: oxdock [OPTIONS] [SCRIPT]
253          SCRIPT             script file path (same as `--script <file>`); `-` reads stdin
254          --script <file|->  script file under the workspace root, or `-` for stdin
255          --shell            run the script, then drop into an interactive shell (requires a TTY)
256          --listen <addr>    expose a logical service port ([host:]port, repeatable)
257          -p <[host:]outer:inner>  map outer port to an inner service port or name (repeatable; outer 0 is ephemeral)
258          --offline          open no sockets (conflicts with --listen/-p)
259          --help, -h         print this help and exit
260        With no script given, reads the script from stdin (must be piped unless `--shell`).
261        Scripts declare logical endpoints (a port like 2251); the flags above map them to interfaces.
262    "}
263}
264
265pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
266    init_temp_gc();
267    execute_with_shell_runner(opts, workspace_root, run_shell, true)
268}
269
270/// Output of a script execution (issue #131).
271///
272/// The snapshot directory is created lazily: [`ExecutionResult::snapshot`]
273/// stays unmaterialized when the script never touches snapshot-rooted state
274/// (e.g. `WORKSPACE LOCAL`-only or empty scripts), in which case
275/// [`ExecutionResult::has_snapshot`] is false and `final_cwd` points under
276/// the workspace root.
277pub struct ExecutionResult {
278    /// Shared ownership of the snapshot backing dir. The physical directory
279    /// lives exactly as long as the last surviving clone (normally this
280    /// struct, since execution-internal clones are dropped before return).
281    pub snapshot: Arc<LazyGuardedTempDir>,
282    /// Actual final cwd: inside the snapshot when materialized, otherwise
283    /// under the workspace/local root.
284    pub final_cwd: GuardedPath,
285    /// Top-level script variable bindings captured at completion, keyed by
286    /// variable name. Empty when the script is empty. Populated exclusively
287    /// by [`execute_with_result`]; `--shell` runs never produce one.
288    pub bindings: BTreeMap<String, Value>,
289}
290
291impl ExecutionResult {
292    /// Whether the run materialized the snapshot tempdir.
293    pub fn has_snapshot(&self) -> bool {
294        self.snapshot.is_materialized()
295    }
296
297    /// Borrow the snapshot root iff materialized.
298    pub fn snapshot_path(&self) -> Option<&GuardedPath> {
299        self.snapshot.get()
300    }
301}
302
303pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
304    if opts.shell {
305        bail!("execute_with_result does not support --shell");
306    }
307
308    // Read + parse BEFORE any tempdir exists so LOCAL-only scripts never
309    // create a snapshot directory they never use (issue #131).
310    let script = read_script(&opts.script, &workspace_root)?;
311
312    let mut final_cwd = workspace_root.clone();
313    let snapshot = Arc::new(LazyGuardedTempDir::new());
314    if !script.trim().is_empty() {
315        // Bind endpoint sockets before parsing: conflicts fail fast,
316        // never parse-then-fail-on-bind.
317        let endpoints = build_registry(&opts.endpoints)?;
318        let steps = parse_cli_script(&script)?;
319        let output = run_steps_with_lazy_snapshot_and_modules(
320            &workspace_root,
321            &steps,
322            ExecIo::new(),
323            cli_host_modules_with(&endpoints),
324            cli_host_types(),
325        )?;
326        final_cwd = output.final_cwd;
327        return Ok(ExecutionResult {
328            snapshot: output.snapshot,
329            final_cwd,
330            bindings: output.bindings,
331        });
332    }
333
334    Ok(ExecutionResult {
335        snapshot,
336        final_cwd,
337        bindings: BTreeMap::new(),
338    })
339}
340
341/// Report ephemeral outer resolutions (`-p 0:<inner>`) to stderr so the
342/// runner learns the real ports. Fixed mappings need no report: the flags
343/// already name them.
344fn report_ephemeral_publishes(flags: &EndpointFlags, registry: &Arc<EndpointRegistry>) {
345    for (outer, inner) in &flags.publishes {
346        if outer.port() != 0 {
347            continue;
348        }
349        match registry.bound_addr(inner) {
350            Some(addr) => eprintln!("oxdock: published {addr} -> {inner}"),
351            None => eprintln!("oxdock: published <unbound> -> {inner}"),
352        }
353    }
354}
355
356/// Read the script source without creating any execution state.
357fn read_script(source: &ScriptSource, workspace_root: &GuardedPath) -> Result<String> {
358    match source {
359        ScriptSource::Path(path) => {
360            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
361            resolver
362                .read_to_string(path)
363                .with_context(|| format!("failed to read script at {}", path.display()))
364        }
365        ScriptSource::Stdin => {
366            let mut buf = String::new();
367            io::stdin()
368                .lock()
369                .read_to_string(&mut buf)
370                .context("failed to read script from stdin")?;
371            Ok(buf)
372        }
373    }
374}
375
376fn execute_with_shell_runner<F>(
377    opts: Options,
378    workspace_root: GuardedPath,
379    shell_runner: F,
380    require_tty: bool,
381) -> Result<()>
382where
383    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
384{
385    #[cfg(windows)]
386    maybe_reexec_shell_to_temp(&opts)?;
387
388    // Interpret a tiny Dockerfile-ish script. No tempdir exists yet: the
389    // snapshot materializes lazily on first snapshot-targeted step, so an
390    // empty non-shell run creates nothing at all (issue #131).
391    let script = match &opts.script {
392        ScriptSource::Path(path) => {
393            // Read script path via PathResolver rooted at the workspace so
394            // script files are validated to live under the workspace.
395            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
396            resolver
397                .read_to_string(path)
398                .with_context(|| format!("failed to read script at {}", path.display()))?
399        }
400        ScriptSource::Stdin => {
401            let stdin = io::stdin();
402            if stdin.is_terminal() {
403                // No piped script provided. If the caller requested `--shell`
404                // allow running with an initially-empty script so we can either
405                // drop into the interactive shell or open the editor later.
406                // Otherwise, require a script on stdin.
407                if opts.shell {
408                    String::new()
409                } else {
410                    bail!(
411                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
412                    );
413                }
414            } else {
415                let mut buf = String::new();
416                stdin
417                    .lock()
418                    .read_to_string(&mut buf)
419                    .context("failed to read script from stdin")?;
420                buf
421            }
422        }
423    };
424
425    // Parse and run steps if we have a non-empty script. Empty scripts are
426    // valid when `--shell` is requested and the caller didn't pipe a script.
427    // Use the caller's workspace as the build context so WORKSPACE LOCAL can
428    // hop back and so COPY can source from the original tree if needed.
429    // Capture the final working directory so shells inherit whatever WORKDIR
430    // the script ended on.
431    let mut final_cwd = workspace_root.clone();
432    let mut snapshot = Arc::new(LazyGuardedTempDir::new());
433    let mut fs: Option<Box<dyn WorkspaceFs>> = None;
434    if !script.trim().is_empty() {
435        // Bind endpoint sockets before parsing: conflicts fail fast,
436        // never parse-then-fail-on-bind.
437        let endpoints = build_registry(&opts.endpoints)?;
438        report_ephemeral_publishes(&opts.endpoints, &endpoints);
439        let steps = parse_cli_script(&script)?;
440        // If we are running a script from a file, we might have stdin available for the script itself.
441        // If we read the script from stdin, then stdin is consumed.
442        // But if opts.script is ScriptSource::Path, stdin is still available.
443
444        let mut stdin_handle: Option<SharedInput> = None;
445        if let ScriptSource::Path(_) = opts.script {
446            let stdin = io::stdin();
447            if !stdin.is_terminal() {
448                // Wrap stdin in SharedInput (Arc<Mutex<dyn Read + Send>>)
449                // Note: std::io::Stdin is a handle, but we need an owned Read + Send.
450                // std::io::stdin() returns Stdin, which implements Read + Send.
451                // However, we need to be careful about locking.
452                // We can wrap the Stdin struct directly.
453                stdin_handle = Some(Arc::new(Mutex::new(stdin)));
454            }
455        }
456
457        let mut io_cfg = ExecIo::new();
458        io_cfg.set_stdin(stdin_handle);
459        let output = run_steps_with_lazy_snapshot_and_modules(
460            &workspace_root,
461            &steps,
462            io_cfg,
463            cli_host_modules_with(&endpoints),
464            cli_host_types(),
465        )?;
466        final_cwd = output.final_cwd;
467        snapshot = output.snapshot;
468        fs = Some(output.fs);
469    }
470
471    // If requested, drop into an interactive shell after running the script.
472    if opts.shell {
473        if require_tty && !has_controlling_tty() {
474            bail!("--shell requires a tty (no controlling tty available)");
475        }
476        // The shell needs a concrete directory: materialize here at shell
477        // entry (not at startup) when the script left the snapshot pending,
478        // then converge the cwd onto the shared concrete root. An empty
479        // script starts the shell in a fresh snapshot directory.
480        match fs.as_ref() {
481            Some(fs) => {
482                if fs.is_snapshot_pending() {
483                    snapshot
484                        .materialize()
485                        .context("failed to create shell temp dir")?;
486                }
487                final_cwd = fs.concretize_cwd(&final_cwd);
488            }
489            None => {
490                snapshot
491                    .materialize()
492                    .context("failed to create shell temp dir")?;
493                final_cwd = snapshot
494                    .get()
495                    .cloned()
496                    .expect("shell snapshot materialized above");
497            }
498        }
499        return shell_runner(&final_cwd, &workspace_root);
500    }
501
502    Ok(())
503}
504
505#[cfg(test)]
506fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
507where
508    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
509{
510    execute_with_shell_runner(opts, workspace_root, shell_runner, false)
511}
512
513fn has_controlling_tty() -> bool {
514    // Prefer checking whether stdin or stderr is a terminal. This avoids
515    // directly opening device files via `std::fs` while still detecting
516    // whether an interactive tty is available in the common cases.
517    #[cfg(unix)]
518    {
519        io::stdin().is_terminal() || io::stderr().is_terminal()
520    }
521
522    #[cfg(windows)]
523    {
524        io::stdin().is_terminal() || io::stderr().is_terminal()
525    }
526
527    #[cfg(not(any(unix, windows)))]
528    {
529        false
530    }
531}
532
533#[cfg(windows)]
534fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
535    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
536    // original target exe is free for rebuilding while the shell stays open.
537    if !opts.shell {
538        return Ok(());
539    }
540    if std::env::var(oxdock_env::SHELL_REEXEC).ok().as_deref() == Some("1") {
541        return Ok(());
542    }
543
544    let self_path = std::env::current_exe().context("determine current executable")?;
545    let base_temp =
546        GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
547    let ts = std::time::SystemTime::now()
548        .duration_since(std::time::UNIX_EPOCH)
549        .unwrap_or_default()
550        .as_millis();
551    let temp_file = base_temp
552        .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
553        .context("construct temp shell path")?;
554
555    // Copy the current executable into the temporary location via a
556    // resolver whose root is the temp directory. The source may live
557    // outside the temp dir, so use `copy_file_from_external`.
558    let temp_root_guard = temp_file
559        .parent()
560        .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
561    let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
562    let dest = temp_file;
563    #[allow(clippy::disallowed_types)]
564    let source = oxdock_fs::UnguardedPath::external(self_path);
565    resolver_temp
566        .copy_file_from_unguarded(&source, &dest)
567        .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
568
569    let mut cmd = CommandBuilder::new(dest.as_path());
570    cmd.args(std::env::args_os().skip(1));
571    cmd.env(oxdock_env::SHELL_REEXEC, "1");
572    cmd.spawn()
573        .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
574
575    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
576    std::process::exit(0);
577}
578
579pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
580    run_steps_with_context(workspace_root, workspace_root, steps)
581}
582
583fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
584    #[cfg(windows)]
585    let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
586    #[cfg(windows)]
587    let workspace_disp = oxdock_fs::command_path(workspace_root)
588        .as_ref()
589        .display()
590        .to_string();
591
592    #[cfg(not(windows))]
593    let cwd_disp = cwd.display().to_string();
594    #[cfg(not(windows))]
595    let workspace_disp = workspace_root.display().to_string();
596
597    let pkg = env::var(oxdock_env::CARGO_PKG_NAME)
598        .unwrap_or_else(|_| oxdock_env::FALLBACK_APP_NAME.to_string());
599    indoc::formatdoc! {"
600        {pkg} shell workspace
601          cwd: {cwd_disp}
602          source: workspace root at {workspace_disp}
603          lifetime: temporary directory created for this shell session; it disappears when you exit
604          creation: temp workspace starts empty unless your script copies files into it
605
606          WARNING: This shell still runs on your host filesystem and is **not** isolated!
607    "}
608}
609
610fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
611    oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
612}
613
614// `command_path` now lives in `oxdock-fs` to centralize Path usage.
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use indoc::indoc;
620    use oxdock_fs::PathResolver;
621    use std::cell::{Cell, RefCell};
622
623    #[cfg_attr(
624        miri,
625        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
626    )]
627    #[test]
628    fn shell_runner_receives_final_workdir() -> Result<()> {
629        let workspace = GuardedPath::tempdir()?;
630        let workspace_root = workspace.as_guarded_path().clone();
631        let script_path = workspace_root.join("script.ox")?;
632        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
633        let script = indoc! {"
634            WRITE temp.txt 123
635            WORKDIR sub
636        "};
637        resolver.write_file(&script_path, script.as_bytes())?;
638
639        let opts = Options {
640            script: ScriptSource::Path(script_path),
641            shell: true,
642            endpoints: EndpointFlags::default(),
643        };
644
645        let observed = Cell::new(false);
646        execute_for_test(opts, workspace_root.clone(), |cwd, _| {
647            assert!(
648                cwd.as_path().ends_with("sub"),
649                "final cwd should end in WORKDIR target, got {}",
650                cwd.display()
651            );
652
653            let temp_root = GuardedPath::new_root(cwd.root())
654                .context("construct guard for temp workspace root")?;
655            let sub_dir = temp_root.join("sub")?;
656            assert_eq!(
657                cwd.as_path(),
658                sub_dir.as_path(),
659                "shell runner cwd should match guarded sub dir"
660            );
661            let temp_file = temp_root.join("temp.txt")?;
662            let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
663            let contents = temp_resolver.read_to_string(&temp_file)?;
664            assert!(
665                contents.contains("123"),
666                "expected WRITE command to materialize temp file"
667            );
668            observed.set(true);
669            Ok(())
670        })?;
671
672        assert!(
673            observed.into_inner(),
674            "shell runner closure should have been invoked"
675        );
676        Ok(())
677    }
678
679    #[cfg_attr(
680        miri,
681        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
682    )]
683    #[test]
684    fn options_parse_requires_script_path_value() {
685        let workspace = GuardedPath::tempdir().expect("tempdir");
686        let mut args = vec!["--script".to_string()].into_iter();
687        let err = Options::parse(&mut args, workspace.as_guarded_path())
688            .expect_err("expected missing path error");
689        assert!(err.to_string().contains("--script requires a path"));
690    }
691
692    #[cfg_attr(
693        miri,
694        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
695    )]
696    #[test]
697    fn options_parse_script_path_and_shell() {
698        let workspace = GuardedPath::tempdir().expect("tempdir");
699        let workspace_root = workspace.as_guarded_path().clone();
700        let script_path = workspace_root.join("script.txt").expect("script path");
701        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
702            .expect("resolver");
703        resolver
704            .write_file(&script_path, b"WRITE out.txt hi")
705            .expect("write script");
706        let mut args = vec![
707            "--script".to_string(),
708            "script.txt".to_string(),
709            "--shell".to_string(),
710        ]
711        .into_iter();
712        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
713        assert!(opts.shell);
714        match opts.script {
715            ScriptSource::Path(path) => assert_eq!(path, script_path),
716            ScriptSource::Stdin => panic!("expected path script"),
717        }
718    }
719
720    #[cfg_attr(
721        miri,
722        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
723    )]
724    #[test]
725    fn options_parse_positional_script_path() {
726        let workspace = GuardedPath::tempdir().expect("tempdir");
727        let workspace_root = workspace.as_guarded_path().clone();
728        let mut args = vec!["script.txt".to_string()].into_iter();
729        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
730        assert!(!opts.shell);
731        match opts.script {
732            ScriptSource::Path(path) => assert_eq!(
733                path,
734                workspace_root.join("script.txt").expect("script path")
735            ),
736            ScriptSource::Stdin => panic!("expected path script"),
737        }
738    }
739
740    #[cfg_attr(
741        miri,
742        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
743    )]
744    #[test]
745    fn options_parse_positional_dash_reads_stdin() {
746        let workspace = GuardedPath::tempdir().expect("tempdir");
747        let mut args = vec!["-".to_string()].into_iter();
748        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
749        assert!(matches!(opts.script, ScriptSource::Stdin));
750    }
751
752    #[cfg_attr(
753        miri,
754        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
755    )]
756    #[test]
757    fn options_parse_rejects_duplicate_script_sources() {
758        let workspace = GuardedPath::tempdir().expect("tempdir");
759        let workspace_root = workspace.as_guarded_path().clone();
760        let mut args = vec![
761            "a.ox".to_string(),
762            "--script".to_string(),
763            "b.ox".to_string(),
764        ]
765        .into_iter();
766        let err = Options::parse(&mut args, &workspace_root)
767            .expect_err("expected duplicate script error");
768        assert!(err.to_string().contains("multiple times"), "{err:?}");
769
770        let mut args = vec!["a.ox".to_string(), "b.ox".to_string()].into_iter();
771        let err = Options::parse(&mut args, &workspace_root)
772            .expect_err("expected duplicate script error");
773        assert!(err.to_string().contains("multiple times"), "{err:?}");
774    }
775
776    #[cfg_attr(
777        miri,
778        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
779    )]
780    #[test]
781    fn options_parse_rejects_unknown_flags() {
782        let workspace = GuardedPath::tempdir().expect("tempdir");
783        let mut args = vec!["--frobnicate".to_string()].into_iter();
784        let err = Options::parse(&mut args, workspace.as_guarded_path())
785            .expect_err("expected unknown flag error");
786        assert!(err.to_string().contains("unexpected flag"), "{err:?}");
787    }
788
789    #[test]
790    fn usage_describes_positional_script_and_help() {
791        let text = usage();
792        assert!(text.contains("Usage: oxdock"), "{text}");
793        assert!(text.contains("SCRIPT"), "{text}");
794        assert!(text.contains("--script"), "{text}");
795        assert!(text.contains("--help"), "{text}");
796        assert!(text.contains("--listen"), "{text}");
797        assert!(text.contains("-p <[host:]outer:inner>"), "{text}");
798        assert!(text.contains("--offline"), "{text}");
799        // Tagline is single-sourced from the package manifest, not hardcoded.
800        assert!(text.contains(env!("CARGO_PKG_DESCRIPTION")), "{text}");
801    }
802
803    #[cfg_attr(
804        miri,
805        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
806    )]
807    #[test]
808    fn options_parse_endpoint_flags() {
809        // Pure flag parsing: no sockets open, but tempdir keeps the
810        // ignore uniform with the neighboring parse tests.
811        let workspace = GuardedPath::tempdir().expect("tempdir");
812        let mut args = vec![
813            "--listen".to_string(),
814            "0.0.0.0:2251".to_string(),
815            "-p".to_string(),
816            "2222:demo-proxy".to_string(),
817            "-p".to_string(),
818            "0:2252".to_string(),
819            "-".to_string(),
820        ]
821        .into_iter();
822        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
823        assert_eq!(opts.endpoints.listens.len(), 1);
824        assert_eq!(opts.endpoints.publishes.len(), 2);
825        assert!(!opts.endpoints.offline);
826        assert!(matches!(opts.script, ScriptSource::Stdin));
827    }
828
829    #[cfg_attr(
830        miri,
831        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
832    )]
833    #[test]
834    fn options_parse_offline_flag() {
835        let workspace = GuardedPath::tempdir().expect("tempdir");
836        let mut args = vec!["--offline".to_string(), "-".to_string()].into_iter();
837        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
838        assert!(opts.endpoints.offline);
839    }
840
841    #[cfg_attr(
842        miri,
843        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
844    )]
845    #[test]
846    fn options_parse_rejects_bad_endpoint_flags() {
847        let workspace = GuardedPath::tempdir().expect("tempdir");
848        for args in [
849            vec!["--listen"],
850            vec!["--listen", "0.0.0.0:0"],
851            vec!["-p"],
852            vec!["-p", "2222"],
853            vec!["-p", "2222:0"],
854        ] {
855            let mut args = args.into_iter().map(str::to_string);
856            Options::parse(&mut args, workspace.as_guarded_path())
857                .expect_err("bad endpoint flag must fail");
858        }
859    }
860
861    #[cfg_attr(
862        miri,
863        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
864    )]
865    #[test]
866    fn options_parse_empty_values_hold_token_boundaries() {
867        // Regression: explicit empty values must not shift the stream.
868        // `--script ""` bails instead of consuming the next token, and a
869        // bare empty positional is skipped like before.
870        let workspace = GuardedPath::tempdir().expect("tempdir");
871        let mut args = vec![
872            "--script".to_string(),
873            "".to_string(),
874            "--shell".to_string(),
875        ]
876        .into_iter();
877        let err = Options::parse(&mut args, workspace.as_guarded_path())
878            .expect_err("empty script path must fail");
879        assert!(
880            err.to_string().contains("--script requires a path"),
881            "{err:?}"
882        );
883        let mut args = vec!["".to_string(), "-".to_string()].into_iter();
884        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
885        assert!(matches!(opts.script, ScriptSource::Stdin));
886    }
887
888    #[cfg_attr(
889        miri,
890        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
891    )]
892    #[test]
893    fn options_parse_accepts_equals_and_attached_forms() {
894        // lexopt-native spellings: `--flag=value`, attached short values,
895        // and `--` separating positionals.
896        let workspace = GuardedPath::tempdir().expect("tempdir");
897        let workspace_root = workspace.as_guarded_path().clone();
898        let mut args = vec![
899            "--listen=0.0.0.0:2251".to_string(),
900            "-p2222:demo-proxy".to_string(),
901            "--".to_string(),
902            "script.ox".to_string(),
903        ]
904        .into_iter();
905        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
906        assert_eq!(opts.endpoints.listens.len(), 1);
907        assert_eq!(opts.endpoints.publishes.len(), 1);
908        match opts.script {
909            ScriptSource::Path(path) => {
910                assert_eq!(path, workspace_root.join("script.ox").expect("script path"))
911            }
912            ScriptSource::Stdin => panic!("expected path script after --"),
913        }
914    }
915
916    #[cfg_attr(
917        miri,
918        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
919    )]
920    #[test]
921    fn options_parse_help_returns_usage_error_without_exiting() {
922        // Regression: parse is public library API and must return instead of
923        // terminating the process; `run()` turns this error into a clean exit 0.
924        let workspace = GuardedPath::tempdir().expect("tempdir");
925        for flag in ["--help", "-h"] {
926            let mut args = vec![flag.to_string()].into_iter();
927            let err = Options::parse(&mut args, workspace.as_guarded_path())
928                .expect_err("help flag must not parse as options");
929            assert_eq!(err.to_string(), usage());
930        }
931    }
932
933    #[cfg_attr(
934        miri,
935        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
936    )]
937    #[test]
938    fn execute_with_result_runs_script() {
939        let workspace = GuardedPath::tempdir().expect("tempdir");
940        let workspace_root = workspace.as_guarded_path().clone();
941        let script_path = workspace_root.join("script.txt").expect("script path");
942        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
943            .expect("resolver");
944        resolver
945            .write_file(&script_path, b"WRITE out.txt hi")
946            .expect("write script");
947        let opts = Options {
948            script: ScriptSource::Path(script_path),
949            shell: false,
950            endpoints: EndpointFlags::default(),
951        };
952        let result = execute_with_result(opts, workspace_root).expect("execute");
953        let snapshot = result
954            .snapshot_path()
955            .expect("default WRITE materializes the snapshot");
956        assert_eq!(snapshot, &result.final_cwd);
957        let temp_resolver = PathResolver::new(snapshot.root(), snapshot.root()).expect("resolver");
958        let out = snapshot.join("out.txt").expect("out path");
959        let contents = temp_resolver.read_to_string(&out).expect("read out");
960        assert_eq!(contents.trim(), "hi");
961    }
962
963    #[cfg_attr(
964        miri,
965        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
966    )]
967    #[test]
968    fn execute_with_result_local_only_creates_no_snapshot() {
969        let workspace = GuardedPath::tempdir().expect("tempdir");
970        let workspace_root = workspace.as_guarded_path().clone();
971        let script_path = workspace_root.join("script.txt").expect("script path");
972        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
973            .expect("resolver");
974        resolver
975            .write_file(&script_path, b"WORKSPACE LOCAL\nWRITE out.txt hi")
976            .expect("write script");
977        let opts = Options {
978            script: ScriptSource::Path(script_path),
979            shell: false,
980            endpoints: EndpointFlags::default(),
981        };
982        let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
983        assert!(
984            !result.has_snapshot(),
985            "WORKSPACE LOCAL-only script must not create a snapshot tempdir"
986        );
987        assert!(result.snapshot_path().is_none());
988        // The write landed in the live workspace tree instead.
989        let out = workspace_root.join("out.txt").expect("out path");
990        let contents = resolver.read_to_string(&out).expect("read out");
991        assert_eq!(contents.trim(), "hi");
992        // The reported cwd stays under the workspace root.
993        assert_eq!(result.final_cwd.root(), workspace_root.as_path());
994    }
995
996    #[cfg_attr(
997        miri,
998        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
999    )]
1000    #[test]
1001    fn execute_with_result_empty_script_creates_no_snapshot() {
1002        let workspace = GuardedPath::tempdir().expect("tempdir");
1003        let workspace_root = workspace.as_guarded_path().clone();
1004        let script_path = workspace_root.join("empty.txt").expect("script path");
1005        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
1006            .expect("resolver");
1007        resolver
1008            .write_file(&script_path, b"")
1009            .expect("write script");
1010        let opts = Options {
1011            script: ScriptSource::Path(script_path),
1012            shell: false,
1013            endpoints: EndpointFlags::default(),
1014        };
1015        let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
1016        assert!(
1017            !result.has_snapshot(),
1018            "empty script must not create a snapshot tempdir"
1019        );
1020        assert_eq!(result.final_cwd, workspace_root);
1021    }
1022
1023    #[cfg_attr(
1024        miri,
1025        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1026    )]
1027    #[test]
1028    fn execute_for_test_invokes_shell_runner() -> Result<()> {
1029        let workspace = GuardedPath::tempdir()?;
1030        let workspace_root = workspace.as_guarded_path().clone();
1031        let script_path = workspace_root.join("empty.txt")?;
1032        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1033        resolver.write_file(&script_path, b"")?;
1034        let opts = Options {
1035            script: ScriptSource::Path(script_path),
1036            shell: true,
1037            endpoints: EndpointFlags::default(),
1038        };
1039        let called = RefCell::new(None::<(String, String)>);
1040        execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
1041            called.replace(Some((cwd.display(), workspace.display())));
1042            // Shell entry converges onto a concrete, existing directory even
1043            // when the script never touched the snapshot (issue #131).
1044            assert!(
1045                cwd.exists(),
1046                "shell cwd must exist on disk, got {}",
1047                cwd.display()
1048            );
1049            Ok(())
1050        })?;
1051        let seen = called.borrow().clone().expect("shell runner called");
1052        assert_eq!(seen.1, workspace_root.display());
1053        Ok(())
1054    }
1055
1056    /// The `ssh` feature wires the SSH host module into the real CLI
1057    /// runner: serve an ephemeral server and close it through
1058    /// `execute_with_result`, no client needed.
1059    #[cfg(feature = "ssh")]
1060    #[cfg_attr(
1061        miri,
1062        ignore = "loopback TCP plus threads plus a Tokio runtime; also GuardedPath::tempdir"
1063    )]
1064    #[test]
1065    fn ssh_feature_serves_and_closes() -> Result<()> {
1066        let workspace = GuardedPath::tempdir()?;
1067        let workspace_root = workspace.as_guarded_path().clone();
1068        let script_path = workspace_root.join("ssh-serve.ox")?;
1069        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1070        let script = indoc! {"
1071            IMPORT [STD, SSH]
1072            LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
1073            SSH_CLOSE($m.server)
1074        "};
1075        resolver.write_file(&script_path, script.as_bytes())?;
1076        let opts = Options {
1077            script: ScriptSource::Path(script_path),
1078            shell: false,
1079            endpoints: EndpointFlags::default(),
1080        };
1081        execute_with_result(opts, workspace_root)?;
1082        Ok(())
1083    }
1084
1085    /// The NET module ships in every CLI build: bind an ephemeral
1086    /// loopback port and close it through `execute_with_result`, no
1087    /// client needed.
1088    #[cfg_attr(
1089        miri,
1090        ignore = "loopback TCP plus GuardedPath::tempdir; blocked under Miri isolation"
1091    )]
1092    #[test]
1093    fn net_module_listens_and_closes() -> Result<()> {
1094        let workspace = GuardedPath::tempdir()?;
1095        let workspace_root = workspace.as_guarded_path().clone();
1096        let script_path = workspace_root.join("net-listen.ox")?;
1097        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1098        let script = indoc! {"
1099            IMPORT [STD, NET]
1100            LET $l: MAP = NET_LISTEN(\"23501\", {})
1101            NET_CLOSE($l.listener)
1102        "};
1103        resolver.write_file(&script_path, script.as_bytes())?;
1104        let opts = Options {
1105            script: ScriptSource::Path(script_path),
1106            shell: false,
1107            endpoints: EndpointFlags::default(),
1108        };
1109        execute_with_result(opts, workspace_root)?;
1110        Ok(())
1111    }
1112
1113    /// Without the `ssh` feature the same script must fail to parse:
1114    /// SSH names stay unknown instead of silently changing meaning.
1115    #[cfg(not(feature = "ssh"))]
1116    #[cfg_attr(
1117        miri,
1118        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
1119    )]
1120    #[test]
1121    fn ssh_scripts_rejected_without_feature() -> Result<()> {
1122        let workspace = GuardedPath::tempdir()?;
1123        let workspace_root = workspace.as_guarded_path().clone();
1124        let script_path = workspace_root.join("ssh-serve.ox")?;
1125        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
1126        let script = indoc! {"
1127            IMPORT [STD, SSH]
1128            LET $m: MAP = SSH_SERVE(\"23301\", {username: \"test\", password: \"test123\"})
1129            SSH_CLOSE($m.server)
1130        "};
1131        resolver.write_file(&script_path, script.as_bytes())?;
1132        let opts = Options {
1133            script: ScriptSource::Path(script_path),
1134            shell: false,
1135            endpoints: EndpointFlags::default(),
1136        };
1137        let err = match execute_with_result(opts, workspace_root) {
1138            Ok(_) => panic!("SSH names must be unknown without the feature"),
1139            Err(err) => err,
1140        };
1141        assert!(err.to_string().contains("SSH"), "{err}");
1142        Ok(())
1143    }
1144}
1145
1146#[cfg(all(test, windows))]
1147mod windows_shell_tests {
1148    use super::*;
1149
1150    #[test]
1151    fn command_path_strips_verbatim_prefix() -> Result<()> {
1152        let temp = GuardedPath::tempdir()?;
1153        let converted = oxdock_fs::command_path(temp.as_guarded_path());
1154        let as_str = converted.as_ref().display().to_string();
1155        assert!(
1156            !as_str.starts_with(r"\\?\"),
1157            "expected non-verbatim path, got {as_str}"
1158        );
1159        Ok(())
1160    }
1161}