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