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}