1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "lowercase")]
12#[allow(clippy::enum_variant_names)] pub enum Shell {
14 #[default]
16 Sh,
17 Bash,
19 Zsh,
21 Fish,
23 Cmd,
25 #[serde(alias = "pwsh")]
27 PowerShell,
28}
29
30impl Shell {
31 #[cfg(unix)]
33 pub fn default_for_platform() -> Self {
34 Shell::Sh
35 }
36
37 #[cfg(windows)]
39 pub fn default_for_platform() -> Self {
40 Shell::Cmd
41 }
42
43 pub fn program(&self) -> &'static str {
45 match self {
46 Shell::Sh => "sh",
47 Shell::Bash => "bash",
48 Shell::Zsh => "zsh",
49 Shell::Fish => "fish",
50 Shell::Cmd => "cmd",
51 Shell::PowerShell => {
52 #[cfg(windows)]
54 {
55 "powershell"
56 }
57 #[cfg(not(windows))]
58 {
59 "pwsh"
60 }
61 }
62 }
63 }
64
65 pub fn exec_args(&self, command: &str) -> Vec<String> {
67 match self {
68 Shell::Sh | Shell::Bash | Shell::Zsh => {
69 vec!["-c".to_string(), command.to_string()]
70 }
71 Shell::Fish => {
72 vec!["-c".to_string(), command.to_string()]
73 }
74 Shell::Cmd => {
75 vec!["/C".to_string(), command.to_string()]
76 }
77 Shell::PowerShell => {
78 vec!["-Command".to_string(), command.to_string()]
79 }
80 }
81 }
82
83 pub fn command(&self, cmd: &str) -> tokio::process::Command {
85 let mut command = tokio::process::Command::new(self.program());
86 command.shell_script(self.program(), &self.exec_options(), cmd);
87 command
88 }
89
90 #[allow(dead_code)] pub fn std_command(&self, cmd: &str) -> std::process::Command {
93 let mut command = std::process::Command::new(self.program());
94 command.shell_script(self.program(), &self.exec_options(), cmd);
95 command
96 }
97
98 fn exec_options(&self) -> Vec<String> {
100 let mut args = self.exec_args("");
101 args.pop();
102 args
103 }
104}
105
106pub(crate) trait HideConsoleWindow {
135 fn hide_console_window(&mut self) -> &mut Self;
136}
137
138#[cfg(windows)]
159fn child_would_get_its_own_console() -> bool {
160 let console = unsafe { windows_sys::Win32::System::Console::GetConsoleWindow() };
161 console.is_null()
162}
163
164#[cfg(windows)]
165impl HideConsoleWindow for std::process::Command {
166 fn hide_console_window(&mut self) -> &mut Self {
167 use std::os::windows::process::CommandExt;
168 if child_would_get_its_own_console() {
169 self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
170 } else {
171 self
172 }
173 }
174}
175
176#[cfg(windows)]
177impl HideConsoleWindow for tokio::process::Command {
178 fn hide_console_window(&mut self) -> &mut Self {
179 if child_would_get_its_own_console() {
182 self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
183 } else {
184 self
185 }
186 }
187}
188
189#[cfg(not(windows))]
190impl HideConsoleWindow for std::process::Command {
191 fn hide_console_window(&mut self) -> &mut Self {
192 self
193 }
194}
195
196#[cfg(not(windows))]
197impl HideConsoleWindow for tokio::process::Command {
198 fn hide_console_window(&mut self) -> &mut Self {
199 self
200 }
201}
202
203pub(crate) trait ShellScript {
217 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self;
220}
221
222pub(crate) fn powershell_script<'a>(
234 program: &str,
235 options: &[String],
236 script: &'a str,
237) -> std::borrow::Cow<'a, str> {
238 let stem = program_stem(program);
239 let is_powershell =
240 stem.eq_ignore_ascii_case("powershell") || stem.eq_ignore_ascii_case("pwsh");
241 let runs_command = options.last().is_some_and(|flag| {
242 flag.eq_ignore_ascii_case("-command") || flag.eq_ignore_ascii_case("-c")
243 });
244 if is_powershell && runs_command && script.trim_start().starts_with(['"', '\'']) {
245 format!("& {script}").into()
246 } else {
247 script.into()
248 }
249}
250
251fn program_stem(program: &str) -> &str {
257 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
258 name.rsplit_once('.').map_or(name, |(stem, _)| stem)
259}
260
261#[cfg(any(windows, test))]
268fn cmd_raw_script<'a>(
269 program: &str,
270 options: &'a [String],
271 script: &str,
272) -> Option<(&'a [String], String)> {
273 let is_cmd = program_stem(program).eq_ignore_ascii_case("cmd");
274 let (flag, leading) = options.split_last()?;
275 if !is_cmd || !flag.eq_ignore_ascii_case("/c") {
276 return None;
277 }
278 let strip = if leading.iter().any(|o| o.eq_ignore_ascii_case("/s")) {
280 ""
281 } else {
282 "/S "
283 };
284 Some((leading, format!("{strip}{flag} \"{script}\"")))
285}
286
287#[cfg(windows)]
288impl ShellScript for std::process::Command {
289 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
290 use std::os::windows::process::CommandExt;
291 match cmd_raw_script(program, options, script) {
292 Some((leading, raw)) => self.args(leading).raw_arg(raw),
293 None => self
294 .args(options)
295 .arg(&*powershell_script(program, options, script)),
296 }
297 }
298}
299
300#[cfg(windows)]
301impl ShellScript for tokio::process::Command {
302 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
303 match cmd_raw_script(program, options, script) {
305 Some((leading, raw)) => self.args(leading).raw_arg(raw),
306 None => self
307 .args(options)
308 .arg(&*powershell_script(program, options, script)),
309 }
310 }
311}
312
313#[cfg(not(windows))]
314impl ShellScript for std::process::Command {
315 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
316 self.args(options)
317 .arg(&*powershell_script(program, options, script))
318 }
319}
320
321#[cfg(not(windows))]
322impl ShellScript for tokio::process::Command {
323 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
324 self.args(options)
325 .arg(&*powershell_script(program, options, script))
326 }
327}
328
329impl std::fmt::Display for Shell {
330 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331 match self {
332 Shell::Sh => write!(f, "sh"),
333 Shell::Bash => write!(f, "bash"),
334 Shell::Zsh => write!(f, "zsh"),
335 Shell::Fish => write!(f, "fish"),
336 Shell::Cmd => write!(f, "cmd"),
337 Shell::PowerShell => write!(f, "powershell"),
338 }
339 }
340}
341
342impl std::str::FromStr for Shell {
343 type Err = String;
344
345 fn from_str(s: &str) -> Result<Self, Self::Err> {
346 match s.to_lowercase().as_str() {
347 "sh" => Ok(Shell::Sh),
348 "bash" => Ok(Shell::Bash),
349 "zsh" => Ok(Shell::Zsh),
350 "fish" => Ok(Shell::Fish),
351 "cmd" => Ok(Shell::Cmd),
352 "powershell" | "pwsh" => Ok(Shell::PowerShell),
353 _ => Err(format!("unknown shell: {s}")),
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_shell_program() {
364 assert_eq!(Shell::Sh.program(), "sh");
365 assert_eq!(Shell::Bash.program(), "bash");
366 assert_eq!(Shell::Zsh.program(), "zsh");
367 assert_eq!(Shell::Fish.program(), "fish");
368 assert_eq!(Shell::Cmd.program(), "cmd");
369 }
370
371 #[test]
372 fn test_shell_exec_args() {
373 assert_eq!(Shell::Sh.exec_args("echo hello"), vec!["-c", "echo hello"]);
374 assert_eq!(
375 Shell::Bash.exec_args("echo hello"),
376 vec!["-c", "echo hello"]
377 );
378 assert_eq!(Shell::Cmd.exec_args("echo hello"), vec!["/C", "echo hello"]);
379 assert_eq!(
380 Shell::PowerShell.exec_args("echo hello"),
381 vec!["-Command", "echo hello"]
382 );
383 }
384
385 #[test]
386 fn test_shell_from_str() {
387 assert_eq!("sh".parse::<Shell>().unwrap(), Shell::Sh);
388 assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
389 assert_eq!("BASH".parse::<Shell>().unwrap(), Shell::Bash);
390 assert_eq!("powershell".parse::<Shell>().unwrap(), Shell::PowerShell);
391 assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::PowerShell);
392 assert!("unknown".parse::<Shell>().is_err());
393 }
394
395 #[test]
396 fn test_shell_display() {
397 assert_eq!(Shell::Sh.to_string(), "sh");
398 assert_eq!(Shell::Bash.to_string(), "bash");
399 assert_eq!(Shell::Cmd.to_string(), "cmd");
400 }
401
402 #[test]
403 fn test_default_shell() {
404 let default = Shell::default_for_platform();
406 #[cfg(unix)]
407 assert_eq!(default, Shell::Sh);
408 #[cfg(windows)]
409 assert_eq!(default, Shell::Cmd);
410 }
411
412 #[test]
419 fn test_hide_console_window() {
420 let program = if cfg!(windows) { "cmd" } else { "echo" };
421 let args: Vec<&str> = if cfg!(windows) {
422 vec!["/C", "echo hi"]
423 } else {
424 vec!["hi"]
425 };
426
427 let output = std::process::Command::new(program)
428 .args(&args)
429 .hide_console_window()
430 .output()
431 .expect("spawning the child should succeed");
432 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");
433
434 let mut async_command = tokio::process::Command::new(program);
437 async_command.args(&args).hide_console_window();
438 }
439
440 fn strings(words: &[&str]) -> Vec<String> {
441 words.iter().map(|w| w.to_string()).collect()
442 }
443
444 #[test]
445 fn test_cmd_raw_script_wraps_the_script_for_cmd() {
446 let options = strings(&["/C"]);
447 let (leading, raw) = cmd_raw_script("cmd", &options, r#"echo "a b""#).unwrap();
448 assert!(leading.is_empty());
449 assert_eq!(raw, r#"/S /C "echo "a b"""#);
450
451 let options = strings(&["/d", "/s", "/c"]);
453 let (leading, raw) =
454 cmd_raw_script(r"C:\Windows\System32\CMD.EXE", &options, "echo hi").unwrap();
455 assert_eq!(leading, &options[..2]);
456 assert_eq!(raw, r#"/c "echo hi""#);
457 }
458
459 #[test]
460 fn test_cmd_raw_script_leaves_other_shells_alone() {
461 assert_eq!(cmd_raw_script("sh", &strings(&["-c"]), "echo hi"), None);
462 assert_eq!(
463 cmd_raw_script("pwsh", &strings(&["-Command"]), "echo hi"),
464 None
465 );
466 assert_eq!(cmd_raw_script("cmd", &strings(&["/K"]), "echo hi"), None);
468 assert_eq!(cmd_raw_script("cmd", &[], "echo hi"), None);
469 }
470
471 #[cfg(windows)]
474 #[test]
475 fn test_cmd_runs_a_script_with_quotes() {
476 let dir = tempfile::tempdir().unwrap();
477 let script_dir = dir.path().join("with space");
478 std::fs::create_dir_all(&script_dir).unwrap();
479 let script = script_dir.join("say.cmd");
480 std::fs::write(&script, "@echo [%~1]\r\n").unwrap();
481
482 let run = format!(r#""{}" "a b""#, script.display());
483 let output = std::process::Command::new("cmd")
484 .shell_script("cmd", &strings(&["/C"]), &run)
485 .output()
486 .unwrap();
487 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "[a b]");
488
489 let output = Shell::Cmd.std_command(r#"echo "a b""#).output().unwrap();
490 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), r#""a b""#);
491 }
492
493 #[test]
494 fn test_powershell_script_calls_a_quoted_program() {
495 let command = strings(&["-Command"]);
496 assert_eq!(
497 powershell_script("powershell", &command, r#""C:\a b\app.exe" --port 80"#),
498 r#"& "C:\a b\app.exe" --port 80"#
499 );
500 assert_eq!(
501 powershell_script(
502 r"C:\Program Files\PowerShell\7\pwsh.exe",
503 &strings(&["-NoProfile", "-c"]),
504 "'app' x"
505 ),
506 "& 'app' x"
507 );
508 assert_eq!(
510 powershell_script("pwsh", &command, "app --port 80"),
511 "app --port 80"
512 );
513 assert_eq!(powershell_script("pwsh", &command, "& 'app'"), "& 'app'");
514 assert_eq!(
515 powershell_script("pwsh", &command, r#"echo "a b""#),
516 r#"echo "a b""#
517 );
518 assert_eq!(
519 powershell_script("cmd", &strings(&["/C"]), r#""app""#),
520 r#""app""#
521 );
522 assert_eq!(powershell_script("sh", &strings(&["-c"]), "'app'"), "'app'");
523 assert_eq!(
524 powershell_script("pwsh", &strings(&["-File"]), "'app'"),
525 "'app'"
526 );
527 }
528
529 #[cfg(windows)]
532 #[test]
533 fn test_powershell_runs_a_script_starting_with_a_quoted_path() {
534 let dir = tempfile::tempdir().unwrap();
535 let script_dir = dir.path().join("with space");
536 std::fs::create_dir_all(&script_dir).unwrap();
537 let script = script_dir.join("say.cmd");
538 std::fs::write(&script, "@echo [%~1]\r\n").unwrap();
539
540 for run in [
541 format!(r#""{}" "a b""#, script.display()),
542 format!("'{}' 'a b'", script.display()),
543 ] {
544 let output = Shell::PowerShell.std_command(&run).output().unwrap();
545 assert!(output.status.success(), "{run}: {output:?}");
546 assert_eq!(
547 String::from_utf8_lossy(&output.stdout).trim(),
548 "[a b]",
549 "{run}"
550 );
551 }
552 }
553}