shell.rs

  1use std::{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())
175    }
176
177    pub fn new(program: impl AsRef<Path>) -> Self {
178        let program = program.as_ref();
179        let Some(program) = program.file_stem().and_then(|s| s.to_str()) else {
180            return if cfg!(windows) {
181                ShellKind::PowerShell
182            } else {
183                ShellKind::Posix
184            };
185        };
186        if program == "powershell" || program == "pwsh" {
187            ShellKind::PowerShell
188        } else if program == "cmd" {
189            ShellKind::Cmd
190        } else if program == "nu" {
191            ShellKind::Nushell
192        } else if program == "fish" {
193            ShellKind::Fish
194        } else if program == "csh" {
195            ShellKind::Csh
196        } else if program == "tcsh" {
197            ShellKind::Tcsh
198        } else if program == "rc" {
199            ShellKind::Rc
200        } else if program == "sh" || program == "bash" {
201            ShellKind::Posix
202        } else {
203            if cfg!(windows) {
204                ShellKind::PowerShell
205            } else {
206                // Some other shell detected, the user might install and use a
207                // unix-like shell.
208                ShellKind::Posix
209            }
210        }
211    }
212
213    pub fn to_shell_variable(self, input: &str) -> String {
214        match self {
215            Self::PowerShell => Self::to_powershell_variable(input),
216            Self::Cmd => Self::to_cmd_variable(input),
217            Self::Posix => input.to_owned(),
218            Self::Fish => input.to_owned(),
219            Self::Csh => input.to_owned(),
220            Self::Tcsh => input.to_owned(),
221            Self::Rc => input.to_owned(),
222            Self::Nushell => Self::to_nushell_variable(input),
223        }
224    }
225
226    fn to_cmd_variable(input: &str) -> String {
227        if let Some(var_str) = input.strip_prefix("${") {
228            if var_str.find(':').is_none() {
229                // If the input starts with "${", remove the trailing "}"
230                format!("%{}%", &var_str[..var_str.len() - 1])
231            } else {
232                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
233                // which will result in the task failing to run in such cases.
234                input.into()
235            }
236        } else if let Some(var_str) = input.strip_prefix('$') {
237            // If the input starts with "$", directly append to "$env:"
238            format!("%{}%", var_str)
239        } else {
240            // If no prefix is found, return the input as is
241            input.into()
242        }
243    }
244    fn to_powershell_variable(input: &str) -> String {
245        if let Some(var_str) = input.strip_prefix("${") {
246            if var_str.find(':').is_none() {
247                // If the input starts with "${", remove the trailing "}"
248                format!("$env:{}", &var_str[..var_str.len() - 1])
249            } else {
250                // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
251                // which will result in the task failing to run in such cases.
252                input.into()
253            }
254        } else if let Some(var_str) = input.strip_prefix('$') {
255            // If the input starts with "$", directly append to "$env:"
256            format!("$env:{}", var_str)
257        } else {
258            // If no prefix is found, return the input as is
259            input.into()
260        }
261    }
262
263    fn to_nushell_variable(input: &str) -> String {
264        let mut result = String::new();
265        let mut source = input;
266        let mut is_start = true;
267
268        loop {
269            match source.chars().next() {
270                None => return result,
271                Some('$') => {
272                    source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
273                    is_start = false;
274                }
275                Some(_) => {
276                    is_start = false;
277                    let chunk_end = source.find('$').unwrap_or(source.len());
278                    let (chunk, rest) = source.split_at(chunk_end);
279                    result.push_str(chunk);
280                    source = rest;
281                }
282            }
283        }
284    }
285
286    fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
287        if source.starts_with("env.") {
288            text.push('$');
289            return source;
290        }
291
292        match source.chars().next() {
293            Some('{') => {
294                let source = &source[1..];
295                if let Some(end) = source.find('}') {
296                    let var_name = &source[..end];
297                    if !var_name.is_empty() {
298                        if !is_start {
299                            text.push_str("(");
300                        }
301                        text.push_str("$env.");
302                        text.push_str(var_name);
303                        if !is_start {
304                            text.push_str(")");
305                        }
306                        &source[end + 1..]
307                    } else {
308                        text.push_str("${}");
309                        &source[end + 1..]
310                    }
311                } else {
312                    text.push_str("${");
313                    source
314                }
315            }
316            Some(c) if c.is_alphabetic() || c == '_' => {
317                let end = source
318                    .find(|c: char| !c.is_alphanumeric() && c != '_')
319                    .unwrap_or(source.len());
320                let var_name = &source[..end];
321                if !is_start {
322                    text.push_str("(");
323                }
324                text.push_str("$env.");
325                text.push_str(var_name);
326                if !is_start {
327                    text.push_str(")");
328                }
329                &source[end..]
330            }
331            _ => {
332                text.push('$');
333                source
334            }
335        }
336    }
337
338    pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
339        match self {
340            ShellKind::PowerShell => vec!["-C".to_owned(), combined_command],
341            ShellKind::Cmd => vec!["/C".to_owned(), combined_command],
342            ShellKind::Posix
343            | ShellKind::Nushell
344            | ShellKind::Fish
345            | ShellKind::Csh
346            | ShellKind::Tcsh
347            | ShellKind::Rc => interactive
348                .then(|| "-i".to_owned())
349                .into_iter()
350                .chain(["-c".to_owned(), combined_command])
351                .collect(),
352        }
353    }
354
355    pub fn command_prefix(&self) -> Option<char> {
356        match self {
357            ShellKind::PowerShell => Some('&'),
358            ShellKind::Nushell => Some('^'),
359            _ => None,
360        }
361    }
362}