oxdock_cli/
lib.rs

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