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