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.args(self.exec_args(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.args(self.exec_args(cmd));
95        command
96    }
97}
98
99/// Prevents a spawned command from creating a console window on Windows.
100///
101/// The supervisor is created with `DETACHED_PROCESS | CREATE_NO_WINDOW`, so it
102/// has no console of its own. On Windows the loader gives every
103/// console-subsystem child of a console-less parent a brand new *visible*
104/// console. Redirecting the child's stdio to pipes or NUL does not suppress
105/// that, because the allocation is decided from the PE subsystem and the
106/// creation flags rather than from the handles, so anything spawned from
107/// inside the supervisor has to opt out explicitly.
108///
109/// Opting out does not leave the child without a console: `CREATE_NO_WINDOW`
110/// gives it one of its own that simply has no window, so console APIs keep
111/// working. Measured on Windows 11 — a child spawned with the flag reports
112/// `GetConsoleCP() = 932` and `GetConsoleProcessList() = 1`, both of which fail
113/// for a process with no console. What changes is only that the console is not
114/// drawn, and that `GetConsoleWindow` returns null for it.
115///
116/// Implemented for both `std::process::Command` and `tokio::process::Command`,
117/// and returns `&mut Self` so it drops into the existing fluent chains. The
118/// non-Windows impls are no-ops, which keeps the call sites free of `cfg`.
119///
120/// The flag is only applied when this process has no console, because that is
121/// the only case where a child would get one of its own. See
122/// `child_would_get_its_own_console`.
123///
124/// Note: `creation_flags` *replaces* a command's creation flags rather than
125/// OR-ing into them. Call this once per command, and after any other
126/// `creation_flags` call, or those flags are silently dropped.
127pub(crate) trait HideConsoleWindow {
128    fn hide_console_window(&mut self) -> &mut Self;
129}
130
131/// Whether a console-subsystem child of this process would be given a console
132/// of its own rather than inheriting one.
133///
134/// A child inherits the parent's console whenever the parent has one, and no
135/// new window appears, so `CREATE_NO_WINDOW` is unnecessary there. It would
136/// also be a behaviour change: the child would be put on a separate console
137/// instead of the shared one, so a console control event sent to the parent's
138/// console would no longer reach it. Detached processes such as the background
139/// supervisor have no console, and only there does a child get a new — and
140/// visible — one.
141///
142/// `GetConsoleWindow` reports the absence of a console *window*, which is not
143/// quite the same as the absence of a console: it also returns null for a
144/// console that has no window, such as a ConPTY session or a process started
145/// with `CREATE_NO_WINDOW` itself. Those cases are counted as "no console"
146/// here, and that costs nothing — the child is then given a console of its own
147/// instead of sharing a console nobody can see, which is what every one of
148/// these spawns did unconditionally before this check existed. What the check
149/// is for is the case it does detect precisely: a supervisor running in the
150/// foreground on a real console, whose children should keep sharing it.
151#[cfg(windows)]
152fn child_would_get_its_own_console() -> bool {
153    let console = unsafe { windows_sys::Win32::System::Console::GetConsoleWindow() };
154    console.is_null()
155}
156
157#[cfg(windows)]
158impl HideConsoleWindow for std::process::Command {
159    fn hide_console_window(&mut self) -> &mut Self {
160        use std::os::windows::process::CommandExt;
161        if child_would_get_its_own_console() {
162            self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
163        } else {
164            self
165        }
166    }
167}
168
169#[cfg(windows)]
170impl HideConsoleWindow for tokio::process::Command {
171    fn hide_console_window(&mut self) -> &mut Self {
172        // tokio exposes `creation_flags` as an inherent method on Windows;
173        // `CommandExt` is not implemented for this type.
174        if child_would_get_its_own_console() {
175            self.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
176        } else {
177            self
178        }
179    }
180}
181
182#[cfg(not(windows))]
183impl HideConsoleWindow for std::process::Command {
184    fn hide_console_window(&mut self) -> &mut Self {
185        self
186    }
187}
188
189#[cfg(not(windows))]
190impl HideConsoleWindow for tokio::process::Command {
191    fn hide_console_window(&mut self) -> &mut Self {
192        self
193    }
194}
195
196impl std::fmt::Display for Shell {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            Shell::Sh => write!(f, "sh"),
200            Shell::Bash => write!(f, "bash"),
201            Shell::Zsh => write!(f, "zsh"),
202            Shell::Fish => write!(f, "fish"),
203            Shell::Cmd => write!(f, "cmd"),
204            Shell::PowerShell => write!(f, "powershell"),
205        }
206    }
207}
208
209impl std::str::FromStr for Shell {
210    type Err = String;
211
212    fn from_str(s: &str) -> Result<Self, Self::Err> {
213        match s.to_lowercase().as_str() {
214            "sh" => Ok(Shell::Sh),
215            "bash" => Ok(Shell::Bash),
216            "zsh" => Ok(Shell::Zsh),
217            "fish" => Ok(Shell::Fish),
218            "cmd" => Ok(Shell::Cmd),
219            "powershell" | "pwsh" => Ok(Shell::PowerShell),
220            _ => Err(format!("unknown shell: {s}")),
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_shell_program() {
231        assert_eq!(Shell::Sh.program(), "sh");
232        assert_eq!(Shell::Bash.program(), "bash");
233        assert_eq!(Shell::Zsh.program(), "zsh");
234        assert_eq!(Shell::Fish.program(), "fish");
235        assert_eq!(Shell::Cmd.program(), "cmd");
236    }
237
238    #[test]
239    fn test_shell_exec_args() {
240        assert_eq!(Shell::Sh.exec_args("echo hello"), vec!["-c", "echo hello"]);
241        assert_eq!(
242            Shell::Bash.exec_args("echo hello"),
243            vec!["-c", "echo hello"]
244        );
245        assert_eq!(Shell::Cmd.exec_args("echo hello"), vec!["/C", "echo hello"]);
246        assert_eq!(
247            Shell::PowerShell.exec_args("echo hello"),
248            vec!["-Command", "echo hello"]
249        );
250    }
251
252    #[test]
253    fn test_shell_from_str() {
254        assert_eq!("sh".parse::<Shell>().unwrap(), Shell::Sh);
255        assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
256        assert_eq!("BASH".parse::<Shell>().unwrap(), Shell::Bash);
257        assert_eq!("powershell".parse::<Shell>().unwrap(), Shell::PowerShell);
258        assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::PowerShell);
259        assert!("unknown".parse::<Shell>().is_err());
260    }
261
262    #[test]
263    fn test_shell_display() {
264        assert_eq!(Shell::Sh.to_string(), "sh");
265        assert_eq!(Shell::Bash.to_string(), "bash");
266        assert_eq!(Shell::Cmd.to_string(), "cmd");
267    }
268
269    #[test]
270    fn test_default_shell() {
271        // Default should be Sh (or Cmd on Windows)
272        let default = Shell::default_for_platform();
273        #[cfg(unix)]
274        assert_eq!(default, Shell::Sh);
275        #[cfg(windows)]
276        assert_eq!(default, Shell::Cmd);
277    }
278
279    /// Checks that `hide_console_window` is available for both command types,
280    /// chains inside a builder expression, and leaves spawning intact.
281    ///
282    /// This does not assert that no console window appears: the reliable
283    /// oracles for that are version-dependent Windows behaviour, so the
284    /// absence of a window is verified manually instead.
285    #[test]
286    fn test_hide_console_window() {
287        let program = if cfg!(windows) { "cmd" } else { "echo" };
288        let args: Vec<&str> = if cfg!(windows) {
289            vec!["/C", "echo hi"]
290        } else {
291            vec!["hi"]
292        };
293
294        let output = std::process::Command::new(program)
295            .args(&args)
296            .hide_console_window()
297            .output()
298            .expect("spawning the child should succeed");
299        assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");
300
301        // Building a tokio command needs no runtime, so this pins the second
302        // impl without making the test async.
303        let mut async_command = tokio::process::Command::new(program);
304        async_command.args(&args).hide_console_window();
305    }
306}