shell.rs

  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}