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 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
116pub 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
136pub struct ExecutionResult {
144 pub snapshot: Arc<LazyGuardedTempDir>,
148 pub final_cwd: GuardedPath,
151 pub bindings: BTreeMap<String, Value>,
155}
156
157impl ExecutionResult {
158 pub fn has_snapshot(&self) -> bool {
160 self.snapshot.is_materialized()
161 }
162
163 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 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
198fn 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 let script = match &opts.script {
234 ScriptSource::Path(path) => {
235 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 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 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 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 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 opts.shell {
305 if require_tty && !has_controlling_tty() {
306 bail!("--shell requires a tty (no controlling tty available)");
307 }
308 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 #[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 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 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 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#[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 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 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 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 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 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}