Skip to main content

tui_test/shell/
mod.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::config::home_dir;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Shell {
10    Bash,
11    Powershell,
12    Pwsh,
13    Cmd,
14    Fish,
15    Zsh,
16    Xonsh,
17    Elvish,
18    Nushell,
19}
20
21impl Shell {
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            Shell::Bash => "bash",
25            Shell::Powershell => "powershell",
26            Shell::Pwsh => "pwsh",
27            Shell::Cmd => "cmd",
28            Shell::Fish => "fish",
29            Shell::Zsh => "zsh",
30            Shell::Xonsh => "xonsh",
31            Shell::Elvish => "elvish",
32            Shell::Nushell => "nushell",
33        }
34    }
35
36    /// The character used to submit a typed command line.
37    pub fn return_char(&self) -> &'static str {
38        match self {
39            Shell::Xonsh => "\n",
40            _ => "\r",
41        }
42    }
43}
44
45pub fn default_shell() -> Shell {
46    if cfg!(windows) {
47        Shell::Powershell
48    } else if cfg!(target_os = "macos") {
49        Shell::Zsh
50    } else {
51        Shell::Bash
52    }
53}
54
55pub fn scripts_dir() -> PathBuf {
56    home_dir().join("shell")
57}
58
59fn zdotdir() -> PathBuf {
60    home_dir().join("zsh")
61}
62
63/// Materialize the bundled shell-integration scripts into the home directory.
64pub fn write_integration_scripts() -> std::io::Result<()> {
65    let dir = scripts_dir();
66    std::fs::create_dir_all(&dir)?;
67    let files: &[(&str, &str)] = &[
68        (
69            "shellIntegration.bash",
70            include_str!("../../shell/shellIntegration.bash"),
71        ),
72        (
73            "shellIntegration.fish",
74            include_str!("../../shell/shellIntegration.fish"),
75        ),
76        (
77            "shellIntegration.ps1",
78            include_str!("../../shell/shellIntegration.ps1"),
79        ),
80        (
81            "shellIntegration.xsh",
82            include_str!("../../shell/shellIntegration.xsh"),
83        ),
84        (
85            "shellIntegration.elv",
86            include_str!("../../shell/shellIntegration.elv"),
87        ),
88        (
89            "shellIntegration.nu",
90            include_str!("../../shell/shellIntegration.nu"),
91        ),
92        (
93            "shellIntegration-rc.zsh",
94            include_str!("../../shell/shellIntegration-rc.zsh"),
95        ),
96        (
97            "shellIntegration-profile.zsh",
98            include_str!("../../shell/shellIntegration-profile.zsh"),
99        ),
100        (
101            "shellIntegration-env.zsh",
102            include_str!("../../shell/shellIntegration-env.zsh"),
103        ),
104        (
105            "shellIntegration-login.zsh",
106            include_str!("../../shell/shellIntegration-login.zsh"),
107        ),
108    ];
109    for (name, body) in files {
110        std::fs::write(dir.join(name), body)?;
111    }
112    Ok(())
113}
114
115fn setup_zsh_dotfiles() -> std::io::Result<()> {
116    let dir = zdotdir();
117    std::fs::create_dir_all(&dir)?;
118    let src = scripts_dir();
119    std::fs::copy(src.join("shellIntegration-rc.zsh"), dir.join(".zshrc"))?;
120    std::fs::copy(
121        src.join("shellIntegration-profile.zsh"),
122        dir.join(".zprofile"),
123    )?;
124    std::fs::copy(src.join("shellIntegration-env.zsh"), dir.join(".zshenv"))?;
125    std::fs::copy(src.join("shellIntegration-login.zsh"), dir.join(".zlogin"))?;
126    Ok(())
127}
128
129pub struct Launch {
130    pub target: String,
131    pub args: Vec<String>,
132    pub env: Vec<(String, String)>,
133}
134
135/// Compute how to launch a shell with integration wired in.
136pub fn shell_launch(shell: Shell) -> anyhow::Result<Launch> {
137    write_integration_scripts()?;
138    let dir = scripts_dir();
139    let mut env: Vec<(String, String)> = Vec::new();
140
141    let (target, args) = match shell {
142        Shell::Bash => {
143            let target = if cfg!(windows) {
144                git_bash_path()?
145            } else {
146                "bash".to_string()
147            };
148            (
149                target,
150                vec![
151                    "--init-file".to_string(),
152                    path_str(&dir.join("shellIntegration.bash")),
153                ],
154            )
155        }
156        Shell::Powershell | Shell::Pwsh => {
157            let exe = if matches!(shell, Shell::Powershell) {
158                "powershell"
159            } else {
160                "pwsh"
161            };
162            let target = windows_exe(exe);
163            let script = path_str(&dir.join("shellIntegration.ps1"));
164            (
165                target,
166                vec![
167                    "-NoLogo".to_string(),
168                    "-NoProfile".to_string(),
169                    "-noexit".to_string(),
170                    "-command".to_string(),
171                    format!(". \"{script}\""),
172                ],
173            )
174        }
175        Shell::Fish => {
176            let script = path_str(&dir.join("shellIntegration.fish"));
177            (
178                windows_exe("fish"),
179                vec![
180                    "--init-command".to_string(),
181                    format!(". {}", script.replace(' ', "\\ ")),
182                ],
183            )
184        }
185        Shell::Zsh => {
186            setup_zsh_dotfiles()?;
187            let user_zdotdir = std::env::var("ZDOTDIR")
188                .ok()
189                .or_else(|| dirs::home_dir().map(|p| path_str(&p)))
190                .unwrap_or_else(|| "~".to_string());
191            env.push(("ZDOTDIR".to_string(), path_str(&zdotdir())));
192            env.push(("USER_ZDOTDIR".to_string(), user_zdotdir));
193            (windows_exe("zsh"), vec![])
194        }
195        Shell::Cmd => {
196            env.push(("PROMPT".to_string(), "$G ".to_string()));
197            (
198                windows_exe("cmd"),
199                vec!["/k".to_string(), "cls".to_string()],
200            )
201        }
202        Shell::Xonsh => {
203            let python = which("python").unwrap_or_else(|| "python".to_string());
204            let mut args = vec!["-m".to_string(), "xonsh".to_string(), "--rc".to_string()];
205            args.push(path_str(&dir.join("shellIntegration.xsh")));
206            (python, args)
207        }
208        Shell::Elvish => (
209            windows_exe("elvish"),
210            vec![
211                "-rc".to_string(),
212                path_str(&dir.join("shellIntegration.elv")),
213            ],
214        ),
215        Shell::Nushell => {
216            let script = path_str(&dir.join("shellIntegration.nu")).replace('\\', "/");
217            (
218                windows_exe("nu"),
219                vec!["--execute".to_string(), format!("source '{script}'")],
220            )
221        }
222    };
223
224    Ok(Launch { target, args, env })
225}
226
227fn path_str(p: &Path) -> String {
228    p.to_string_lossy().into_owned()
229}
230
231fn windows_exe(name: &str) -> String {
232    if cfg!(windows) {
233        format!("{name}.exe")
234    } else {
235        name.to_string()
236    }
237}
238
239pub(crate) fn which(cmd: &str) -> Option<String> {
240    let path = std::env::var_os("PATH")?;
241    let exts: Vec<String> = if cfg!(windows) {
242        std::env::var("PATHEXT")
243            .unwrap_or_else(|_| ".EXE;.CMD;.BAT".to_string())
244            .split(';')
245            .map(|s| s.to_lowercase())
246            .collect()
247    } else {
248        vec![String::new()]
249    };
250    for dir in std::env::split_paths(&path) {
251        for ext in &exts {
252            let candidate = dir.join(format!("{cmd}{ext}"));
253            if is_executable(&candidate) {
254                return Some(candidate.to_string_lossy().into_owned());
255            }
256        }
257    }
258    None
259}
260
261fn is_executable(path: &Path) -> bool {
262    if !path.is_file() {
263        return false;
264    }
265    #[cfg(unix)]
266    {
267        use std::os::unix::fs::PermissionsExt;
268        path.metadata()
269            .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
270    }
271    #[cfg(not(unix))]
272    {
273        true
274    }
275}
276
277#[cfg(all(test, unix))]
278mod executable_tests {
279    use super::is_executable;
280    use std::os::unix::fs::PermissionsExt;
281
282    #[test]
283    fn non_executable_path_candidate_is_rejected() {
284        let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
285            .join("..")
286            .join("..")
287            .join("target")
288            .join("executable-tests");
289        std::fs::create_dir_all(&root).unwrap();
290        let path = root.join(format!("ffmpeg-{}", std::process::id()));
291        std::fs::write(&path, b"not executable").unwrap();
292        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
293
294        assert!(!is_executable(&path));
295
296        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
297        assert!(is_executable(&path));
298        std::fs::remove_file(path).unwrap();
299    }
300}
301
302fn git_bash_path() -> anyhow::Result<String> {
303    let mut dirs: Vec<PathBuf> = Vec::new();
304    if let Some(git) = which("git") {
305        if let Some(bin) = Path::new(&git).parent().and_then(|p| p.parent()) {
306            dirs.push(bin.to_path_buf());
307        }
308    }
309    for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(X86)"] {
310        if let Ok(v) = std::env::var(var) {
311            dirs.push(PathBuf::from(v));
312        }
313    }
314    if let Ok(local) = std::env::var("LocalAppData") {
315        dirs.push(PathBuf::from(format!("{local}\\Program")));
316    }
317    let mut candidates: Vec<PathBuf> = Vec::new();
318    for d in &dirs {
319        candidates.push(d.join("Git\\bin\\bash.exe"));
320        candidates.push(d.join("Git\\usr\\bin\\bash.exe"));
321        candidates.push(d.join("usr\\bin\\bash.exe"));
322    }
323    if let Ok(profile) = std::env::var("UserProfile") {
324        candidates.push(PathBuf::from(format!(
325            "{profile}\\scoop\\apps\\git\\current\\bin\\bash.exe"
326        )));
327    }
328    for c in candidates {
329        if c.is_file() {
330            return Ok(c.to_string_lossy().into_owned());
331        }
332    }
333    anyhow::bail!("unable to find a git bash executable installed")
334}