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 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
114pub 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
134pub struct ExecutionResult {
142 pub snapshot: Arc<LazyGuardedTempDir>,
146 pub final_cwd: GuardedPath,
149 pub bindings: BTreeMap<String, Value>,
153}
154
155impl ExecutionResult {
156 pub fn has_snapshot(&self) -> bool {
158 self.snapshot.is_materialized()
159 }
160
161 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 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
196fn 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 let script = match &opts.script {
232 ScriptSource::Path(path) => {
233 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 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 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 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 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 opts.shell {
303 if require_tty && !has_controlling_tty() {
304 bail!("--shell requires a tty (no controlling tty available)");
305 }
306 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 #[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 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 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 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#[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 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 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 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 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 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}