1#[cfg(any(windows, test))]
2use std::ffi::OsStr;
3#[cfg(any(windows, test))]
4use std::path::PathBuf;
5#[cfg(windows)]
6use std::sync::LazyLock;
7use std::{borrow::Cow, fmt, path::Path};
8
9#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum ShellKind {
11 #[default]
12 Posix,
13 Csh,
14 Tcsh,
15 Rc,
16 Fish,
17 PowerShell,
18 Nushell,
19 Cmd,
20 Xonsh,
21}
22
23pub fn get_system_shell() -> String {
24 if cfg!(windows) {
25 get_windows_system_shell()
26 } else {
27 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
28 }
29}
30
31pub fn get_default_system_shell() -> String {
32 if cfg!(windows) {
33 get_windows_system_shell()
34 } else {
35 "/bin/sh".to_string()
36 }
37}
38
39pub fn get_default_system_shell_preferring_bash() -> String {
41 #[cfg(windows)]
42 {
43 get_windows_git_bash().unwrap_or_else(|| get_windows_system_shell())
44 }
45
46 #[cfg(not(windows))]
47 {
48 "/bin/sh".to_string()
49 }
50}
51
52#[cfg(any(windows, test))]
54fn find_bash_in_installation(install_root: &Path) -> Option<PathBuf> {
55 if !install_root.join("git-bash.exe").is_file() {
56 return None;
57 }
58 let bash = install_root.join("bin").join("bash.exe");
59 bash.is_file().then_some(bash)
60}
61
62#[cfg(any(windows, test))]
68fn find_bash_from_git_binary(git: &Path) -> Option<PathBuf> {
69 let binary_directory = git.parent()?;
70 let parent = binary_directory.parent()?;
71 find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?))
72}
73
74#[cfg(any(windows, test))]
75fn find_bash_using_git_install(
76 git_install_root: Option<PathBuf>,
77 git_binary: Option<&Path>,
78) -> Option<PathBuf> {
79 if let Some(bash) = git_install_root.and_then(|path| find_bash_in_installation(&path)) {
80 return Some(bash);
81 }
82 find_bash_from_git_binary(git_binary?)
83}
84
85#[cfg(windows)]
86pub fn get_windows_git_bash() -> Option<String> {
87 fn find_bash_in_git() -> Option<PathBuf> {
88 let git = which::which("git").ok();
89 find_bash_using_git_install(
90 std::env::var_os("GIT_INSTALL_ROOT").map(PathBuf::from),
91 git.as_deref(),
92 )
93 }
94
95 static GIT_BASH: LazyLock<Option<String>> = LazyLock::new(|| {
96 let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned());
97 if let Some(ref path) = bash {
98 log::info!("Found bash at {}", path);
99 }
100 bash
101 });
102
103 (*GIT_BASH).clone()
104}
105
106#[cfg(any(windows, test))]
111fn scoop_install_root(scoop: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
112 scoop
113 .filter(|value| !value.is_empty())
114 .map(PathBuf::from)
115 .or_else(|| userprofile.map(|home| PathBuf::from(home).join("scoop")))
116}
117
118#[cfg(windows)]
119pub fn get_powershell() -> Option<String> {
120 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
121 #[cfg(target_pointer_width = "64")]
122 let env_var = if find_alternate {
123 "ProgramFiles(x86)"
124 } else {
125 "ProgramFiles"
126 };
127
128 #[cfg(target_pointer_width = "32")]
129 let env_var = if find_alternate {
130 "ProgramW6432"
131 } else {
132 "ProgramFiles"
133 };
134
135 let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
136 install_base_dir
137 .read_dir()
138 .ok()?
139 .filter_map(Result::ok)
140 .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
141 .filter_map(|entry| {
142 let dir_name = entry.file_name();
143 let dir_name = dir_name.to_string_lossy();
144
145 let version = if find_preview {
146 let dash_index = dir_name.find('-')?;
147 if &dir_name[dash_index + 1..] != "preview" {
148 return None;
149 };
150 dir_name[..dash_index].parse::<u32>().ok()?
151 } else {
152 dir_name.parse::<u32>().ok()?
153 };
154
155 let exe_path = entry.path().join("pwsh.exe");
156 if exe_path.is_file() {
157 Some((version, exe_path))
158 } else {
159 None
160 }
161 })
162 .max_by_key(|(version, _)| *version)
163 .map(|(_, path)| path)
164 }
165
166 fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
167 let msix_app_dir =
168 PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
169 let package_family_name = if find_preview {
170 "Microsoft.PowerShellPreview_8wekyb3d8bbwe"
171 } else {
172 "Microsoft.PowerShell_8wekyb3d8bbwe"
173 };
174 let pwsh_exe = msix_app_dir.join(package_family_name).join("pwsh.exe");
175 pwsh_exe.exists().then_some(pwsh_exe)
176 }
177
178 fn find_pwsh_in_scoop() -> Option<PathBuf> {
179 let scoop = std::env::var_os("SCOOP");
182 let userprofile = std::env::var_os("USERPROFILE");
183 let scoop_dir = scoop_install_root(scoop.as_deref(), userprofile.as_deref())?;
184 let pwsh_exe = scoop_dir.join("shims").join("pwsh.exe");
185 pwsh_exe.is_file().then_some(pwsh_exe)
186 }
187
188 fn find_pwsh_in_dotnet_tools() -> Option<PathBuf> {
189 let pwsh_exe =
190 PathBuf::from(std::env::var_os("USERPROFILE")?).join(".dotnet\\tools\\pwsh.exe");
191 pwsh_exe.is_file().then_some(pwsh_exe)
192 }
193
194 fn find_windows_powershell() -> Option<PathBuf> {
195 let system_root = PathBuf::from(std::env::var_os("SystemRoot")?);
196 let powershell = system_root.join("System32\\WindowsPowerShell\\v1.0\\powershell.exe");
197 powershell.is_file().then_some(powershell)
198 }
199
200 static POWERSHELL: LazyLock<Option<String>> = LazyLock::new(|| {
201 let locations = [
202 || find_pwsh_in_programfiles(false, false),
203 || find_pwsh_in_programfiles(true, false),
204 || find_pwsh_in_msix(false),
205 || find_pwsh_in_programfiles(false, true),
206 || find_pwsh_in_msix(true),
207 || find_pwsh_in_programfiles(true, true),
208 || find_pwsh_in_scoop(),
209 || find_pwsh_in_dotnet_tools(),
210 || which::which_global("pwsh.exe").ok(),
211 || which::which_global("powershell.exe").ok(),
212 || find_windows_powershell(),
213 ];
214
215 locations
216 .into_iter()
217 .find_map(|f| f())
218 .map(|p| p.to_string_lossy().trim().to_owned())
219 .inspect(|shell| log::info!("Found powershell in: {}", shell))
220 });
221
222 (*POWERSHELL).clone()
223}
224
225#[cfg(windows)]
226pub fn get_windows_system_shell() -> String {
227 static CMD: LazyLock<String> = LazyLock::new(|| {
228 log::warn!("Powershell not found, falling back to `cmd`");
229 let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
230 PathBuf::from(system_root)
231 .join("System32\\cmd.exe")
232 .to_string_lossy()
233 .into_owned()
234 });
235 get_powershell().unwrap_or_else(|| (*CMD).clone())
236}
237
238#[cfg(not(windows))]
239pub fn get_windows_system_shell() -> String {
240 "cmd.exe".to_string()
241}
242
243impl fmt::Display for ShellKind {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 match self {
246 ShellKind::Posix => write!(f, "sh"),
247 ShellKind::Csh => write!(f, "csh"),
248 ShellKind::Tcsh => write!(f, "tcsh"),
249 ShellKind::Fish => write!(f, "fish"),
250 ShellKind::PowerShell => write!(f, "powershell"),
251 ShellKind::Nushell => write!(f, "nu"),
252 ShellKind::Cmd => write!(f, "cmd"),
253 ShellKind::Rc => write!(f, "rc"),
254 ShellKind::Xonsh => write!(f, "xonsh"),
255 }
256 }
257}
258
259impl ShellKind {
260 pub fn system() -> Self {
261 Self::new(&get_system_shell(), cfg!(windows))
262 }
263
264 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
265 let program = program.as_ref();
266 let program = program
267 .file_stem()
268 .unwrap_or_else(|| program.as_os_str())
269 .to_string_lossy();
270
271 if program == "powershell" || program == "pwsh" {
272 ShellKind::PowerShell
273 } else if program == "cmd" {
274 ShellKind::Cmd
275 } else if program == "nu" {
276 ShellKind::Nushell
277 } else if program == "fish" {
278 ShellKind::Fish
279 } else if program == "csh" {
280 ShellKind::Csh
281 } else if program == "tcsh" {
282 ShellKind::Tcsh
283 } else if program == "rc" {
284 ShellKind::Rc
285 } else if program == "xonsh" {
286 ShellKind::Xonsh
287 } else if program == "sh" || program == "bash" {
288 ShellKind::Posix
289 } else {
290 if is_windows {
291 ShellKind::PowerShell
292 } else {
293 ShellKind::Posix
296 }
297 }
298 }
299
300 pub fn to_shell_variable(self, input: &str) -> String {
301 match self {
302 Self::PowerShell => Self::to_powershell_variable(input),
303 Self::Cmd => Self::to_cmd_variable(input),
304 Self::Posix => input.to_owned(),
305 Self::Fish => input.to_owned(),
306 Self::Csh => input.to_owned(),
307 Self::Tcsh => input.to_owned(),
308 Self::Rc => input.to_owned(),
309 Self::Nushell => Self::to_nushell_variable(input),
310 Self::Xonsh => input.to_owned(),
311 }
312 }
313
314 fn to_cmd_variable(input: &str) -> String {
315 if let Some(var_str) = input.strip_prefix("${") {
316 match var_str.strip_suffix('}') {
317 Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
318 format!("%{var_name}%")
319 }
320 _ => input.into(),
323 }
324 } else if let Some(var_str) = input.strip_prefix('$') {
325 format!("%{}%", var_str)
327 } else {
328 input.into()
330 }
331 }
332
333 fn to_powershell_variable(input: &str) -> String {
334 if let Some(var_str) = input.strip_prefix("${") {
335 match var_str.strip_suffix('}') {
336 Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => {
337 format!("$env:{var_name}")
338 }
339 _ => input.into(),
342 }
343 } else if let Some(var_str) = input.strip_prefix('$') {
344 format!("$env:{}", var_str)
346 } else {
347 input.into()
349 }
350 }
351
352 fn to_nushell_variable(input: &str) -> String {
353 let mut result = String::new();
354 let mut source = input;
355 let mut is_start = true;
356
357 loop {
358 match source.chars().next() {
359 None => return result,
360 Some('$') => {
361 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
362 is_start = false;
363 }
364 Some(_) => {
365 is_start = false;
366 let chunk_end = source.find('$').unwrap_or(source.len());
367 let (chunk, rest) = source.split_at(chunk_end);
368 result.push_str(chunk);
369 source = rest;
370 }
371 }
372 }
373 }
374
375 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
376 if source.starts_with("env.") {
377 text.push('$');
378 return source;
379 }
380
381 match source.chars().next() {
382 Some('{') => {
383 let source = &source[1..];
384 if let Some(end) = source.find('}') {
385 let var_name = &source[..end];
386 if !var_name.is_empty() {
387 if !is_start {
388 text.push_str("(");
389 }
390 text.push_str("$env.");
391 text.push_str(var_name);
392 if !is_start {
393 text.push_str(")");
394 }
395 &source[end + 1..]
396 } else {
397 text.push_str("${}");
398 &source[end + 1..]
399 }
400 } else {
401 text.push_str("${");
402 source
403 }
404 }
405 Some(c) if c.is_alphabetic() || c == '_' => {
406 let end = source
407 .find(|c: char| !c.is_alphanumeric() && c != '_')
408 .unwrap_or(source.len());
409 let var_name = &source[..end];
410 if !is_start {
411 text.push_str("(");
412 }
413 text.push_str("$env.");
414 text.push_str(var_name);
415 if !is_start {
416 text.push_str(")");
417 }
418 &source[end..]
419 }
420 _ => {
421 text.push('$');
422 source
423 }
424 }
425 }
426
427 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
428 match self {
429 ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
430 ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
431 ShellKind::Posix
432 | ShellKind::Nushell
433 | ShellKind::Fish
434 | ShellKind::Csh
435 | ShellKind::Tcsh
436 | ShellKind::Rc
437 | ShellKind::Xonsh => interactive
438 .then(|| "-i".to_owned())
439 .into_iter()
440 .chain(["-c".to_owned(), combined_command])
441 .collect(),
442 }
443 }
444
445 pub const fn command_prefix(&self) -> Option<char> {
446 match self {
447 ShellKind::PowerShell => Some('&'),
448 ShellKind::Nushell => Some('^'),
449 _ => None,
450 }
451 }
452
453 pub const fn sequential_commands_separator(&self) -> char {
454 match self {
455 ShellKind::Cmd => '&',
456 _ => ';',
457 }
458 }
459
460 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
461 shlex::try_quote(arg).ok().map(|arg| match self {
462 ShellKind::PowerShell => Cow::Owned(arg.replace("\\\"", "`\"")),
466 _ => arg,
467 })
468 }
469
470 pub const fn activate_keyword(&self) -> &'static str {
471 match self {
472 ShellKind::Cmd => "",
473 ShellKind::Nushell => "overlay use",
474 ShellKind::PowerShell => ".",
475 ShellKind::Fish => "source",
476 ShellKind::Csh => "source",
477 ShellKind::Tcsh => "source",
478 ShellKind::Posix | ShellKind::Rc => "source",
479 ShellKind::Xonsh => "source",
480 }
481 }
482
483 pub const fn clear_screen_command(&self) -> &'static str {
484 match self {
485 ShellKind::Cmd => "cls",
486 _ => "clear",
487 }
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 fn git_for_windows_layout() -> tempfile::TempDir {
496 let dir = tempfile::tempdir().unwrap();
497 let bin = dir.path().join("bin");
498 std::fs::create_dir_all(&bin).unwrap();
499 std::fs::write(dir.path().join("git-bash.exe"), []).unwrap();
500 std::fs::write(bin.join("bash.exe"), []).unwrap();
501 dir
502 }
503
504 #[test]
505 fn test_to_shell_variable() {
506 assert_eq!(
507 ShellKind::PowerShell.to_shell_variable("${FOO}"),
508 "$env:FOO"
509 );
510 assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%");
511 assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO");
512 assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}");
513
514 assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO");
515 assert_eq!(
516 ShellKind::PowerShell.to_shell_variable("${日本}"),
517 "$env:日本"
518 );
519 assert_eq!(
520 ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"),
521 "${FOO:-bar}"
522 );
523 }
524
525 #[test]
526 fn test_to_shell_variable_malformed_is_passed_through() {
527 for input in ["${", "${FOO", "${café", "${}", "${日本"] {
528 for shell_kind in [ShellKind::PowerShell, ShellKind::Cmd, ShellKind::Nushell] {
529 assert_eq!(shell_kind.to_shell_variable(input), input);
530 }
531 }
532 }
533
534 #[test]
535 fn git_bash_requires_git_bash_exe_and_bin_bash() {
536 let dir = tempfile::tempdir().unwrap();
537 let bin = dir.path().join("bin");
538 std::fs::create_dir_all(&bin).unwrap();
539 std::fs::write(bin.join("bash.exe"), []).unwrap();
540 assert!(find_bash_in_installation(dir.path()).is_none());
541
542 std::fs::write(dir.path().join("git-bash.exe"), []).unwrap();
543 assert_eq!(
544 find_bash_in_installation(dir.path()).as_deref(),
545 Some(bin.join("bash.exe").as_path())
546 );
547 }
548
549 #[test]
550 fn git_bash_from_cmd_git_uses_install_root() {
551 let install = git_for_windows_layout();
552 let cmd = install.path().join("cmd");
553 std::fs::create_dir_all(&cmd).unwrap();
554 let git = cmd.join("git.exe");
555 std::fs::write(&git, []).unwrap();
556 assert_eq!(
557 find_bash_from_git_binary(&git).unwrap(),
558 install.path().join("bin").join("bash.exe")
559 );
560 }
561
562 #[test]
563 fn git_bash_from_mingw64_bin_walks_up_to_install_root() {
564 let install = git_for_windows_layout();
565 let mingw_bin = install.path().join("mingw64").join("bin");
566 std::fs::create_dir_all(&mingw_bin).unwrap();
567 let git = mingw_bin.join("git.exe");
568 std::fs::write(&git, []).unwrap();
569 assert_eq!(
570 find_bash_from_git_binary(&git).unwrap(),
571 install.path().join("bin").join("bash.exe")
572 );
573 }
574
575 #[test]
576 fn git_install_root_takes_precedence_over_git_binary() {
577 let preferred = git_for_windows_layout();
578 let other = git_for_windows_layout();
579 let git = other.path().join("cmd").join("git.exe");
580 std::fs::create_dir_all(git.parent().unwrap()).unwrap();
581 std::fs::write(&git, []).unwrap();
582 assert_eq!(
583 find_bash_using_git_install(Some(preferred.path().to_path_buf()), Some(&git)).unwrap(),
584 preferred.path().join("bin").join("bash.exe")
585 );
586 }
587
588 #[test]
589 fn empty_scoop_falls_back_to_userprofile() {
590 assert_eq!(
591 scoop_install_root(Some(OsStr::new("")), Some(OsStr::new(r"C:\Users\someone")))
592 .unwrap(),
593 PathBuf::from(r"C:\Users\someone").join("scoop")
594 );
595 }
596
597 #[test]
598 fn scoop_env_overrides_userprofile_when_non_empty() {
599 assert_eq!(
600 scoop_install_root(
601 Some(OsStr::new(r"D:\Apps\scoop")),
602 Some(OsStr::new(r"C:\Users\someone"))
603 )
604 .unwrap(),
605 PathBuf::from(r"D:\Apps\scoop")
606 );
607 }
608
609 #[test]
610 fn scoop_root_is_none_when_scoop_and_userprofile_are_missing() {
611 assert!(scoop_install_root(None, None).is_none());
612 assert!(scoop_install_root(Some(OsStr::new("")), None).is_none());
613 }
614
615 #[cfg(not(windows))]
616 #[test]
617 fn unix_stubs_for_windows_shell_discovery() {
618 assert_eq!(get_windows_system_shell(), "cmd.exe");
619 assert_eq!(get_default_system_shell_preferring_bash(), "/bin/sh");
620 assert_eq!(get_default_system_shell(), "/bin/sh");
621 }
622}