Skip to main content

retch_sysinfo/
shell.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Shell detection and version querying.
5
6use sysinfo::System;
7
8pub(crate) fn detect_shell(sys: &System) -> Option<String> {
9    let shell_env = std::env::var("SHELL").ok();
10
11    let (shell_path, shell_name) = if let Some(ref path_str) = shell_env {
12        let path = std::path::Path::new(path_str);
13        let name = path
14            .file_name()
15            .and_then(|n| n.to_str())
16            .unwrap_or(path_str)
17            .to_string();
18        (path_str.clone(), name)
19    } else {
20        let mut current_pid = sysinfo::get_current_pid().ok();
21        let mut detected: Option<(String, String)> = None;
22        let known_shells = [
23            "bash",
24            "zsh",
25            "fish",
26            "sh",
27            "dash",
28            "nu",
29            "elvish",
30            "tcsh",
31            "csh",
32            "ksh",
33            "powershell",
34            "pwsh",
35            "cmd",
36        ];
37
38        while let Some(pid) = current_pid {
39            if let Some(process) = sys.process(pid) {
40                let proc_name = process.name().to_string_lossy().to_string();
41                let proc_name_lower = proc_name.to_lowercase();
42                let clean_name = proc_name_lower
43                    .strip_suffix(".exe")
44                    .unwrap_or(&proc_name_lower)
45                    .to_string();
46                if known_shells.contains(&clean_name.as_str()) {
47                    detected = Some((proc_name.clone(), clean_name));
48                    break;
49                }
50                current_pid = process.parent();
51            } else {
52                break;
53            }
54        }
55
56        if let Some((orig_name, clean_name)) = detected {
57            (orig_name, clean_name)
58        } else {
59            #[cfg(target_os = "windows")]
60            {
61                if std::env::var("PSModulePath").is_ok() {
62                    ("powershell.exe".to_string(), "powershell".to_string())
63                } else {
64                    ("cmd.exe".to_string(), "cmd".to_string())
65                }
66            }
67            #[cfg(not(target_os = "windows"))]
68            {
69                ("sh".to_string(), "sh".to_string())
70            }
71        }
72    };
73
74    let shell_name_lower = shell_name.to_lowercase();
75    let shell_name_clean = shell_name_lower
76        .strip_suffix(".exe")
77        .unwrap_or(&shell_name_lower);
78
79    let version = detect_shell_version(&shell_path, shell_name_clean);
80
81    if let Some(ver) = version {
82        Some(format!("{} {}", shell_name_clean, ver))
83    } else {
84        Some(shell_name_clean.to_string())
85    }
86}
87
88fn detect_shell_version(shell_path: &str, shell_name: &str) -> Option<String> {
89    let args = match shell_name {
90        "powershell" => vec![
91            "-NoProfile",
92            "-Command",
93            "$PSVersionTable.PSVersion.ToString()",
94        ],
95        "elvish" => vec!["-version"],
96        _ => vec!["--version"],
97    };
98
99    let output = std::process::Command::new(shell_path)
100        .args(&args)
101        .output()
102        .or_else(|_| std::process::Command::new(shell_name).args(&args).output())
103        .ok()?;
104
105    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
106    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
107    let full_output = format!("{}\n{}", stdout, stderr);
108
109    parse_shell_version(shell_name, &full_output)
110}
111
112pub fn parse_shell_version(shell_name: &str, output: &str) -> Option<String> {
113    let output_trimmed = output.trim();
114    if output_trimmed.is_empty() {
115        return None;
116    }
117
118    match shell_name {
119        "bash" => {
120            if let Some(pos) = output.find("version ") {
121                let rest = &output[pos + 8..];
122                let ver = rest
123                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
124                    .next()
125                    .unwrap_or("");
126                if !ver.is_empty() {
127                    return Some(ver.to_string());
128                }
129            }
130        }
131        "zsh" => {
132            if let Some(pos) = output.find("zsh ") {
133                let rest = &output[pos + 4..];
134                let ver = rest
135                    .split(|c: char| c.is_whitespace() || c == '(')
136                    .next()
137                    .unwrap_or("");
138                if !ver.is_empty() {
139                    return Some(ver.to_string());
140                }
141            }
142        }
143        "fish" => {
144            if let Some(pos) = output.find("version ") {
145                let rest = &output[pos + 8..];
146                let ver = rest
147                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',')
148                    .next()
149                    .unwrap_or("");
150                if !ver.is_empty() {
151                    return Some(ver.to_string());
152                }
153            }
154        }
155        "nu" => {
156            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
157            if !ver.is_empty() {
158                return Some(ver.to_string());
159            }
160        }
161        "pwsh" => {
162            if let Some(pos) = output.find("PowerShell ") {
163                let rest = &output[pos + 11..];
164                let ver = rest.split_whitespace().next().unwrap_or("");
165                if !ver.is_empty() {
166                    return Some(ver.to_string());
167                }
168            }
169        }
170        "powershell" => {
171            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
172            if !ver.is_empty() {
173                return Some(ver.to_string());
174            }
175        }
176        "elvish" => {
177            let ver = output_trimmed.split_whitespace().next().unwrap_or("");
178            if !ver.is_empty() {
179                return Some(ver.to_string());
180            }
181        }
182        "tcsh" => {
183            if let Some(pos) = output.find("tcsh ") {
184                let rest = &output[pos + 5..];
185                let ver = rest.split_whitespace().next().unwrap_or("");
186                if !ver.is_empty() {
187                    return Some(ver.to_string());
188                }
189            }
190        }
191        _ => {
192            if let Some(pos) = output.to_lowercase().find("version ") {
193                let rest = &output[pos + 8..];
194                let ver = rest
195                    .split(|c: char| c.is_whitespace() || c == '(' || c == ',' || c == '-')
196                    .next()
197                    .unwrap_or("");
198                if !ver.is_empty() {
199                    return Some(ver.to_string());
200                }
201            }
202        }
203    }
204
205    None
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_parse_shell_version() {
214        let bash_out = "GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)\nCopyright (C) 2022 Free Software Foundation, Inc.";
215        assert_eq!(
216            parse_shell_version("bash", bash_out),
217            Some("5.2.15".to_string())
218        );
219
220        let zsh_out = "zsh 5.9 (x86_64-pc-linux-gnu)";
221        assert_eq!(parse_shell_version("zsh", zsh_out), Some("5.9".to_string()));
222
223        let fish_out = "fish, version 3.6.0";
224        assert_eq!(
225            parse_shell_version("fish", fish_out),
226            Some("3.6.0".to_string())
227        );
228
229        let nu_out = "0.93.0";
230        assert_eq!(
231            parse_shell_version("nu", nu_out),
232            Some("0.93.0".to_string())
233        );
234
235        let pwsh_out = "PowerShell 7.4.1";
236        assert_eq!(
237            parse_shell_version("pwsh", pwsh_out),
238            Some("7.4.1".to_string())
239        );
240
241        let powershell_out = "5.1.22621.2428";
242        assert_eq!(
243            parse_shell_version("powershell", powershell_out),
244            Some("5.1.22621.2428".to_string())
245        );
246
247        let elvish_out = "0.20.1";
248        assert_eq!(
249            parse_shell_version("elvish", elvish_out),
250            Some("0.20.1".to_string())
251        );
252
253        let tcsh_out = "tcsh 6.24.10 (Astron) 2023-04-20 (x86_64-amd-linux) options wide,nls,dl,al,kan,sm,color,filec";
254        assert_eq!(
255            parse_shell_version("tcsh", tcsh_out),
256            Some("6.24.10".to_string())
257        );
258
259        let custom_out = "CustomShell version 1.2.3-patch4";
260        assert_eq!(
261            parse_shell_version("custom", custom_out),
262            Some("1.2.3".to_string())
263        );
264    }
265}