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