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    init_temp_gc,
5};
6#[cfg(windows)]
7use oxdock_process::CommandBuilder;
8use oxdock_process::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};
20pub use oxdock_parser::{Guard, Step, StepKind};
21pub use oxdock_process::shell_program;
22use std::collections::BTreeMap;
23
24pub fn run() -> Result<()> {
25    init_temp_gc();
26    let workspace_root = discover_workspace_root().context("guard workspace root")?;
27
28    let mut args = std::env::args().skip(1);
29    // `--help`/`-h` surfaces as the usage text in the parse error (parse must
30    // not exit the process itself: it is public library API). Print it and
31    // succeed so the binary exits 0.
32    let opts = match Options::parse(&mut args, &workspace_root) {
33        Ok(opts) => opts,
34        Err(err) if err.to_string() == usage() => {
35            print!("{err}");
36            return Ok(());
37        }
38        Err(err) => return Err(err),
39    };
40    execute(opts, workspace_root)
41}
42
43#[derive(Debug, Clone)]
44pub enum ScriptSource {
45    Path(GuardedPath),
46    Stdin,
47}
48
49#[derive(Debug, Clone)]
50pub struct Options {
51    pub script: ScriptSource,
52    pub shell: bool,
53}
54
55impl Options {
56    pub fn parse(
57        args: &mut impl Iterator<Item = String>,
58        workspace_root: &GuardedPath,
59    ) -> Result<Self> {
60        let mut script: Option<ScriptSource> = None;
61        let mut shell = false;
62        let mut set_script = |source: ScriptSource, origin: &str| -> Result<()> {
63            if script.is_some() {
64                bail!("script given multiple times ({origin})");
65            }
66            script = Some(source);
67            Ok(())
68        };
69        while let Some(arg) = args.next() {
70            if arg.is_empty() {
71                continue;
72            }
73            match arg.as_str() {
74                "--script" => {
75                    let p = args
76                        .next()
77                        .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
78                    if p == "-" {
79                        set_script(ScriptSource::Stdin, "--script -")?;
80                    } else {
81                        set_script(
82                            ScriptSource::Path(
83                                workspace_root
84                                    .join(&p)
85                                    .with_context(|| format!("guard script path {p}"))?,
86                            ),
87                            "--script",
88                        )?;
89                    }
90                }
91                "--shell" => {
92                    shell = true;
93                }
94                "--help" | "-h" => {
95                    bail!("{}", usage());
96                }
97                "-" => set_script(ScriptSource::Stdin, "positional `-`")?,
98                other if other.starts_with('-') => bail!("unexpected flag: {}", other),
99                other => set_script(
100                    ScriptSource::Path(
101                        workspace_root
102                            .join(other)
103                            .with_context(|| format!("guard script path {other}"))?,
104                    ),
105                    "positional argument",
106                )?,
107            }
108        }
109
110        let script = script.unwrap_or(ScriptSource::Stdin);
111
112        Ok(Self { script, shell })
113    }
114}
115
116/// Human-readable CLI usage, printed for `--help`/`-h`.
117pub fn usage() -> String {
118    let version = env!("CARGO_PKG_VERSION");
119    let description = env!("CARGO_PKG_DESCRIPTION");
120    indoc::formatdoc! {"
121        oxdock {version} — {description}
122        Usage: oxdock [OPTIONS] [SCRIPT]
123          SCRIPT             script file path (same as `--script <file>`); `-` reads stdin
124          --script <file|->  script file under the workspace root, or `-` for stdin
125          --shell            run the script, then drop into an interactive shell (requires a TTY)
126          --help, -h         print this help and exit
127        With no script given, reads the script from stdin (must be piped unless `--shell`).
128    "}
129}
130
131pub fn execute(opts: Options, workspace_root: GuardedPath) -> Result<()> {
132    init_temp_gc();
133    execute_with_shell_runner(opts, workspace_root, run_shell, true)
134}
135
136/// Output of a script execution (issue #131).
137///
138/// The snapshot directory is created lazily: [`ExecutionResult::snapshot`]
139/// stays unmaterialized when the script never touches snapshot-rooted state
140/// (e.g. `WORKSPACE LOCAL`-only or empty scripts), in which case
141/// [`ExecutionResult::has_snapshot`] is false and `final_cwd` points under
142/// the workspace root.
143pub struct ExecutionResult {
144    /// Shared ownership of the snapshot backing dir. The physical directory
145    /// lives exactly as long as the last surviving clone (normally this
146    /// struct, since execution-internal clones are dropped before return).
147    pub snapshot: Arc<LazyGuardedTempDir>,
148    /// Actual final cwd: inside the snapshot when materialized, otherwise
149    /// under the workspace/local root.
150    pub final_cwd: GuardedPath,
151    /// Top-level script variable bindings captured at completion, keyed by
152    /// variable name. Empty when the script is empty. Populated exclusively
153    /// by [`execute_with_result`]; `--shell` runs never produce one.
154    pub bindings: BTreeMap<String, Value>,
155}
156
157impl ExecutionResult {
158    /// Whether the run materialized the snapshot tempdir.
159    pub fn has_snapshot(&self) -> bool {
160        self.snapshot.is_materialized()
161    }
162
163    /// Borrow the snapshot root iff materialized.
164    pub fn snapshot_path(&self) -> Option<&GuardedPath> {
165        self.snapshot.get()
166    }
167}
168
169pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
170    if opts.shell {
171        bail!("execute_with_result does not support --shell");
172    }
173
174    // Read + parse BEFORE any tempdir exists so LOCAL-only scripts never
175    // create a snapshot directory they never use (issue #131).
176    let script = read_script(&opts.script, &workspace_root)?;
177
178    let mut final_cwd = workspace_root.clone();
179    let snapshot = Arc::new(LazyGuardedTempDir::new());
180    if !script.trim().is_empty() {
181        let steps = parse_script(&script)?;
182        let output = run_steps_with_lazy_snapshot(&workspace_root, &steps, ExecIo::new())?;
183        final_cwd = output.final_cwd;
184        return Ok(ExecutionResult {
185            snapshot: output.snapshot,
186            final_cwd,
187            bindings: output.bindings,
188        });
189    }
190
191    Ok(ExecutionResult {
192        snapshot,
193        final_cwd,
194        bindings: BTreeMap::new(),
195    })
196}
197
198/// Read the script source without creating any execution state.
199fn read_script(source: &ScriptSource, workspace_root: &GuardedPath) -> Result<String> {
200    match source {
201        ScriptSource::Path(path) => {
202            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
203            resolver
204                .read_to_string(path)
205                .with_context(|| format!("failed to read script at {}", path.display()))
206        }
207        ScriptSource::Stdin => {
208            let mut buf = String::new();
209            io::stdin()
210                .lock()
211                .read_to_string(&mut buf)
212                .context("failed to read script from stdin")?;
213            Ok(buf)
214        }
215    }
216}
217
218fn execute_with_shell_runner<F>(
219    opts: Options,
220    workspace_root: GuardedPath,
221    shell_runner: F,
222    require_tty: bool,
223) -> Result<()>
224where
225    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
226{
227    #[cfg(windows)]
228    maybe_reexec_shell_to_temp(&opts)?;
229
230    // Interpret a tiny Dockerfile-ish script. No tempdir exists yet: the
231    // snapshot materializes lazily on first snapshot-targeted step, so an
232    // empty non-shell run creates nothing at all (issue #131).
233    let script = match &opts.script {
234        ScriptSource::Path(path) => {
235            // Read script path via PathResolver rooted at the workspace so
236            // script files are validated to live under the workspace.
237            let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
238            resolver
239                .read_to_string(path)
240                .with_context(|| format!("failed to read script at {}", path.display()))?
241        }
242        ScriptSource::Stdin => {
243            let stdin = io::stdin();
244            if stdin.is_terminal() {
245                // No piped script provided. If the caller requested `--shell`
246                // allow running with an initially-empty script so we can either
247                // drop into the interactive shell or open the editor later.
248                // Otherwise, require a script on stdin.
249                if opts.shell {
250                    String::new()
251                } else {
252                    bail!(
253                        "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
254                    );
255                }
256            } else {
257                let mut buf = String::new();
258                stdin
259                    .lock()
260                    .read_to_string(&mut buf)
261                    .context("failed to read script from stdin")?;
262                buf
263            }
264        }
265    };
266
267    // Parse and run steps if we have a non-empty script. Empty scripts are
268    // valid when `--shell` is requested and the caller didn't pipe a script.
269    // Use the caller's workspace as the build context so WORKSPACE LOCAL can
270    // hop back and so COPY can source from the original tree if needed.
271    // Capture the final working directory so shells inherit whatever WORKDIR
272    // the script ended on.
273    let mut final_cwd = workspace_root.clone();
274    let mut snapshot = Arc::new(LazyGuardedTempDir::new());
275    let mut fs: Option<Box<dyn WorkspaceFs>> = None;
276    if !script.trim().is_empty() {
277        let steps = parse_script(&script)?;
278        // If we are running a script from a file, we might have stdin available for the script itself.
279        // If we read the script from stdin, then stdin is consumed.
280        // But if opts.script is ScriptSource::Path, stdin is still available.
281
282        let mut stdin_handle: Option<SharedInput> = None;
283        if let ScriptSource::Path(_) = opts.script {
284            let stdin = io::stdin();
285            if !stdin.is_terminal() {
286                // Wrap stdin in SharedInput (Arc<Mutex<dyn Read + Send>>)
287                // Note: std::io::Stdin is a handle, but we need an owned Read + Send.
288                // std::io::stdin() returns Stdin, which implements Read + Send.
289                // However, we need to be careful about locking.
290                // We can wrap the Stdin struct directly.
291                stdin_handle = Some(Arc::new(Mutex::new(stdin)));
292            }
293        }
294
295        let mut io_cfg = ExecIo::new();
296        io_cfg.set_stdin(stdin_handle);
297        let output = run_steps_with_lazy_snapshot(&workspace_root, &steps, io_cfg)?;
298        final_cwd = output.final_cwd;
299        snapshot = output.snapshot;
300        fs = Some(output.fs);
301    }
302
303    // If requested, drop into an interactive shell after running the script.
304    if opts.shell {
305        if require_tty && !has_controlling_tty() {
306            bail!("--shell requires a tty (no controlling tty available)");
307        }
308        // The shell needs a concrete directory: materialize here at shell
309        // entry (not at startup) when the script left the snapshot pending,
310        // then converge the cwd onto the shared concrete root. An empty
311        // script starts the shell in a fresh snapshot directory.
312        match fs.as_ref() {
313            Some(fs) => {
314                if fs.is_snapshot_pending() {
315                    snapshot
316                        .materialize()
317                        .context("failed to create shell temp dir")?;
318                }
319                final_cwd = fs.concretize_cwd(&final_cwd);
320            }
321            None => {
322                snapshot
323                    .materialize()
324                    .context("failed to create shell temp dir")?;
325                final_cwd = snapshot
326                    .get()
327                    .cloned()
328                    .expect("shell snapshot materialized above");
329            }
330        }
331        return shell_runner(&final_cwd, &workspace_root);
332    }
333
334    Ok(())
335}
336
337#[cfg(test)]
338fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
339where
340    F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
341{
342    execute_with_shell_runner(opts, workspace_root, shell_runner, false)
343}
344
345fn has_controlling_tty() -> bool {
346    // Prefer checking whether stdin or stderr is a terminal. This avoids
347    // directly opening device files via `std::fs` while still detecting
348    // whether an interactive tty is available in the common cases.
349    #[cfg(unix)]
350    {
351        io::stdin().is_terminal() || io::stderr().is_terminal()
352    }
353
354    #[cfg(windows)]
355    {
356        io::stdin().is_terminal() || io::stderr().is_terminal()
357    }
358
359    #[cfg(not(any(unix, windows)))]
360    {
361        false
362    }
363}
364
365#[cfg(windows)]
366fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
367    // Only used for interactive shells. Copy the binary to a temp path and run it there so the
368    // original target exe is free for rebuilding while the shell stays open.
369    if !opts.shell {
370        return Ok(());
371    }
372    if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
373        return Ok(());
374    }
375
376    let self_path = std::env::current_exe().context("determine current executable")?;
377    let base_temp =
378        GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
379    let ts = std::time::SystemTime::now()
380        .duration_since(std::time::UNIX_EPOCH)
381        .unwrap_or_default()
382        .as_millis();
383    let temp_file = base_temp
384        .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
385        .context("construct temp shell path")?;
386
387    // Copy the current executable into the temporary location via a
388    // resolver whose root is the temp directory. The source may live
389    // outside the temp dir, so use `copy_file_from_external`.
390    let temp_root_guard = temp_file
391        .parent()
392        .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
393    let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
394    let dest = temp_file;
395    #[allow(clippy::disallowed_types)]
396    let source = oxdock_fs::UnguardedPath::external(self_path);
397    resolver_temp
398        .copy_file_from_unguarded(&source, &dest)
399        .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
400
401    let mut cmd = CommandBuilder::new(dest.as_path());
402    cmd.args(std::env::args_os().skip(1));
403    cmd.env("OXDOCK_SHELL_REEXEC", "1");
404    cmd.spawn()
405        .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
406
407    // Exit immediately so the original binary can be rebuilt while the shell child stays running.
408    std::process::exit(0);
409}
410
411pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
412    run_steps_with_context(workspace_root, workspace_root, steps)
413}
414
415fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
416    #[cfg(windows)]
417    let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
418    #[cfg(windows)]
419    let workspace_disp = oxdock_fs::command_path(workspace_root)
420        .as_ref()
421        .display()
422        .to_string();
423
424    #[cfg(not(windows))]
425    let cwd_disp = cwd.display().to_string();
426    #[cfg(not(windows))]
427    let workspace_disp = workspace_root.display().to_string();
428
429    let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
430    indoc::formatdoc! {"
431        {pkg} shell workspace
432          cwd: {cwd_disp}
433          source: workspace root at {workspace_disp}
434          lifetime: temporary directory created for this shell session; it disappears when you exit
435          creation: temp workspace starts empty unless your script copies files into it
436
437          WARNING: This shell still runs on your host filesystem and is **not** isolated!
438    "}
439}
440
441fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
442    oxdock_process::spawn_interactive_shell(cwd, workspace_root, &shell_banner(cwd, workspace_root))
443}
444
445// `command_path` now lives in `oxdock-fs` to centralize Path usage.
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use indoc::indoc;
451    use oxdock_fs::PathResolver;
452    use std::cell::{Cell, RefCell};
453
454    #[cfg_attr(
455        miri,
456        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
457    )]
458    #[test]
459    fn shell_runner_receives_final_workdir() -> Result<()> {
460        let workspace = GuardedPath::tempdir()?;
461        let workspace_root = workspace.as_guarded_path().clone();
462        let script_path = workspace_root.join("script.ox")?;
463        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
464        let script = indoc! {"
465            WRITE temp.txt 123
466            WORKDIR sub
467        "};
468        resolver.write_file(&script_path, script.as_bytes())?;
469
470        let opts = Options {
471            script: ScriptSource::Path(script_path),
472            shell: true,
473        };
474
475        let observed = Cell::new(false);
476        execute_for_test(opts, workspace_root.clone(), |cwd, _| {
477            assert!(
478                cwd.as_path().ends_with("sub"),
479                "final cwd should end in WORKDIR target, got {}",
480                cwd.display()
481            );
482
483            let temp_root = GuardedPath::new_root(cwd.root())
484                .context("construct guard for temp workspace root")?;
485            let sub_dir = temp_root.join("sub")?;
486            assert_eq!(
487                cwd.as_path(),
488                sub_dir.as_path(),
489                "shell runner cwd should match guarded sub dir"
490            );
491            let temp_file = temp_root.join("temp.txt")?;
492            let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
493            let contents = temp_resolver.read_to_string(&temp_file)?;
494            assert!(
495                contents.contains("123"),
496                "expected WRITE command to materialize temp file"
497            );
498            observed.set(true);
499            Ok(())
500        })?;
501
502        assert!(
503            observed.into_inner(),
504            "shell runner closure should have been invoked"
505        );
506        Ok(())
507    }
508
509    #[cfg_attr(
510        miri,
511        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
512    )]
513    #[test]
514    fn options_parse_requires_script_path_value() {
515        let workspace = GuardedPath::tempdir().expect("tempdir");
516        let mut args = vec!["--script".to_string()].into_iter();
517        let err = Options::parse(&mut args, workspace.as_guarded_path())
518            .expect_err("expected missing path error");
519        assert!(err.to_string().contains("--script requires a path"));
520    }
521
522    #[cfg_attr(
523        miri,
524        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
525    )]
526    #[test]
527    fn options_parse_script_path_and_shell() {
528        let workspace = GuardedPath::tempdir().expect("tempdir");
529        let workspace_root = workspace.as_guarded_path().clone();
530        let script_path = workspace_root.join("script.txt").expect("script path");
531        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
532            .expect("resolver");
533        resolver
534            .write_file(&script_path, b"WRITE out.txt hi")
535            .expect("write script");
536        let mut args = vec![
537            "--script".to_string(),
538            "script.txt".to_string(),
539            "--shell".to_string(),
540        ]
541        .into_iter();
542        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
543        assert!(opts.shell);
544        match opts.script {
545            ScriptSource::Path(path) => assert_eq!(path, script_path),
546            ScriptSource::Stdin => panic!("expected path script"),
547        }
548    }
549
550    #[cfg_attr(
551        miri,
552        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
553    )]
554    #[test]
555    fn options_parse_positional_script_path() {
556        let workspace = GuardedPath::tempdir().expect("tempdir");
557        let workspace_root = workspace.as_guarded_path().clone();
558        let mut args = vec!["script.txt".to_string()].into_iter();
559        let opts = Options::parse(&mut args, &workspace_root).expect("parse");
560        assert!(!opts.shell);
561        match opts.script {
562            ScriptSource::Path(path) => assert_eq!(
563                path,
564                workspace_root.join("script.txt").expect("script path")
565            ),
566            ScriptSource::Stdin => panic!("expected path script"),
567        }
568    }
569
570    #[cfg_attr(
571        miri,
572        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
573    )]
574    #[test]
575    fn options_parse_positional_dash_reads_stdin() {
576        let workspace = GuardedPath::tempdir().expect("tempdir");
577        let mut args = vec!["-".to_string()].into_iter();
578        let opts = Options::parse(&mut args, workspace.as_guarded_path()).expect("parse");
579        assert!(matches!(opts.script, ScriptSource::Stdin));
580    }
581
582    #[cfg_attr(
583        miri,
584        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
585    )]
586    #[test]
587    fn options_parse_rejects_duplicate_script_sources() {
588        let workspace = GuardedPath::tempdir().expect("tempdir");
589        let workspace_root = workspace.as_guarded_path().clone();
590        let mut args = vec![
591            "a.ox".to_string(),
592            "--script".to_string(),
593            "b.ox".to_string(),
594        ]
595        .into_iter();
596        let err = Options::parse(&mut args, &workspace_root)
597            .expect_err("expected duplicate script error");
598        assert!(err.to_string().contains("multiple times"), "{err:?}");
599
600        let mut args = vec!["a.ox".to_string(), "b.ox".to_string()].into_iter();
601        let err = Options::parse(&mut args, &workspace_root)
602            .expect_err("expected duplicate script error");
603        assert!(err.to_string().contains("multiple times"), "{err:?}");
604    }
605
606    #[cfg_attr(
607        miri,
608        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
609    )]
610    #[test]
611    fn options_parse_rejects_unknown_flags() {
612        let workspace = GuardedPath::tempdir().expect("tempdir");
613        let mut args = vec!["--frobnicate".to_string()].into_iter();
614        let err = Options::parse(&mut args, workspace.as_guarded_path())
615            .expect_err("expected unknown flag error");
616        assert!(err.to_string().contains("unexpected flag"), "{err:?}");
617    }
618
619    #[test]
620    fn usage_describes_positional_script_and_help() {
621        let text = usage();
622        assert!(text.contains("Usage: oxdock"), "{text}");
623        assert!(text.contains("SCRIPT"), "{text}");
624        assert!(text.contains("--script"), "{text}");
625        assert!(text.contains("--help"), "{text}");
626        // Tagline is single-sourced from the package manifest, not hardcoded.
627        assert!(text.contains(env!("CARGO_PKG_DESCRIPTION")), "{text}");
628    }
629
630    #[cfg_attr(
631        miri,
632        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
633    )]
634    #[test]
635    fn options_parse_help_returns_usage_error_without_exiting() {
636        // Regression: parse is public library API and must return instead of
637        // terminating the process; `run()` turns this error into a clean exit 0.
638        let workspace = GuardedPath::tempdir().expect("tempdir");
639        for flag in ["--help", "-h"] {
640            let mut args = vec![flag.to_string()].into_iter();
641            let err = Options::parse(&mut args, workspace.as_guarded_path())
642                .expect_err("help flag must not parse as options");
643            assert_eq!(err.to_string(), usage());
644        }
645    }
646
647    #[cfg_attr(
648        miri,
649        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
650    )]
651    #[test]
652    fn execute_with_result_runs_script() {
653        let workspace = GuardedPath::tempdir().expect("tempdir");
654        let workspace_root = workspace.as_guarded_path().clone();
655        let script_path = workspace_root.join("script.txt").expect("script path");
656        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
657            .expect("resolver");
658        resolver
659            .write_file(&script_path, b"WRITE out.txt hi")
660            .expect("write script");
661        let opts = Options {
662            script: ScriptSource::Path(script_path),
663            shell: false,
664        };
665        let result = execute_with_result(opts, workspace_root).expect("execute");
666        let snapshot = result
667            .snapshot_path()
668            .expect("default WRITE materializes the snapshot");
669        assert_eq!(snapshot, &result.final_cwd);
670        let temp_resolver = PathResolver::new(snapshot.root(), snapshot.root()).expect("resolver");
671        let out = snapshot.join("out.txt").expect("out path");
672        let contents = temp_resolver.read_to_string(&out).expect("read out");
673        assert_eq!(contents.trim(), "hi");
674    }
675
676    #[cfg_attr(
677        miri,
678        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
679    )]
680    #[test]
681    fn execute_with_result_local_only_creates_no_snapshot() {
682        let workspace = GuardedPath::tempdir().expect("tempdir");
683        let workspace_root = workspace.as_guarded_path().clone();
684        let script_path = workspace_root.join("script.txt").expect("script path");
685        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
686            .expect("resolver");
687        resolver
688            .write_file(&script_path, b"WORKSPACE LOCAL\nWRITE out.txt hi")
689            .expect("write script");
690        let opts = Options {
691            script: ScriptSource::Path(script_path),
692            shell: false,
693        };
694        let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
695        assert!(
696            !result.has_snapshot(),
697            "WORKSPACE LOCAL-only script must not create a snapshot tempdir"
698        );
699        assert!(result.snapshot_path().is_none());
700        // The write landed in the live workspace tree instead.
701        let out = workspace_root.join("out.txt").expect("out path");
702        let contents = resolver.read_to_string(&out).expect("read out");
703        assert_eq!(contents.trim(), "hi");
704        // The reported cwd stays under the workspace root.
705        assert_eq!(result.final_cwd.root(), workspace_root.as_path());
706    }
707
708    #[cfg_attr(
709        miri,
710        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
711    )]
712    #[test]
713    fn execute_with_result_empty_script_creates_no_snapshot() {
714        let workspace = GuardedPath::tempdir().expect("tempdir");
715        let workspace_root = workspace.as_guarded_path().clone();
716        let script_path = workspace_root.join("empty.txt").expect("script path");
717        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
718            .expect("resolver");
719        resolver
720            .write_file(&script_path, b"")
721            .expect("write script");
722        let opts = Options {
723            script: ScriptSource::Path(script_path),
724            shell: false,
725        };
726        let result = execute_with_result(opts, workspace_root.clone()).expect("execute");
727        assert!(
728            !result.has_snapshot(),
729            "empty script must not create a snapshot tempdir"
730        );
731        assert_eq!(result.final_cwd, workspace_root);
732    }
733
734    #[cfg_attr(
735        miri,
736        ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
737    )]
738    #[test]
739    fn execute_for_test_invokes_shell_runner() -> Result<()> {
740        let workspace = GuardedPath::tempdir()?;
741        let workspace_root = workspace.as_guarded_path().clone();
742        let script_path = workspace_root.join("empty.txt")?;
743        let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
744        resolver.write_file(&script_path, b"")?;
745        let opts = Options {
746            script: ScriptSource::Path(script_path),
747            shell: true,
748        };
749        let called = RefCell::new(None::<(String, String)>);
750        execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
751            called.replace(Some((cwd.display(), workspace.display())));
752            // Shell entry converges onto a concrete, existing directory even
753            // when the script never touched the snapshot (issue #131).
754            assert!(
755                cwd.exists(),
756                "shell cwd must exist on disk, got {}",
757                cwd.display()
758            );
759            Ok(())
760        })?;
761        let seen = called.borrow().clone().expect("shell runner called");
762        assert_eq!(seen.1, workspace_root.display());
763        Ok(())
764    }
765}
766
767#[cfg(all(test, windows))]
768mod windows_shell_tests {
769    use super::*;
770
771    #[test]
772    fn command_path_strips_verbatim_prefix() -> Result<()> {
773        let temp = GuardedPath::tempdir()?;
774        let converted = oxdock_fs::command_path(temp.as_guarded_path());
775        let as_str = converted.as_ref().display().to_string();
776        assert!(
777            !as_str.starts_with(r"\\?\"),
778            "expected non-verbatim path, got {as_str}"
779        );
780        Ok(())
781    }
782}