1use anyhow::{Context, Result, bail};
2use oxdock_fs::GuardedPath;
3#[cfg(all(unix, not(miri)))]
4use oxdock_fs::PathResolver;
5use oxdock_fs::env::BANNER;
6use std::ffi::OsStr;
7use std::fs::File;
8#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
9use std::process::{Command, ExitStatus, Stdio};
10
11use crate::CommandBuilder;
12
13pub fn shell_program() -> String {
14 #[cfg(windows)]
15 {
16 std::env::var(oxdock_fs::env::COMSPEC).unwrap_or_else(|_| "cmd".to_string())
17 }
18
19 #[cfg(not(windows))]
20 {
21 std::env::var(oxdock_fs::env::SHELL).unwrap_or_else(|_| "sh".to_string())
22 }
23}
24
25#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
26pub(crate) fn direct_cmd(argv: &[String]) -> Result<Command> {
27 let (program, rest) = argv
28 .split_first()
29 .ok_or_else(|| anyhow::anyhow!("RUN exec form requires at least one argument"))?;
30 if program.is_empty() {
31 bail!("RUN exec form requires a non-empty executable");
32 }
33 let mut c = Command::new(program);
34 c.args(rest);
35 Ok(c)
36}
37
38#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
39pub(crate) fn shell_cmd(cmd: &str) -> Command {
40 let program = shell_program();
41 let mut c = Command::new(program);
42 #[allow(clippy::disallowed_macros)]
43 if cfg!(windows) {
44 c.arg("/C").arg(cmd);
45 } else {
46 c.arg("-c").arg(cmd);
47 }
48 c
49}
50
51#[derive(Default)]
52pub struct ShellLauncher;
53
54#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
55impl ShellLauncher {
56 pub fn run(&self, cmd: &mut Command) -> Result<()> {
57 let status = cmd
58 .status()
59 .with_context(|| format!("failed to run {:?}", cmd))?;
60 if !status.success() {
61 bail!("command {:?} failed with status {}", cmd, status);
62 }
63 Ok(())
64 }
65
66 pub fn run_with_output(&self, cmd: &mut Command) -> Result<(ExitStatus, Vec<u8>, Vec<u8>)> {
67 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
68 let output = cmd
69 .output()
70 .with_context(|| format!("failed to run {:?}", cmd))?;
71 Ok((output.status, output.stdout, output.stderr))
72 }
73
74 pub fn spawn(&self, cmd: &mut Command) -> Result<()> {
75 cmd.spawn()
76 .with_context(|| format!("failed to spawn {:?}", cmd))?;
77 Ok(())
78 }
79
80 pub fn with_stdins<'a>(&self, cmd: &'a mut Command, stdin: Option<File>) -> &'a mut Command {
81 if let Some(file) = stdin {
82 cmd.stdin(file);
83 }
84 cmd
85 }
86
87 pub fn program_arg(&self, program: impl AsRef<OsStr>) -> Command {
88 Command::new(program)
89 }
90}
91
92pub fn spawn_interactive_shell(
98 cwd: &GuardedPath,
99 workspace_root: &GuardedPath,
100 banner: &str,
101) -> Result<()> {
102 let _ = workspace_root;
103 #[cfg(unix)]
104 {
105 let mut cmd = CommandBuilder::new(shell_program());
106 cmd.current_dir(cwd.as_path());
107
108 const SCRIPT: &str = "printf '%s\\n' \"$OXDOCK_BANNER\"; exec \"$1\"";
112 cmd.env(BANNER, banner);
113 cmd.arg("-c").arg(SCRIPT).arg("sh").arg(shell_program());
114
115 #[cfg(not(miri))]
117 {
118 #[allow(clippy::disallowed_types)]
119 let tty_path = oxdock_fs::UnguardedPath::external("/dev/tty");
120 if let Ok(resolver) =
121 PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
122 && let Ok(tty) = resolver.open_file_unguarded(&tty_path)
123 {
124 cmd.stdin_file(tty);
125 }
126 }
127
128 if try_shell_command_hook(&mut cmd)? {
129 return Ok(());
130 }
131
132 let status = cmd.status()?;
133 if !status.success() {
134 bail!("shell exited with status {}", status);
135 }
136 Ok(())
137 }
138
139 #[cfg(windows)]
140 {
141 let cwd_path = oxdock_fs::command_path(cwd);
145 let banner_cmd = windows_banner_command(banner, cwd);
146 let mut cmd = CommandBuilder::new("cmd");
147 cmd.env(BANNER, banner);
148 cmd.current_dir(cwd_path.as_ref())
149 .arg("/C")
150 .arg("start")
151 .arg("oxdock shell")
152 .arg("cmd")
153 .arg("/K")
154 .arg(banner_cmd);
155
156 if try_shell_command_hook(&mut cmd)? {
157 return Ok(());
158 }
159
160 cmd.spawn()
163 .context("failed to start interactive shell window")?;
164 Ok(())
165 }
166
167 #[cfg(not(any(unix, windows)))]
168 {
169 let _ = (cwd, workspace_root, banner);
170 bail!("interactive shell unsupported on this platform");
171 }
172}
173
174#[cfg(windows)]
175fn escape_for_cmd(s: &str) -> String {
176 s.replace('^', "^^")
178 .replace('&', "^&")
179 .replace('|', "^|")
180 .replace('>', "^>")
181 .replace('<', "^<")
182}
183
184#[cfg(windows)]
185fn windows_banner_command(banner: &str, cwd: &GuardedPath) -> String {
186 let mut parts: Vec<String> = banner
187 .lines()
188 .map(|line| format!("echo {}", escape_for_cmd(line)))
189 .collect();
190 let cwd_path = oxdock_fs::command_path(cwd);
191 parts.push(format!(
192 "cd /d {}",
193 escape_for_cmd(&cwd_path.as_ref().display().to_string())
194 ));
195 parts.join(" && ")
196}
197
198#[cfg(test)]
199type ShellCmdHook = dyn FnMut(&crate::CommandSnapshot) -> Result<()> + Send;
200
201#[cfg(test)]
202thread_local! {
203 static SHELL_CMD_HOOK: std::cell::RefCell<Option<Box<ShellCmdHook>>> =
204 std::cell::RefCell::new(None);
205}
206
207#[cfg(test)]
208fn set_shell_command_hook<F>(hook: F)
209where
210 F: FnMut(&crate::CommandSnapshot) -> Result<()> + Send + 'static,
211{
212 SHELL_CMD_HOOK.with(|slot| {
213 *slot.borrow_mut() = Some(Box::new(hook));
214 });
215}
216
217#[cfg(test)]
218fn clear_shell_command_hook() {
219 SHELL_CMD_HOOK.with(|slot| {
220 *slot.borrow_mut() = None;
221 });
222}
223
224#[cfg(test)]
225fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
226 SHELL_CMD_HOOK.with(|slot| {
227 if let Some(hook) = slot.borrow_mut().as_mut() {
228 let snap = cmd.snapshot();
229 hook(&snap)?;
230 return Ok(true);
231 }
232 Ok(false)
233 })
234}
235
236#[cfg(not(test))]
237fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
238 let _ = cmd;
239 Ok(false)
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{ShellLauncher, direct_cmd, shell_cmd, shell_program};
245 use crate::TestEnvGuard;
246
247 use std::ffi::OsStr;
248 use std::sync::Mutex;
249
250 pub(super) static ENV_LOCK: Mutex<()> = Mutex::new(());
252
253 #[test]
254 fn shell_program_prefers_env_override() {
255 let _lock = ENV_LOCK.lock().expect("env lock");
256 #[cfg(windows)]
257 let _guard = TestEnvGuard::set(oxdock_fs::env::COMSPEC, "custom-cmd");
258 #[cfg(not(windows))]
259 let _guard = TestEnvGuard::set(oxdock_fs::env::SHELL, "custom-sh");
260 let program = shell_program();
261 #[cfg(windows)]
262 assert_eq!(program, "custom-cmd");
263 #[cfg(not(windows))]
264 assert_eq!(program, "custom-sh");
265 }
266
267 #[cfg_attr(
268 miri,
269 ignore = "spawns shell command; Miri does not support process execution"
270 )]
271 #[test]
272 fn shell_launcher_run_with_output_captures_stdout() {
273 let _lock = ENV_LOCK.lock().expect("env lock");
274 let launcher = ShellLauncher;
275 let mut cmd = shell_cmd("echo hello");
276 let (status, stdout, _stderr) = launcher.run_with_output(&mut cmd).expect("run output");
277 assert!(status.success());
278 let out = String::from_utf8_lossy(&stdout);
279 assert!(out.contains("hello"));
280 }
281
282 #[test]
283 fn shell_launcher_program_arg_tracks_program() {
284 let launcher = ShellLauncher;
285 let cmd = launcher.program_arg("echo");
286 assert_eq!(cmd.get_program(), OsStr::new("echo"));
287 }
288
289 #[test]
290 fn shell_program_falls_back_to_default_without_env() {
291 let _lock = ENV_LOCK.lock().expect("env lock");
292 #[cfg(windows)]
293 {
294 let _guard = TestEnvGuard::remove(oxdock_fs::env::COMSPEC);
295 assert_eq!(shell_program(), "cmd");
296 }
297 #[cfg(not(windows))]
298 {
299 let _guard = TestEnvGuard::remove(oxdock_fs::env::SHELL);
300 assert_eq!(shell_program(), "sh");
301 }
302 }
303
304 #[test]
305 fn shell_cmd_applies_platform_flag_and_script() {
306 let cmd = shell_cmd("echo hi");
307 let args: Vec<String> = cmd
308 .get_args()
309 .map(|arg| arg.to_string_lossy().into_owned())
310 .collect();
311 #[cfg(windows)]
312 assert_eq!(args, vec!["/C".to_string(), "echo hi".to_string()]);
313 #[cfg(not(windows))]
314 assert_eq!(args, vec!["-c".to_string(), "echo hi".to_string()]);
315 }
316
317 #[test]
318 fn direct_cmd_builds_program_and_args_without_shell() {
319 let argv = vec!["prog".to_string(), "a".to_string(), "b c".to_string()];
320 let cmd = direct_cmd(&argv).expect("direct_cmd");
321 assert_eq!(cmd.get_program(), OsStr::new("prog"));
322 let args: Vec<String> = cmd
323 .get_args()
324 .map(|arg| arg.to_string_lossy().into_owned())
325 .collect();
326 assert_eq!(args, vec!["a".to_string(), "b c".to_string()]);
327 }
328
329 #[test]
330 fn direct_cmd_rejects_empty_argv() {
331 let err = direct_cmd(&[]).expect_err("empty argv must fail");
332 assert!(
333 err.to_string().contains("at least one argument"),
334 "unexpected error: {err:#}"
335 );
336 let err = direct_cmd(&[String::new()]).expect_err("empty program must fail");
337 assert!(
338 err.to_string().contains("non-empty executable"),
339 "unexpected error: {err:#}"
340 );
341 }
342
343 #[cfg_attr(
344 miri,
345 ignore = "spawns shell command; Miri does not support process execution"
346 )]
347 #[test]
348 fn shell_launcher_run_succeeds_on_zero_exit() {
349 let launcher = ShellLauncher;
350 let mut cmd = shell_cmd("exit 0");
351 launcher.run(&mut cmd).expect("zero exit should succeed");
352 }
353
354 #[cfg_attr(
355 miri,
356 ignore = "spawns shell command; Miri does not support process execution"
357 )]
358 #[test]
359 fn shell_launcher_run_reports_nonzero_status() {
360 let launcher = ShellLauncher;
361 let mut cmd = shell_cmd("exit 3");
362 let err = launcher.run(&mut cmd).expect_err("nonzero exit must fail");
363 let msg = err.to_string();
364 assert!(
365 msg.contains("failed with status"),
366 "unexpected error message: {msg}"
367 );
368 }
369
370 #[cfg_attr(
371 miri,
372 ignore = "spawns shell command; Miri does not support process execution"
373 )]
374 #[test]
375 fn shell_launcher_spawn_smoke() {
376 let launcher = ShellLauncher;
377 let mut cmd = shell_cmd("exit 0");
378 launcher.spawn(&mut cmd).expect("spawn should succeed");
379 }
380
381 #[test]
382 fn shell_launcher_with_stdins_none_passthrough_keeps_builder_usable() {
383 let launcher = ShellLauncher;
384 let mut cmd = launcher.program_arg("echo");
385 let same = launcher.with_stdins(&mut cmd, None);
386 assert_eq!(same.get_program(), OsStr::new("echo"));
387 }
388}
389
390#[cfg(test)]
391mod interactive_shell_tests {
392 use super::tests::ENV_LOCK;
393 use super::{clear_shell_command_hook, set_shell_command_hook, spawn_interactive_shell};
394 use crate::CommandSnapshot;
395 use anyhow::Result;
396 use oxdock_fs::GuardedPath;
397 use oxdock_fs::env::BANNER;
398 use std::sync::{Arc, Mutex};
399
400 #[cfg(any(unix, windows))]
401 #[test]
402 fn spawn_interactive_shell_builds_command_for_platform() -> Result<()> {
403 let _lock = ENV_LOCK.lock().expect("env lock");
407 let workspace = GuardedPath::tempdir()?;
408 let workspace_root = workspace.as_guarded_path().clone();
409 let cwd = workspace_root.join("subdir")?;
410 #[cfg(not(miri))]
411 {
412 let resolver =
413 oxdock_fs::PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
414 resolver.create_dir_all(&cwd)?;
415 }
416
417 let captured = Arc::new(Mutex::new(None::<CommandSnapshot>));
418 let guard = captured.clone();
419 set_shell_command_hook(move |cmd| {
420 *guard.lock().unwrap() = Some(cmd.clone());
421 Ok(())
422 });
423 spawn_interactive_shell(&cwd, &workspace_root, "test banner")?;
424 clear_shell_command_hook();
425
426 let snap = captured
427 .lock()
428 .unwrap()
429 .clone()
430 .expect("hook should capture snapshot");
431 let cwd_path = snap.cwd.expect("cwd should be set");
432 assert!(
433 cwd_path.ends_with("subdir"),
434 "expected cwd to include subdir, got {}",
435 cwd_path.display()
436 );
437 assert!(
438 snap.envs
439 .iter()
440 .any(|(k, v)| k == BANNER && v == "test banner"),
441 "expected OXDOCK_BANNER env injection, got {:?}",
442 snap.envs
443 );
444
445 #[cfg(unix)]
446 {
447 let program = snap.program.to_string_lossy();
448 assert_eq!(
449 program,
450 super::shell_program(),
451 "expected shell program name"
452 );
453 let args: Vec<_> = snap
454 .args
455 .iter()
456 .map(|s| s.to_string_lossy().to_string())
457 .collect();
458 assert_eq!(
459 args.len(),
460 4,
461 "expected four args (-c script sh shell), got {:?}",
462 args
463 );
464 assert_eq!(args[0], "-c");
465 assert!(
466 args[1].contains("$OXDOCK_BANNER"),
467 "expected env-based banner reference, got {:?}",
468 args[1]
469 );
470 assert!(
471 args[1].contains("exec \"$1\""),
472 "expected positional shell exec, got {:?}",
473 args[1]
474 );
475 assert!(
476 !args[1].contains("test banner"),
477 "banner must not be interpolated into the script, got {:?}",
478 args[1]
479 );
480 assert_eq!(args[2], "sh", "expected $0 placeholder");
481 assert_eq!(args[3], super::shell_program(), "expected shell path as $1");
482 }
483
484 #[cfg(windows)]
485 {
486 use super::windows_banner_command;
487 let program = snap.program.to_string_lossy().to_string();
488 assert_eq!(program, "cmd", "expected cmd.exe launcher");
489 let args: Vec<_> = snap
490 .args
491 .iter()
492 .map(|s| s.to_string_lossy().to_string())
493 .collect();
494 let banner_cmd = windows_banner_command("test banner", &cwd);
495 let expected = vec![
496 "/C".to_string(),
497 "start".to_string(),
498 "oxdock shell".to_string(),
499 "cmd".to_string(),
500 "/K".to_string(),
501 banner_cmd,
502 ];
503 assert_eq!(args, expected, "expected exact windows shell argv");
504 }
505
506 Ok(())
507 }
508
509 #[cfg(windows)]
510 #[test]
511 fn windows_banner_command_emits_all_lines() {
512 let banner = "line1\nline2\nline3";
513 let workspace = GuardedPath::tempdir().expect("tempdir");
514 let cwd = workspace.as_guarded_path().clone();
515 let cmd = super::windows_banner_command(banner, &cwd);
516 assert!(cmd.contains("line1"));
517 assert!(cmd.contains("line2"));
518 assert!(cmd.contains("line3"));
519 assert!(cmd.contains("cd /d "));
520 }
521}