1use schemars::JsonSchema;
2use serde::Deserialize;
3use std::process::Command as ProcessCommand;
4
5#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
6pub struct ShellArgs {
8 pub command: String,
10
11 pub args: Option<Vec<String>>,
13}
14
15#[derive(Debug, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
16#[serde(untagged)]
17pub enum Shell {
19 String(String),
20 Shell(Box<ShellArgs>),
21}
22
23impl Default for Shell {
24 fn default() -> Self {
25 Shell::String(default_shell_command().to_string())
26 }
27}
28
29impl Shell {
30 pub fn new() -> anyhow::Result<Self> {
31 Ok(Shell::default())
32 }
33
34 pub fn new_with_flags(command: &str, args: Vec<String>) -> anyhow::Result<Self> {
35 let shell_def = ShellArgs {
36 command: command.to_string(),
37 args: Some(args),
38 };
39 Ok(Shell::Shell(Box::new(shell_def)))
40 }
41
42 pub fn from_shell(shell: &Shell) -> Self {
43 match shell {
44 Shell::String(command) => Shell::String(command.to_string()),
45 Shell::Shell(args) => Shell::Shell(args.clone()),
46 }
47 }
48
49 pub fn cmd(&self) -> String {
50 match self {
51 Shell::String(command) => ShellArgs {
52 command: command.to_string(),
53 args: None,
54 }
55 .cmd(),
56 Shell::Shell(args) => args.cmd(),
57 }
58 }
59
60 pub fn args(&self) -> Vec<String> {
61 match self {
62 Shell::String(command) => ShellArgs {
63 command: command.to_string(),
64 args: None,
65 }
66 .shell_args(),
67 Shell::Shell(args) => args.shell_args(),
68 }
69 }
70
71 pub fn proc(&self) -> ProcessCommand {
72 let shell = self.cmd();
73 let args = self.args();
74
75 let mut cmd = ProcessCommand::new(&shell);
76 for arg in args {
77 cmd.arg(arg);
78 }
79
80 cmd
81 }
82}
83
84impl From<Shell> for ProcessCommand {
85 fn from(shell: Shell) -> Self {
86 shell.proc()
87 }
88}
89
90impl ShellArgs {
91 pub fn cmd(&self) -> String {
92 self.command.clone()
93 }
94
95 pub fn shell_args(&self) -> Vec<String> {
96 let command = self.command.clone();
97 let args = self.args.clone().unwrap_or_default();
98 let Some(eval_flag) = shell_eval_flag(&command) else {
99 return args;
100 };
101
102 if args.iter().any(|arg| arg.eq_ignore_ascii_case(eval_flag)) {
103 return args;
104 }
105
106 let mut args = args;
107 args.push(eval_flag.to_string());
108 args
109 }
110}
111
112fn default_shell_command() -> &'static str {
113 if cfg!(windows) {
114 "cmd"
115 } else {
116 "sh"
117 }
118}
119
120fn shell_eval_flag(command: &str) -> Option<&'static str> {
121 let shell = command
122 .rsplit(['/', '\\'])
123 .next()
124 .unwrap_or(command)
125 .to_ascii_lowercase();
126
127 match shell.as_str() {
128 "sh" | "bash" | "zsh" | "fish" => Some("-c"),
129 "cmd" | "cmd.exe" => Some("/C"),
130 "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => Some("-Command"),
131 _ => None,
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::{
138 default_shell_command,
139 Shell,
140 ShellArgs,
141 };
142
143 #[test]
144 fn shell_default_matches_platform() {
145 let shell = Shell::default();
146 assert_eq!(shell.cmd(), default_shell_command().to_string());
147 }
148
149 #[test]
150 fn posix_shell_adds_dash_c() {
151 let args = ShellArgs {
152 command: "bash".to_string(),
153 args: None,
154 };
155
156 assert_eq!(args.shell_args(), vec!["-c".to_string()]);
157 }
158
159 #[test]
160 fn cmd_shell_adds_slash_c() {
161 let args = ShellArgs {
162 command: "cmd.exe".to_string(),
163 args: None,
164 };
165
166 assert_eq!(args.shell_args(), vec!["/C".to_string()]);
167 }
168
169 #[test]
170 fn powershell_adds_command_flag() {
171 let args = ShellArgs {
172 command: "pwsh".to_string(),
173 args: Some(vec!["-NoProfile".to_string()]),
174 };
175
176 assert_eq!(
177 args.shell_args(),
178 vec!["-NoProfile".to_string(), "-Command".to_string()]
179 );
180 }
181
182 #[test]
183 fn existing_eval_flag_is_preserved() {
184 let args = ShellArgs {
185 command: "cmd".to_string(),
186 args: Some(vec!["/C".to_string()]),
187 };
188
189 assert_eq!(args.shell_args(), vec!["/C".to_string()]);
190 }
191}