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 {
214 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self;
217}
218
219#[cfg(any(windows, test))]
226fn cmd_raw_script<'a>(
227 program: &str,
228 options: &'a [String],
229 script: &str,
230) -> Option<(&'a [String], String)> {
231 let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
234 let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
235 let is_cmd = stem.eq_ignore_ascii_case("cmd");
236 let (flag, leading) = options.split_last()?;
237 if !is_cmd || !flag.eq_ignore_ascii_case("/c") {
238 return None;
239 }
240 let strip = if leading.iter().any(|o| o.eq_ignore_ascii_case("/s")) {
242 ""
243 } else {
244 "/S "
245 };
246 Some((leading, format!("{strip}{flag} \"{script}\"")))
247}
248
249#[cfg(windows)]
250impl ShellScript for std::process::Command {
251 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
252 use std::os::windows::process::CommandExt;
253 match cmd_raw_script(program, options, script) {
254 Some((leading, raw)) => self.args(leading).raw_arg(raw),
255 None => self.args(options).arg(script),
256 }
257 }
258}
259
260#[cfg(windows)]
261impl ShellScript for tokio::process::Command {
262 fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self {
263 match cmd_raw_script(program, options, script) {
265 Some((leading, raw)) => self.args(leading).raw_arg(raw),
266 None => self.args(options).arg(script),
267 }
268 }
269}
270
271#[cfg(not(windows))]
272impl ShellScript for std::process::Command {
273 fn shell_script(&mut self, _program: &str, options: &[String], script: &str) -> &mut Self {
274 self.args(options).arg(script)
275 }
276}
277
278#[cfg(not(windows))]
279impl ShellScript for tokio::process::Command {
280 fn shell_script(&mut self, _program: &str, options: &[String], script: &str) -> &mut Self {
281 self.args(options).arg(script)
282 }
283}
284
285impl std::fmt::Display for Shell {
286 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
287 match self {
288 Shell::Sh => write!(f, "sh"),
289 Shell::Bash => write!(f, "bash"),
290 Shell::Zsh => write!(f, "zsh"),
291 Shell::Fish => write!(f, "fish"),
292 Shell::Cmd => write!(f, "cmd"),
293 Shell::PowerShell => write!(f, "powershell"),
294 }
295 }
296}
297
298impl std::str::FromStr for Shell {
299 type Err = String;
300
301 fn from_str(s: &str) -> Result<Self, Self::Err> {
302 match s.to_lowercase().as_str() {
303 "sh" => Ok(Shell::Sh),
304 "bash" => Ok(Shell::Bash),
305 "zsh" => Ok(Shell::Zsh),
306 "fish" => Ok(Shell::Fish),
307 "cmd" => Ok(Shell::Cmd),
308 "powershell" | "pwsh" => Ok(Shell::PowerShell),
309 _ => Err(format!("unknown shell: {s}")),
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_shell_program() {
320 assert_eq!(Shell::Sh.program(), "sh");
321 assert_eq!(Shell::Bash.program(), "bash");
322 assert_eq!(Shell::Zsh.program(), "zsh");
323 assert_eq!(Shell::Fish.program(), "fish");
324 assert_eq!(Shell::Cmd.program(), "cmd");
325 }
326
327 #[test]
328 fn test_shell_exec_args() {
329 assert_eq!(Shell::Sh.exec_args("echo hello"), vec!["-c", "echo hello"]);
330 assert_eq!(
331 Shell::Bash.exec_args("echo hello"),
332 vec!["-c", "echo hello"]
333 );
334 assert_eq!(Shell::Cmd.exec_args("echo hello"), vec!["/C", "echo hello"]);
335 assert_eq!(
336 Shell::PowerShell.exec_args("echo hello"),
337 vec!["-Command", "echo hello"]
338 );
339 }
340
341 #[test]
342 fn test_shell_from_str() {
343 assert_eq!("sh".parse::<Shell>().unwrap(), Shell::Sh);
344 assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
345 assert_eq!("BASH".parse::<Shell>().unwrap(), Shell::Bash);
346 assert_eq!("powershell".parse::<Shell>().unwrap(), Shell::PowerShell);
347 assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::PowerShell);
348 assert!("unknown".parse::<Shell>().is_err());
349 }
350
351 #[test]
352 fn test_shell_display() {
353 assert_eq!(Shell::Sh.to_string(), "sh");
354 assert_eq!(Shell::Bash.to_string(), "bash");
355 assert_eq!(Shell::Cmd.to_string(), "cmd");
356 }
357
358 #[test]
359 fn test_default_shell() {
360 let default = Shell::default_for_platform();
362 #[cfg(unix)]
363 assert_eq!(default, Shell::Sh);
364 #[cfg(windows)]
365 assert_eq!(default, Shell::Cmd);
366 }
367
368 #[test]
375 fn test_hide_console_window() {
376 let program = if cfg!(windows) { "cmd" } else { "echo" };
377 let args: Vec<&str> = if cfg!(windows) {
378 vec!["/C", "echo hi"]
379 } else {
380 vec!["hi"]
381 };
382
383 let output = std::process::Command::new(program)
384 .args(&args)
385 .hide_console_window()
386 .output()
387 .expect("spawning the child should succeed");
388 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");
389
390 let mut async_command = tokio::process::Command::new(program);
393 async_command.args(&args).hide_console_window();
394 }
395
396 fn strings(words: &[&str]) -> Vec<String> {
397 words.iter().map(|w| w.to_string()).collect()
398 }
399
400 #[test]
401 fn test_cmd_raw_script_wraps_the_script_for_cmd() {
402 let options = strings(&["/C"]);
403 let (leading, raw) = cmd_raw_script("cmd", &options, r#"echo "a b""#).unwrap();
404 assert!(leading.is_empty());
405 assert_eq!(raw, r#"/S /C "echo "a b"""#);
406
407 let options = strings(&["/d", "/s", "/c"]);
409 let (leading, raw) =
410 cmd_raw_script(r"C:\Windows\System32\CMD.EXE", &options, "echo hi").unwrap();
411 assert_eq!(leading, &options[..2]);
412 assert_eq!(raw, r#"/c "echo hi""#);
413 }
414
415 #[test]
416 fn test_cmd_raw_script_leaves_other_shells_alone() {
417 assert_eq!(cmd_raw_script("sh", &strings(&["-c"]), "echo hi"), None);
418 assert_eq!(
419 cmd_raw_script("pwsh", &strings(&["-Command"]), "echo hi"),
420 None
421 );
422 assert_eq!(cmd_raw_script("cmd", &strings(&["/K"]), "echo hi"), None);
424 assert_eq!(cmd_raw_script("cmd", &[], "echo hi"), None);
425 }
426
427 #[cfg(windows)]
430 #[test]
431 fn test_cmd_runs_a_script_with_quotes() {
432 let dir = tempfile::tempdir().unwrap();
433 let script_dir = dir.path().join("with space");
434 std::fs::create_dir_all(&script_dir).unwrap();
435 let script = script_dir.join("say.cmd");
436 std::fs::write(&script, "@echo [%~1]\r\n").unwrap();
437
438 let run = format!(r#""{}" "a b""#, script.display());
439 let output = std::process::Command::new("cmd")
440 .shell_script("cmd", &strings(&["/C"]), &run)
441 .output()
442 .unwrap();
443 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "[a b]");
444
445 let output = Shell::Cmd.std_command(r#"echo "a b""#).output().unwrap();
446 assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), r#""a b""#);
447 }
448}