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 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
63pub 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
135pub 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}