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