shell.rs

  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}