Skip to main content

pitchfork_cli/
shell.rs

1//! Shell abstraction for cross-platform command execution
2//!
3//! This module provides a platform-agnostic way to execute shell commands,
4//! supporting different shells on Unix and Windows platforms.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Supported shell types for command execution
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "lowercase")]
12#[allow(clippy::enum_variant_names)] // PowerShell is the correct name for this shell
13pub enum Shell {
14    /// POSIX-compatible shell (default on Unix)
15    #[default]
16    Sh,
17    /// Bash shell
18    Bash,
19    /// Zsh shell
20    Zsh,
21    /// Fish shell
22    Fish,
23    /// Windows Command Prompt
24    Cmd,
25    /// PowerShell (cross-platform)
26    #[serde(alias = "pwsh")]
27    PowerShell,
28}
29
30impl Shell {
31    /// Returns the default shell for the current platform
32    #[cfg(unix)]
33    pub fn default_for_platform() -> Self {
34        Shell::Sh
35    }
36
37    /// Returns the default shell for the current platform
38    #[cfg(windows)]
39    pub fn default_for_platform() -> Self {
40        Shell::Cmd
41    }
42
43    /// Returns the shell program name/path
44    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                // pwsh is the cross-platform PowerShell, powershell is Windows-only
53                #[cfg(windows)]
54                {
55                    "powershell"
56                }
57                #[cfg(not(windows))]
58                {
59                    "pwsh"
60                }
61            }
62        }
63    }
64
65    /// Returns the arguments needed to execute a command string
66    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    /// Creates a tokio Command configured to run the given command string
84    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    /// Creates a std Command configured to run the given command string
91    #[allow(dead_code)] // Available for future use (e.g., spawn commands)
92    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    /// The options that come before the command string: `exec_args` without it.
99    fn exec_options(&self) -> Vec<String> {
100        let mut args = self.exec_args("");
101        args.pop();
102        args
103    }
104}
105
106/// Prevents a spawned command from creating a console window on Windows.
107///
108/// The supervisor is created with `DETACHED_PROCESS | CREATE_NO_WINDOW`, so it
109/// has no console of its own. On Windows the loader gives every
110/// console-subsystem child of a console-less parent a brand new *visible*
111/// console. Redirecting the child's stdio to pipes or NUL does not suppress
112/// that, because the allocation is decided from the PE subsystem and the
113/// creation flags rather than from the handles, so anything spawned from
114/// inside the supervisor has to opt out explicitly.
115///
116/// Opting out does not leave the child without a console: `CREATE_NO_WINDOW`
117/// gives it one of its own that simply has no window, so console APIs keep
118/// working. Measured on Windows 11 — a child spawned with the flag reports
119/// `GetConsoleCP() = 932` and `GetConsoleProcessList() = 1`, both of which fail
120/// for a process with no console. What changes is only that the console is not
121/// drawn, and that `GetConsoleWindow` returns null for it.
122///
123/// Implemented for both `std::process::Command` and `tokio::process::Command`,
124/// and returns `&mut Self` so it drops into the existing fluent chains. The
125/// non-Windows impls are no-ops, which keeps the call sites free of `cfg`.
126///
127/// The flag is only applied when this process has no console, because that is
128/// the only case where a child would get one of its own. See
129/// `child_would_get_its_own_console`.
130///
131/// Note: `creation_flags` *replaces* a command's creation flags rather than
132/// OR-ing into them. Call this once per command, and after any other
133/// `creation_flags` call, or those flags are silently dropped.
134pub(crate) trait HideConsoleWindow {
135    fn hide_console_window(&mut self) -> &mut Self;
136}
137
138/// Whether a console-subsystem child of this process would be given a console
139/// of its own rather than inheriting one.
140///
141/// A child inherits the parent's console whenever the parent has one, and no
142/// new window appears, so `CREATE_NO_WINDOW` is unnecessary there. It would
143/// also be a behaviour change: the child would be put on a separate console
144/// instead of the shared one, so a console control event sent to the parent's
145/// console would no longer reach it. Detached processes such as the background
146/// supervisor have no console, and only there does a child get a new — and
147/// visible — one.
148///
149/// `GetConsoleWindow` reports the absence of a console *window*, which is not
150/// quite the same as the absence of a console: it also returns null for a
151/// console that has no window, such as a ConPTY session or a process started
152/// with `CREATE_NO_WINDOW` itself. Those cases are counted as "no console"
153/// here, and that costs nothing — the child is then given a console of its own
154/// instead of sharing a console nobody can see, which is what every one of
155/// these spawns did unconditionally before this check existed. What the check
156/// is for is the case it does detect precisely: a supervisor running in the
157/// foreground on a real console, whose children should keep sharing it.
158#[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        // tokio exposes `creation_flags` as an inherent method on Windows;
180        // `CommandExt` is not implemented for this type.
181        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
203/// Hands a script to a shell: the shell's own options, then the script.
204///
205/// On Windows, when the shell is cmd.exe, the script is passed as
206/// `/S /C "<script>"` on the raw command line instead of as an argument.
207/// `Command::arg` quotes an argument for the Microsoft C runtime, turning each
208/// `"` into `\"`, and cmd does not undo that: `node "my server.js"` would reach
209/// it as `node \"my server.js\"`. With `/S`, cmd strips the outer pair of
210/// quotes and runs the rest exactly as written.
211///
212/// When the shell is PowerShell, a script that starts with a quoted string is
213/// run with the call operator, as `& <script>`. See `powershell_script`.
214///
215/// Implemented for both `std::process::Command` and `tokio::process::Command`.
216pub(crate) trait ShellScript {
217    /// `program` is the shell the command was created for, and `options` the
218    /// arguments that come before the script, such as `-c` or `/C`.
219    fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self;
220}
221
222/// The script as PowerShell should be given it.
223///
224/// PowerShell reads a statement that starts with a quoted string as an
225/// expression rather than a command, so `"C:\Program Files\app\app.exe" --port 80`
226/// fails to parse ("Unexpected token"), and a quoted path on its own just
227/// prints itself. A quoted program path has to be run with the call operator,
228/// `& "C:\Program Files\app\app.exe" --port 80`, which is what this adds when
229/// the shell is `powershell` or `pwsh` with `-Command` (or `-c`) last.
230///
231/// The script is returned unchanged for any other shell, and for a script that
232/// does not start with a quote.
233pub(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
251/// The file name of `program` without its extension: `cmd` for
252/// `C:\Windows\System32\cmd.exe`.
253///
254/// Split by hand rather than with `Path`, which does not treat `\` as a
255/// separator outside Windows and so could not be tested there.
256fn 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/// For cmd.exe run with `/C` as its last option, the options to pass before
262/// the script, and the raw text that carries the script.
263///
264/// `None` for any other shell, which takes the script as a normal argument.
265/// Platform-independent so that it can be tested everywhere; only Windows
266/// acts on it.
267#[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    // `/S` has to come before `/C`: everything after `/C` is the command.
279    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        // tokio exposes `raw_arg` as an inherent method on Windows.
304        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        // Default should be Sh (or Cmd on Windows)
405        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    /// Checks that `hide_console_window` is available for both command types,
413    /// chains inside a builder expression, and leaves spawning intact.
414    ///
415    /// This does not assert that no console window appears: the reliable
416    /// oracles for that are version-dependent Windows behaviour, so the
417    /// absence of a window is verified manually instead.
418    #[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        // Building a tokio command needs no runtime, so this pins the second
435        // impl without making the test async.
436        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        // Any spelling of cmd.exe, and any case of the flags.
452        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        // cmd without /C last is not running a script this way.
467        assert_eq!(cmd_raw_script("cmd", &strings(&["/K"]), "echo hi"), None);
468        assert_eq!(cmd_raw_script("cmd", &[], "echo hi"), None);
469    }
470
471    /// The case the raw command line exists for: quotes in the script reach
472    /// cmd.exe as written, including a quoted program path with spaces.
473    #[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        // Not quoted, not PowerShell, or not run as a command: unchanged.
509        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    /// PowerShell runs a script that starts with a quoted program path,
530    /// which it would otherwise fail to parse.
531    #[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}