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