par_term/
shell_detection.rs1use std::path::Path;
6use std::sync::OnceLock;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ShellInfo {
11 pub name: String,
13 pub path: String,
15}
16
17impl ShellInfo {
18 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
33static DETECTED_SHELLS: OnceLock<Vec<ShellInfo>> = OnceLock::new();
35
36pub fn detected_shells() -> &'static [ShellInfo] {
41 DETECTED_SHELLS.get_or_init(detect_shells)
42}
43
44#[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 if let Ok(contents) = std::fs::read_to_string("/etc/shells") {
52 for line in contents.lines() {
53 let line = line.trim();
54 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 if let Ok(current_shell) = std::env::var("SHELL")
70 && !current_shell.is_empty()
71 && Path::new(¤t_shell).exists()
72 && seen_paths.insert(current_shell.clone())
73 {
74 let name = Path::new(¤t_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, ¤t_shell));
79 }
80
81 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; }
119 }
120 }
121
122 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#[cfg(target_os = "windows")]
141fn detect_shells() -> Vec<ShellInfo> {
142 let mut shells = Vec::new();
143
144 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 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 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 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 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 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 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 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 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}