Skip to main content

par_term/
shell_detection.rs

1//! Platform-aware shell detection for profile shell selection.
2//!
3//! Discovers available shells on the host OS and caches the results.
4
5use std::path::Path;
6use std::sync::OnceLock;
7
8/// Information about a detected shell.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ShellInfo {
11    /// Human-readable display name (e.g. "zsh", "bash", "PowerShell")
12    pub name: String,
13    /// Absolute path to the shell binary
14    pub path: String,
15}
16
17impl ShellInfo {
18    /// Create a new `ShellInfo`.
19    pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
20        Self {
21            name: name.into(),
22            path: path.into(),
23        }
24    }
25}
26
27impl std::fmt::Display for ShellInfo {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(f, "{} ({})", self.name, self.path)
30    }
31}
32
33/// Cached list of detected shells.
34static DETECTED_SHELLS: OnceLock<Vec<ShellInfo>> = OnceLock::new();
35
36/// Get the list of available shells on the current system.
37///
38/// Results are cached after the first call. The list is sorted with common
39/// shells first and always includes shells that actually exist on disk.
40pub fn detected_shells() -> &'static [ShellInfo] {
41    DETECTED_SHELLS.get_or_init(detect_shells)
42}
43
44/// Detect available shells on Unix/macOS by parsing `/etc/shells`.
45#[cfg(not(target_os = "windows"))]
46fn detect_shells() -> Vec<ShellInfo> {
47    let mut shells = Vec::new();
48    let mut seen_paths = std::collections::HashSet::new();
49
50    // Parse /etc/shells (standard on Unix/macOS)
51    if let Ok(contents) = std::fs::read_to_string("/etc/shells") {
52        for line in contents.lines() {
53            let line = line.trim();
54            // Skip comments and empty lines
55            if line.is_empty() || line.starts_with('#') {
56                continue;
57            }
58            if Path::new(line).exists() && seen_paths.insert(line.to_string()) {
59                let name = Path::new(line)
60                    .file_name()
61                    .map(|n| n.to_string_lossy().to_string())
62                    .unwrap_or_else(|| line.to_string());
63                shells.push(ShellInfo::new(name, line));
64            }
65        }
66    }
67
68    // Ensure current $SHELL is in the list
69    if let Ok(current_shell) = std::env::var("SHELL")
70        && !current_shell.is_empty()
71        && Path::new(&current_shell).exists()
72        && seen_paths.insert(current_shell.clone())
73    {
74        let name = Path::new(&current_shell)
75            .file_name()
76            .map(|n| n.to_string_lossy().to_string())
77            .unwrap_or_else(|| current_shell.clone());
78        shells.insert(0, ShellInfo::new(name, &current_shell));
79    }
80
81    // Check for common shells not listed in /etc/shells
82    // (e.g. Homebrew-installed pwsh, fish, nushell)
83    let extra_shells: &[(&str, &[&str])] = &[
84        (
85            "pwsh",
86            &[
87                "/opt/homebrew/bin/pwsh",
88                "/usr/local/bin/pwsh",
89                "/usr/bin/pwsh",
90            ],
91        ),
92        (
93            "fish",
94            &[
95                "/opt/homebrew/bin/fish",
96                "/usr/local/bin/fish",
97                "/usr/bin/fish",
98            ],
99        ),
100        (
101            "nu",
102            &["/opt/homebrew/bin/nu", "/usr/local/bin/nu", "/usr/bin/nu"],
103        ),
104        (
105            "elvish",
106            &[
107                "/opt/homebrew/bin/elvish",
108                "/usr/local/bin/elvish",
109                "/usr/bin/elvish",
110            ],
111        ),
112    ];
113    for (name, paths) in extra_shells {
114        for path in *paths {
115            if Path::new(path).exists() && seen_paths.insert((*path).to_string()) {
116                shells.push(ShellInfo::new(*name, *path));
117                break; // Only add first found path for each shell
118            }
119        }
120    }
121
122    // If nothing found, provide reasonable fallbacks
123    if shells.is_empty() {
124        for path in ["/bin/bash", "/bin/sh"] {
125            if Path::new(path).exists() {
126                let name = Path::new(path)
127                    .file_name()
128                    .unwrap()
129                    .to_string_lossy()
130                    .to_string();
131                shells.push(ShellInfo::new(name, path));
132            }
133        }
134    }
135
136    shells
137}
138
139/// Detect available shells on Windows.
140#[cfg(target_os = "windows")]
141fn detect_shells() -> Vec<ShellInfo> {
142    let mut shells = Vec::new();
143
144    // PowerShell 7+ (pwsh)
145    if let Ok(output) = std::process::Command::new("where").arg("pwsh.exe").output() {
146        if output.status.success() {
147            if let Ok(path) = String::from_utf8(output.stdout) {
148                let path = path.lines().next().unwrap_or("").trim();
149                if !path.is_empty() && Path::new(path).exists() {
150                    shells.push(ShellInfo::new("PowerShell 7", path));
151                }
152            }
153        }
154    }
155
156    // Windows PowerShell (5.1)
157    let ps_path = r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe";
158    if Path::new(ps_path).exists() {
159        shells.push(ShellInfo::new("Windows PowerShell", ps_path));
160    }
161
162    // Command Prompt
163    let cmd_path = r"C:\Windows\System32\cmd.exe";
164    if Path::new(cmd_path).exists() {
165        shells.push(ShellInfo::new("Command Prompt", cmd_path));
166    }
167
168    // Git Bash
169    let git_bash_paths = [
170        r"C:\Program Files\Git\bin\bash.exe",
171        r"C:\Program Files (x86)\Git\bin\bash.exe",
172    ];
173    for path in &git_bash_paths {
174        if Path::new(path).exists() {
175            shells.push(ShellInfo::new("Git Bash", *path));
176            break;
177        }
178    }
179
180    // WSL (if available)
181    let wsl_path = r"C:\Windows\System32\wsl.exe";
182    if Path::new(wsl_path).exists() {
183        shells.push(ShellInfo::new("WSL", wsl_path));
184    }
185
186    // MSYS2
187    let msys2_path = r"C:\msys64\usr\bin\bash.exe";
188    if Path::new(msys2_path).exists() {
189        shells.push(ShellInfo::new("MSYS2 Bash", msys2_path));
190    }
191
192    // Cygwin
193    let cygwin_path = r"C:\cygwin64\bin\bash.exe";
194    if Path::new(cygwin_path).exists() {
195        shells.push(ShellInfo::new("Cygwin Bash", cygwin_path));
196    }
197
198    if shells.is_empty() {
199        // Ultimate fallback
200        shells.push(ShellInfo::new("PowerShell", "powershell.exe"));
201    }
202
203    shells
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_detected_shells_not_empty() {
212        let shells = detected_shells();
213        assert!(
214            !shells.is_empty(),
215            "Should detect at least one shell on any platform"
216        );
217    }
218
219    #[test]
220    fn test_detected_shells_have_valid_paths() {
221        let shells = detected_shells();
222        for shell in shells {
223            assert!(!shell.name.is_empty(), "Shell name should not be empty");
224            assert!(!shell.path.is_empty(), "Shell path should not be empty");
225        }
226    }
227
228    #[test]
229    fn test_detected_shells_cached() {
230        let first = detected_shells();
231        let second = detected_shells();
232        // Same pointer means cached
233        assert!(std::ptr::eq(first, second));
234    }
235
236    #[test]
237    fn test_shell_info_display() {
238        let info = ShellInfo::new("bash", "/bin/bash");
239        assert_eq!(info.to_string(), "bash (/bin/bash)");
240    }
241
242    #[cfg(not(target_os = "windows"))]
243    #[test]
244    fn test_unix_shells_exist_on_disk() {
245        let shells = detected_shells();
246        for shell in shells {
247            assert!(
248                Path::new(&shell.path).exists(),
249                "Shell path should exist: {}",
250                shell.path
251            );
252        }
253    }
254}