1use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
2
3#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum ShellKind {
5 #[default]
6 Posix,
7 Csh,
8 Tcsh,
9 Rc,
10 Fish,
11 PowerShell,
12 Nushell,
13 Cmd,
14 Xonsh,
15}
16
17pub fn get_system_shell() -> String {
18 if cfg!(windows) {
19 get_windows_system_shell()
20 } else {
21 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
22 }
23}
24
25pub fn get_default_system_shell() -> String {
26 if cfg!(windows) {
27 get_windows_system_shell()
28 } else {
29 "/bin/sh".to_string()
30 }
31}
32
33/// Get the default system shell, preferring git-bash on Windows.
34pub fn get_default_system_shell_preferring_bash() -> String {
35 if cfg!(windows) {
36 get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
37 } else {
38 "/bin/sh".to_string()
39 }
40}
41
42pub fn get_windows_git_bash() -> Option<String> {
43 static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
44 // /path/to/git/cmd/git.exe/../../bin/bash.exe
45 let git = which::which("git").ok()?;
46 let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
47 if git_bash.is_file() {
48 Some(git_bash.to_string_lossy().to_string())
49 } else {
50 None
51 }
52 });
53
54 (*GIT_BASH).clone()
55}
56
57pub fn get_windows_system_shell() -> String {
58 use std::path::PathBuf;
59
60 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
61 #[cfg(target_pointer_width = "64")]
62 let env_var = if find_alternate {
63 "ProgramFiles(x86)"
64 } else {
65 "ProgramFiles"
66 };
67
68 #[cfg(target_pointer_width = "32")]
69 let env_var = if find_alternate {
70 "ProgramW6432"
71 } else {
72 "ProgramFiles"
73 };
74
75 let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
76 install_base_dir
77 .read_dir()
78 .ok()?
79 .filter_map(Result::ok)
80 .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
81 .filter_map(|entry| {
82 let dir_name = entry.file_name();
83 let dir_name = dir_name.to_string_lossy();
84
85 let version = if find_preview {
86 let dash_index = dir_name.find('-')?;
87 if &dir_name[dash_index + 1..] != "preview" {
88 return None;
89 };
90 dir_name[..dash_index].parse::<u32>().ok()?
91 } else {
92 dir_name.parse::<u32>().ok()?
93 };
94
95 let exe_path = entry.path().join("pwsh.exe");
96 if exe_path.exists() {
97 Some((version, exe_path))
98 } else {
99 None
100 }
101 })
102 .max_by_key(|(version, _)| *version)
103 .map(|(_, path)| path)
104 }
105
106 fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
107 let msix_app_dir =
108 PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
109 if !msix_app_dir.exists() {
110 return None;
111 }
112
113 let prefix = if find_preview {
114 "Microsoft.PowerShellPreview_"
115 } else {
116 "Microsoft.PowerShell_"
117 };
118 msix_app_dir
119 .read_dir()
120 .ok()?
121 .filter_map(|entry| {
122 let entry = entry.ok()?;
123 if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
124 return None;
125 }
126
127 if !entry.file_name().to_string_lossy().starts_with(prefix) {
128 return None;
129 }
130
131 let exe_path = entry.path().join("pwsh.exe");
132 exe_path.exists().then_some(exe_path)
133 })
134 .next()
135 }
136
137 fn find_pwsh_in_scoop() -> Option<PathBuf> {
138 let pwsh_exe =
139 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
140 pwsh_exe.exists().then_some(pwsh_exe)
141 }
142
143 static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
144 find_pwsh_in_programfiles(false, false)
145 .or_else(|| find_pwsh_in_programfiles(true, false))
146 .or_else(|| find_pwsh_in_msix(false))
147 .or_else(|| find_pwsh_in_programfiles(false, true))
148 .or_else(|| find_pwsh_in_msix(true))
149 .or_else(|| find_pwsh_in_programfiles(true, true))
150 .or_else(find_pwsh_in_scoop)
151 .map(|p| p.to_string_lossy().into_owned())
152 .unwrap_or("powershell.exe".to_string())
153 });
154
155 (*SYSTEM_SHELL).clone()
156}
157
158impl fmt::Display for ShellKind {
159 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160 match self {
161 ShellKind::Posix => write!(f, "sh"),
162 ShellKind::Csh => write!(f, "csh"),
163 ShellKind::Tcsh => write!(f, "tcsh"),
164 ShellKind::Fish => write!(f, "fish"),
165 ShellKind::PowerShell => write!(f, "powershell"),
166 ShellKind::Nushell => write!(f, "nu"),
167 ShellKind::Cmd => write!(f, "cmd"),
168 ShellKind::Rc => write!(f, "rc"),
169 ShellKind::Xonsh => write!(f, "xonsh"),
170 }
171 }
172}
173
174impl ShellKind {
175 pub fn system() -> Self {
176 Self::new(&get_system_shell(), cfg!(windows))
177 }
178
179 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
180 let program = program.as_ref();
181 let program = program
182 .file_stem()
183 .unwrap_or_else(|| program.as_os_str())
184 .to_string_lossy();
185
186 if program == "powershell" || program == "pwsh" {
187 ShellKind::PowerShell
188 } else if program == "cmd" {
189 ShellKind::Cmd
190 } else if program == "nu" {
191 ShellKind::Nushell
192 } else if program == "fish" {
193 ShellKind::Fish
194 } else if program == "csh" {
195 ShellKind::Csh
196 } else if program == "tcsh" {
197 ShellKind::Tcsh
198 } else if program == "rc" {
199 ShellKind::Rc
200 } else if program == "xonsh" {
201 ShellKind::Xonsh
202 } else if program == "sh" || program == "bash" {
203 ShellKind::Posix
204 } else {
205 if is_windows {
206 ShellKind::PowerShell
207 } else {
208 // Some other shell detected, the user might install and use a
209 // unix-like shell.
210 ShellKind::Posix
211 }
212 }
213 }
214
215 pub fn to_shell_variable(self, input: &str) -> String {
216 match self {
217 Self::PowerShell => Self::to_powershell_variable(input),
218 Self::Cmd => Self::to_cmd_variable(input),
219 Self::Posix => input.to_owned(),
220 Self::Fish => input.to_owned(),
221 Self::Csh => input.to_owned(),
222 Self::Tcsh => input.to_owned(),
223 Self::Rc => input.to_owned(),
224 Self::Nushell => Self::to_nushell_variable(input),
225 Self::Xonsh => input.to_owned(),
226 }
227 }
228
229 fn to_cmd_variable(input: &str) -> String {
230 if let Some(var_str) = input.strip_prefix("${") {
231 if var_str.find(':').is_none() {
232 // If the input starts with "${", remove the trailing "}"
233 format!("%{}%", &var_str[..var_str.len() - 1])
234 } else {
235 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
236 // which will result in the task failing to run in such cases.
237 input.into()
238 }
239 } else if let Some(var_str) = input.strip_prefix('$') {
240 // If the input starts with "$", directly append to "$env:"
241 format!("%{}%", var_str)
242 } else {
243 // If no prefix is found, return the input as is
244 input.into()
245 }
246 }
247
248 fn to_powershell_variable(input: &str) -> String {
249 if let Some(var_str) = input.strip_prefix("${") {
250 if var_str.find(':').is_none() {
251 // If the input starts with "${", remove the trailing "}"
252 format!("$env:{}", &var_str[..var_str.len() - 1])
253 } else {
254 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
255 // which will result in the task failing to run in such cases.
256 input.into()
257 }
258 } else if let Some(var_str) = input.strip_prefix('$') {
259 // If the input starts with "$", directly append to "$env:"
260 format!("$env:{}", var_str)
261 } else {
262 // If no prefix is found, return the input as is
263 input.into()
264 }
265 }
266
267 fn to_nushell_variable(input: &str) -> String {
268 let mut result = String::new();
269 let mut source = input;
270 let mut is_start = true;
271
272 loop {
273 match source.chars().next() {
274 None => return result,
275 Some('$') => {
276 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
277 is_start = false;
278 }
279 Some(_) => {
280 is_start = false;
281 let chunk_end = source.find('$').unwrap_or(source.len());
282 let (chunk, rest) = source.split_at(chunk_end);
283 result.push_str(chunk);
284 source = rest;
285 }
286 }
287 }
288 }
289
290 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
291 if source.starts_with("env.") {
292 text.push('$');
293 return source;
294 }
295
296 match source.chars().next() {
297 Some('{') => {
298 let source = &source[1..];
299 if let Some(end) = source.find('}') {
300 let var_name = &source[..end];
301 if !var_name.is_empty() {
302 if !is_start {
303 text.push_str("(");
304 }
305 text.push_str("$env.");
306 text.push_str(var_name);
307 if !is_start {
308 text.push_str(")");
309 }
310 &source[end + 1..]
311 } else {
312 text.push_str("${}");
313 &source[end + 1..]
314 }
315 } else {
316 text.push_str("${");
317 source
318 }
319 }
320 Some(c) if c.is_alphabetic() || c == '_' => {
321 let end = source
322 .find(|c: char| !c.is_alphanumeric() && c != '_')
323 .unwrap_or(source.len());
324 let var_name = &source[..end];
325 if !is_start {
326 text.push_str("(");
327 }
328 text.push_str("$env.");
329 text.push_str(var_name);
330 if !is_start {
331 text.push_str(")");
332 }
333 &source[end..]
334 }
335 _ => {
336 text.push('$');
337 source
338 }
339 }
340 }
341
342 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
343 match self {
344 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
345 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
346 ShellKind::Posix
347 | ShellKind::Nushell
348 | ShellKind::Fish
349 | ShellKind::Csh
350 | ShellKind::Tcsh
351 | ShellKind::Rc
352 | ShellKind::Xonsh => interactive
353 .then(|| "-i".to_owned())
354 .into_iter()
355 .chain(["-c".to_owned(), combined_command])
356 .collect(),
357 }
358 }
359
360 pub const fn command_prefix(&self) -> Option<char> {
361 match self {
362 ShellKind::PowerShell => Some('&'),
363 ShellKind::Nushell => Some('^'),
364 _ => None,
365 }
366 }
367
368 pub const fn sequential_commands_separator(&self) -> char {
369 match self {
370 ShellKind::Cmd => '&',
371 _ => ';',
372 }
373 }
374
375 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
376 shlex::try_quote(arg).ok().map(|arg| match self {
377 // If we are running in PowerShell, we want to take extra care when escaping strings.
378 // In particular, we want to escape strings with a backtick (`) rather than a backslash (\).
379 // TODO double escaping backslashes is not necessary in PowerShell and probably CMD
380 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
381 _ => arg,
382 })
383 }
384
385 pub const fn activate_keyword(&self) -> &'static str {
386 match self {
387 ShellKind::Cmd => "",
388 ShellKind::Nushell => "overlay use",
389 ShellKind::PowerShell => ".",
390 ShellKind::Fish => "source",
391 ShellKind::Csh => "source",
392 ShellKind::Tcsh => "source",
393 ShellKind::Posix | ShellKind::Rc => "source",
394 ShellKind::Xonsh => "source",
395 }
396 }
397
398 pub const fn clear_screen_command(&self) -> &'static str {
399 match self {
400 ShellKind::Cmd => "cls",
401 _ => "clear",
402 }
403 }
404}