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/// Implemented for both `std::process::Command` and `tokio::process::Command`.
213pub(crate) trait ShellScript {
214    /// `program` is the shell the command was created for, and `options` the
215    /// arguments that come before the script, such as `-c` or `/C`.
216    fn shell_script(&mut self, program: &str, options: &[String], script: &str) -> &mut Self;
217}
218
219/// For cmd.exe run with `/C` as its last option, the options to pass before
220/// the script, and the raw text that carries the script.
221///
222/// `None` for any other shell, which takes the script as a normal argument.
223/// Platform-independent so that it can be tested everywhere; only Windows
224/// acts on it.
225#[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    // Split by hand rather than with `Path`, which does not treat `\` as a
232    // separator outside Windows and so could not be tested there.
233    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    // `/S` has to come before `/C`: everything after `/C` is the command.
241    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        // tokio exposes `raw_arg` as an inherent method on Windows.
264        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        // Default should be Sh (or Cmd on Windows)
361        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    /// Checks that `hide_console_window` is available for both command types,
369    /// chains inside a builder expression, and leaves spawning intact.
370    ///
371    /// This does not assert that no console window appears: the reliable
372    /// oracles for that are version-dependent Windows behaviour, so the
373    /// absence of a window is verified manually instead.
374    #[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        // Building a tokio command needs no runtime, so this pins the second
391        // impl without making the test async.
392        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        // Any spelling of cmd.exe, and any case of the flags.
408        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        // cmd without /C last is not running a script this way.
423        assert_eq!(cmd_raw_script("cmd", &strings(&["/K"]), "echo hi"), None);
424        assert_eq!(cmd_raw_script("cmd", &[], "echo hi"), None);
425    }
426
427    /// The case the raw command line exists for: quotes in the script reach
428    /// cmd.exe as written, including a quoted program path with spaces.
429    #[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}