Skip to main content

codex_shell_command/
shell_detect.rs

1use std::path::PathBuf;
2
3use serde::Deserialize;
4use serde::Serialize;
5
6#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize)]
7pub enum ShellType {
8    Zsh,
9    Bash,
10    PowerShell,
11    Sh,
12    Cmd,
13}
14
15impl ShellType {
16    pub fn name(self) -> &'static str {
17        match self {
18            Self::Zsh => "zsh",
19            Self::Bash => "bash",
20            Self::PowerShell => "powershell",
21            Self::Sh => "sh",
22            Self::Cmd => "cmd",
23        }
24    }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct DetectedShell {
29    pub shell_type: ShellType,
30    pub shell_path: PathBuf,
31}
32
33impl DetectedShell {
34    pub fn name(&self) -> &'static str {
35        self.shell_type.name()
36    }
37}
38
39pub fn detect_shell_type(shell_path: impl AsRef<std::path::Path>) -> Option<ShellType> {
40    let shell_path = shell_path.as_ref();
41    match shell_path.as_os_str().to_str() {
42        Some("zsh") => Some(ShellType::Zsh),
43        Some("sh") => Some(ShellType::Sh),
44        Some("cmd") => Some(ShellType::Cmd),
45        Some("bash") => Some(ShellType::Bash),
46        Some("pwsh") => Some(ShellType::PowerShell),
47        Some("powershell") => Some(ShellType::PowerShell),
48        _ => {
49            let shell_name = shell_path.file_stem();
50            if let Some(shell_name) = shell_name {
51                let shell_name_path = std::path::Path::new(shell_name);
52                if shell_name_path != shell_path {
53                    return detect_shell_type(shell_name_path);
54                }
55            }
56            None
57        }
58    }
59}
60
61#[cfg(unix)]
62fn get_user_shell_path() -> Option<PathBuf> {
63    let uid = unsafe { libc::getuid() };
64    use std::ffi::CStr;
65    use std::mem::MaybeUninit;
66    use std::ptr;
67
68    let mut passwd = MaybeUninit::<libc::passwd>::uninit();
69
70    // We cannot use getpwuid here: it returns pointers into libc-managed
71    // storage, which is not safe to read concurrently on all targets (the musl
72    // static build used by the CLI can segfault when parallel callers race on
73    // that buffer). getpwuid_r keeps the passwd data in caller-owned memory.
74    let suggested_buffer_len = unsafe { libc::sysconf(libc::_SC_GETPW_R_SIZE_MAX) };
75    let buffer_len = usize::try_from(suggested_buffer_len)
76        .ok()
77        .filter(|len| *len > 0)
78        .unwrap_or(1024);
79    let mut buffer = vec![0; buffer_len];
80
81    loop {
82        let mut result = ptr::null_mut();
83        let status = unsafe {
84            libc::getpwuid_r(
85                uid,
86                passwd.as_mut_ptr(),
87                buffer.as_mut_ptr().cast(),
88                buffer.len(),
89                &mut result,
90            )
91        };
92
93        if status == 0 {
94            if result.is_null() {
95                return None;
96            }
97
98            let passwd = unsafe { passwd.assume_init_ref() };
99            if passwd.pw_shell.is_null() {
100                return None;
101            }
102
103            let shell_path = unsafe { CStr::from_ptr(passwd.pw_shell) }
104                .to_string_lossy()
105                .into_owned();
106            return Some(PathBuf::from(shell_path));
107        }
108
109        if status != libc::ERANGE {
110            return None;
111        }
112
113        // Retry with a larger buffer until libc can materialize the passwd entry.
114        let new_len = buffer.len().checked_mul(2)?;
115        if new_len > 1024 * 1024 {
116            return None;
117        }
118        buffer.resize(new_len, 0);
119    }
120}
121
122#[cfg(not(unix))]
123fn get_user_shell_path() -> Option<PathBuf> {
124    None
125}
126
127fn file_exists(path: &std::path::Path) -> Option<PathBuf> {
128    if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_file()) {
129        Some(PathBuf::from(path))
130    } else {
131        None
132    }
133}
134
135fn get_shell_path(
136    shell_type: ShellType,
137    provided_path: Option<&PathBuf>,
138    binary_name: &str,
139    fallback_paths: &[&str],
140) -> Option<PathBuf> {
141    if let Some(path) = provided_path.and_then(|path| file_exists(path)) {
142        return Some(path);
143    }
144
145    let default_shell_path = get_user_shell_path();
146    if let Some(default_shell_path) = default_shell_path
147        && detect_shell_type(&default_shell_path) == Some(shell_type)
148        && file_exists(&default_shell_path).is_some()
149    {
150        return Some(default_shell_path);
151    }
152
153    if let Ok(path) = which::which(binary_name) {
154        return Some(path);
155    }
156
157    for path in fallback_paths {
158        if let Some(path) = file_exists(std::path::Path::new(path)) {
159            return Some(path);
160        }
161    }
162
163    None
164}
165
166const ZSH_FALLBACK_PATHS: &[&str] = &["/bin/zsh"];
167
168fn get_zsh_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
169    let shell_path = get_shell_path(ShellType::Zsh, path, "zsh", ZSH_FALLBACK_PATHS);
170
171    shell_path.map(|shell_path| DetectedShell {
172        shell_type: ShellType::Zsh,
173        shell_path,
174    })
175}
176
177const BASH_FALLBACK_PATHS: &[&str] = &["/bin/bash", "/usr/bin/bash"];
178
179fn get_bash_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
180    let shell_path = get_shell_path(ShellType::Bash, path, "bash", BASH_FALLBACK_PATHS);
181
182    shell_path.map(|shell_path| DetectedShell {
183        shell_type: ShellType::Bash,
184        shell_path,
185    })
186}
187
188const SH_FALLBACK_PATHS: &[&str] = &["/bin/sh"];
189
190fn get_sh_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
191    let shell_path = get_shell_path(ShellType::Sh, path, "sh", SH_FALLBACK_PATHS);
192
193    shell_path.map(|shell_path| DetectedShell {
194        shell_type: ShellType::Sh,
195        shell_path,
196    })
197}
198
199// Note the `pwsh` and `powershell` fallback paths are where the respective
200// shells are commonly installed on GitHub Actions Windows runners, but may not
201// be present on all Windows machines:
202// https://docs.github.com/en/actions/tutorials/build-and-test-code/powershell
203
204#[cfg(windows)]
205const PWSH_FALLBACK_PATHS: &[&str] = &[r#"C:\Program Files\PowerShell\7\pwsh.exe"#];
206#[cfg(not(windows))]
207const PWSH_FALLBACK_PATHS: &[&str] = &["/usr/local/bin/pwsh"];
208
209#[cfg(windows)]
210const POWERSHELL_FALLBACK_PATHS: &[&str] =
211    &[r#"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"#];
212#[cfg(not(windows))]
213const POWERSHELL_FALLBACK_PATHS: &[&str] = &[];
214
215fn get_powershell_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
216    let shell_path = get_shell_path(ShellType::PowerShell, path, "pwsh", PWSH_FALLBACK_PATHS)
217        .or_else(|| {
218            get_shell_path(
219                ShellType::PowerShell,
220                path,
221                "powershell",
222                POWERSHELL_FALLBACK_PATHS,
223            )
224        });
225
226    shell_path.map(|shell_path| DetectedShell {
227        shell_type: ShellType::PowerShell,
228        shell_path,
229    })
230}
231
232fn get_cmd_shell(path: Option<&PathBuf>) -> Option<DetectedShell> {
233    let shell_path = get_shell_path(ShellType::Cmd, path, "cmd", &[]);
234
235    shell_path.map(|shell_path| DetectedShell {
236        shell_type: ShellType::Cmd,
237        shell_path,
238    })
239}
240
241pub fn ultimate_fallback_shell() -> DetectedShell {
242    if cfg!(windows) {
243        DetectedShell {
244            shell_type: ShellType::Cmd,
245            shell_path: PathBuf::from("cmd.exe"),
246        }
247    } else {
248        DetectedShell {
249            shell_type: ShellType::Sh,
250            shell_path: PathBuf::from("/bin/sh"),
251        }
252    }
253}
254
255pub fn get_shell_by_model_provided_path(shell_path: &PathBuf) -> DetectedShell {
256    detect_shell_type(shell_path)
257        .and_then(|shell_type| get_shell(shell_type, Some(shell_path)))
258        .unwrap_or_else(ultimate_fallback_shell)
259}
260
261pub fn get_shell(shell_type: ShellType, path: Option<&PathBuf>) -> Option<DetectedShell> {
262    match shell_type {
263        ShellType::Zsh => get_zsh_shell(path),
264        ShellType::Bash => get_bash_shell(path),
265        ShellType::PowerShell => get_powershell_shell(path),
266        ShellType::Sh => get_sh_shell(path),
267        ShellType::Cmd => get_cmd_shell(path),
268    }
269}
270
271pub fn default_user_shell() -> DetectedShell {
272    default_user_shell_from_path(get_user_shell_path())
273}
274
275pub fn default_user_shell_from_path(user_shell_path: Option<PathBuf>) -> DetectedShell {
276    if cfg!(windows) {
277        get_shell(ShellType::PowerShell, /*path*/ None).unwrap_or_else(ultimate_fallback_shell)
278    } else {
279        let user_default_shell = user_shell_path
280            .and_then(|shell| detect_shell_type(&shell))
281            .and_then(|shell_type| get_shell(shell_type, /*path*/ None));
282
283        let shell_with_fallback = if cfg!(target_os = "macos") {
284            user_default_shell
285                .or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
286                .or_else(|| get_shell(ShellType::Bash, /*path*/ None))
287        } else {
288            user_default_shell
289                .or_else(|| get_shell(ShellType::Bash, /*path*/ None))
290                .or_else(|| get_shell(ShellType::Zsh, /*path*/ None))
291        };
292
293        shell_with_fallback.unwrap_or_else(ultimate_fallback_shell)
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use pretty_assertions::assert_eq;
301
302    #[test]
303    fn test_detect_shell_type() {
304        assert_eq!(
305            detect_shell_type(PathBuf::from("zsh")),
306            Some(ShellType::Zsh)
307        );
308        assert_eq!(
309            detect_shell_type(PathBuf::from("bash")),
310            Some(ShellType::Bash)
311        );
312        assert_eq!(
313            detect_shell_type(PathBuf::from("pwsh")),
314            Some(ShellType::PowerShell)
315        );
316        assert_eq!(
317            detect_shell_type(PathBuf::from("powershell")),
318            Some(ShellType::PowerShell)
319        );
320        assert_eq!(detect_shell_type(PathBuf::from("fish")), None);
321        assert_eq!(detect_shell_type(PathBuf::from("other")), None);
322        assert_eq!(
323            detect_shell_type(PathBuf::from("/bin/zsh")),
324            Some(ShellType::Zsh)
325        );
326        assert_eq!(
327            detect_shell_type(PathBuf::from("/bin/bash")),
328            Some(ShellType::Bash)
329        );
330        assert_eq!(
331            detect_shell_type(PathBuf::from("/usr/bin/bash")),
332            Some(ShellType::Bash)
333        );
334        assert_eq!(
335            detect_shell_type(PathBuf::from("powershell.exe")),
336            Some(ShellType::PowerShell)
337        );
338        assert_eq!(
339            detect_shell_type(PathBuf::from(if cfg!(windows) {
340                "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
341            } else {
342                "/usr/local/bin/pwsh"
343            })),
344            Some(ShellType::PowerShell)
345        );
346        assert_eq!(
347            detect_shell_type(PathBuf::from("pwsh.exe")),
348            Some(ShellType::PowerShell)
349        );
350        assert_eq!(
351            detect_shell_type(PathBuf::from("/usr/local/bin/pwsh")),
352            Some(ShellType::PowerShell)
353        );
354        assert_eq!(
355            detect_shell_type(PathBuf::from("/bin/sh")),
356            Some(ShellType::Sh)
357        );
358        assert_eq!(detect_shell_type(PathBuf::from("sh")), Some(ShellType::Sh));
359        assert_eq!(
360            detect_shell_type(PathBuf::from("cmd")),
361            Some(ShellType::Cmd)
362        );
363        assert_eq!(
364            detect_shell_type(PathBuf::from("cmd.exe")),
365            Some(ShellType::Cmd)
366        );
367    }
368}