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