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
99impl std::fmt::Display for Shell {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        match self {
102            Shell::Sh => write!(f, "sh"),
103            Shell::Bash => write!(f, "bash"),
104            Shell::Zsh => write!(f, "zsh"),
105            Shell::Fish => write!(f, "fish"),
106            Shell::Cmd => write!(f, "cmd"),
107            Shell::PowerShell => write!(f, "powershell"),
108        }
109    }
110}
111
112impl std::str::FromStr for Shell {
113    type Err = String;
114
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        match s.to_lowercase().as_str() {
117            "sh" => Ok(Shell::Sh),
118            "bash" => Ok(Shell::Bash),
119            "zsh" => Ok(Shell::Zsh),
120            "fish" => Ok(Shell::Fish),
121            "cmd" => Ok(Shell::Cmd),
122            "powershell" | "pwsh" => Ok(Shell::PowerShell),
123            _ => Err(format!("unknown shell: {s}")),
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_shell_program() {
134        assert_eq!(Shell::Sh.program(), "sh");
135        assert_eq!(Shell::Bash.program(), "bash");
136        assert_eq!(Shell::Zsh.program(), "zsh");
137        assert_eq!(Shell::Fish.program(), "fish");
138        assert_eq!(Shell::Cmd.program(), "cmd");
139    }
140
141    #[test]
142    fn test_shell_exec_args() {
143        assert_eq!(Shell::Sh.exec_args("echo hello"), vec!["-c", "echo hello"]);
144        assert_eq!(
145            Shell::Bash.exec_args("echo hello"),
146            vec!["-c", "echo hello"]
147        );
148        assert_eq!(Shell::Cmd.exec_args("echo hello"), vec!["/C", "echo hello"]);
149        assert_eq!(
150            Shell::PowerShell.exec_args("echo hello"),
151            vec!["-Command", "echo hello"]
152        );
153    }
154
155    #[test]
156    fn test_shell_from_str() {
157        assert_eq!("sh".parse::<Shell>().unwrap(), Shell::Sh);
158        assert_eq!("bash".parse::<Shell>().unwrap(), Shell::Bash);
159        assert_eq!("BASH".parse::<Shell>().unwrap(), Shell::Bash);
160        assert_eq!("powershell".parse::<Shell>().unwrap(), Shell::PowerShell);
161        assert_eq!("pwsh".parse::<Shell>().unwrap(), Shell::PowerShell);
162        assert!("unknown".parse::<Shell>().is_err());
163    }
164
165    #[test]
166    fn test_shell_display() {
167        assert_eq!(Shell::Sh.to_string(), "sh");
168        assert_eq!(Shell::Bash.to_string(), "bash");
169        assert_eq!(Shell::Cmd.to_string(), "cmd");
170    }
171
172    #[test]
173    fn test_default_shell() {
174        // Default should be Sh (or Cmd on Windows)
175        let default = Shell::default();
176        #[cfg(unix)]
177        assert_eq!(default, Shell::Sh);
178        #[cfg(windows)]
179        assert_eq!(default, Shell::Cmd);
180    }
181}