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