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