1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12pub const ZSH: &str = r#"
15_forge_cmd_running=0
16_forge_precmd() {
17 local ret=$?
18 if (( _forge_cmd_running )); then
19 printf '\033]133;D;%s\007' "$ret"
20 _forge_cmd_running=0
21 fi
22 printf '\033]133;A\007\033]133;B\007'
23}
24_forge_preexec() {
25 _forge_cmd_running=1
26 printf '\033]133;C\007'
27}
28precmd_functions+=(_forge_precmd)
29preexec_functions+=(_forge_preexec)
30"#;
31
32pub const BASH: &str = r#"
41_forge_cmd_running=
42_forge_in_prompt=
43_forge_status=0
44_forge_prompt() {
45 local ret=$_forge_status
46 if [ -n "$_forge_cmd_running" ]; then
47 printf '\033]133;D;%s\007' "$ret"
48 _forge_cmd_running=
49 fi
50 printf '\033]133;A\007\033]133;B\007'
51 _forge_in_prompt=1
52}
53_forge_preexec() {
54 _forge_status=$?
55 if [ -n "$_forge_in_prompt" ]; then
56 return 0
57 fi
58 case "$BASH_COMMAND" in
59 _forge_prompt) return 0 ;;
60 esac
61 if [ -z "$_forge_cmd_running" ]; then
62 _forge_cmd_running=1
63 printf '\033]133;C\007'
64 fi
65 return 0
66}
67PROMPT_COMMAND="_forge_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND};_forge_in_prompt="
68trap '_forge_preexec' DEBUG
69"#;
70
71pub const FISH: &str = r"
73function _forge_prompt --on-event fish_prompt
74 set -l ret $status
75 if set -q _forge_cmd_running
76 printf '\033]133;D;%s\007' $ret
77 set -e _forge_cmd_running
78 end
79 printf '\033]133;A\007\033]133;B\007'
80end
81function _forge_preexec --on-event fish_preexec
82 set -g _forge_cmd_running 1
83 printf '\033]133;C\007'
84end
85";
86
87#[derive(Debug)]
89pub struct ShellPlan {
90 pub program: String,
91 pub args: Vec<String>,
92 pub env: HashMap<String, String>,
93 pub shim: Option<PathBuf>,
95}
96
97impl ShellPlan {
98 pub fn cleanup(&self) {
100 if let Some(shim) = &self.shim {
101 let _ = std::fs::remove_dir_all(shim);
102 }
103 }
104}
105
106pub fn plan<S: std::hash::BuildHasher>(shell: &str, env: &HashMap<String, String, S>) -> ShellPlan {
112 let name = Path::new(shell).file_name().and_then(|name| name.to_str()).unwrap_or(shell);
113 match name {
114 "zsh" => zsh_plan(shell, env),
115 "bash" => bash_plan(shell, env),
116 "fish" => fish_plan(shell, env),
117 _ => ShellPlan {
118 program: shell.to_owned(),
119 args: vec!["-l".to_owned()],
120 env: clone_env(env),
121 shim: None,
122 },
123 }
124}
125
126fn zsh_plan<S: std::hash::BuildHasher>(shell: &str, env: &HashMap<String, String, S>) -> ShellPlan {
127 let real = env
128 .get("ZDOTDIR")
129 .cloned()
130 .or_else(|| env.get("HOME").cloned())
131 .unwrap_or_else(|| "/".to_owned());
132 let Some(shim) = make_shim("zsh") else {
133 return ShellPlan {
134 program: shell.to_owned(),
135 args: vec!["-l".to_owned()],
136 env: clone_env(env),
137 shim: None,
138 };
139 };
140 let zshenv = format!("if [ -f \"{real}/.zshenv\" ]; then source \"{real}/.zshenv\"; fi\n");
144 let zshrc = format!("if [ -f \"{real}/.zshrc\" ]; then source \"{real}/.zshrc\"; fi\n{ZSH}");
145 let zprofile =
146 format!("if [ -f \"{real}/.zprofile\" ]; then source \"{real}/.zprofile\"; fi\n");
147 let zshenv_path = shim.join(".zshenv");
148 let zshrc_path = shim.join(".zshrc");
149 let zprofile_path = shim.join(".zprofile");
150 for (path, body) in
151 [(&zshenv_path, &zshenv), (&zshrc_path, &zshrc), (&zprofile_path, &zprofile)]
152 {
153 if let Err(error) = std::fs::write(path, body) {
154 warn_unavailable("zsh", path, &error);
155 let _ = std::fs::remove_dir_all(&shim);
156 return ShellPlan {
157 program: shell.to_owned(),
158 args: vec!["-l".to_owned()],
159 env: clone_env(env),
160 shim: None,
161 };
162 }
163 }
164 let mut env = clone_env(env);
165 env.insert("ZDOTDIR".to_owned(), shim.display().to_string());
166 env.insert("FORGE_REAL_ZDOTDIR".to_owned(), real);
167 ShellPlan { program: shell.to_owned(), args: vec!["-l".to_owned()], env, shim: Some(shim) }
168}
169
170fn bash_plan<S: std::hash::BuildHasher>(
171 shell: &str,
172 env: &HashMap<String, String, S>,
173) -> ShellPlan {
174 let home = env.get("HOME").cloned().unwrap_or_else(|| "/".to_owned());
175 let Some(shim) = make_shim("bash") else {
176 return ShellPlan {
177 program: shell.to_owned(),
178 args: vec!["-i".to_owned()],
179 env: clone_env(env),
180 shim: None,
181 };
182 };
183 let rc = format!("if [ -f \"{home}/.bashrc\" ]; then source \"{home}/.bashrc\"; fi\n{BASH}");
184 let rcfile = shim.join("bashrc");
185 if let Err(error) = std::fs::write(&rcfile, rc) {
186 warn_unavailable("bash", &rcfile, &error);
187 let _ = std::fs::remove_dir_all(&shim);
188 return ShellPlan {
189 program: shell.to_owned(),
190 args: vec!["-i".to_owned()],
191 env: clone_env(env),
192 shim: None,
193 };
194 }
195 ShellPlan {
199 program: shell.to_owned(),
200 args: vec!["--rcfile".to_owned(), rcfile.display().to_string(), "-i".to_owned()],
201 env: clone_env(env),
202 shim: Some(shim),
203 }
204}
205
206fn fish_plan<S: std::hash::BuildHasher>(
207 shell: &str,
208 env: &HashMap<String, String, S>,
209) -> ShellPlan {
210 let Some(shim) = make_shim("fish") else {
211 return ShellPlan {
212 program: shell.to_owned(),
213 args: vec!["-l".to_owned()],
214 env: clone_env(env),
215 shim: None,
216 };
217 };
218 let script = shim.join("integration.fish");
219 if let Err(error) = std::fs::write(&script, FISH) {
220 warn_unavailable("fish", &script, &error);
221 let _ = std::fs::remove_dir_all(&shim);
222 return ShellPlan {
223 program: shell.to_owned(),
224 args: vec!["-l".to_owned()],
225 env: clone_env(env),
226 shim: None,
227 };
228 }
229 ShellPlan {
230 program: shell.to_owned(),
231 args: vec!["-l".to_owned(), "-C".to_owned(), format!("source {}", script.display())],
232 env: clone_env(env),
233 shim: Some(shim),
234 }
235}
236
237fn clone_env<S: std::hash::BuildHasher>(
241 env: &HashMap<String, String, S>,
242) -> HashMap<String, String> {
243 let mut env: HashMap<String, String> =
244 env.iter().map(|(key, value)| (key.clone(), value.clone())).collect();
245 env.insert("TERM".to_owned(), "xterm-256color".to_owned());
246 env.insert("COLORTERM".to_owned(), "truecolor".to_owned());
247 env
248}
249
250fn make_shim(shell: &str) -> Option<PathBuf> {
252 let nanos = std::time::SystemTime::now()
253 .duration_since(std::time::UNIX_EPOCH)
254 .map_or(0, |d| d.as_nanos());
255 let dir =
256 std::env::temp_dir().join(format!("forge-terminal-{shell}-{}-{nanos}", std::process::id()));
257 if let Err(error) = std::fs::create_dir_all(&dir) {
258 warn_unavailable(shell, &dir, &error);
259 return None;
260 }
261 Some(dir)
262}
263
264fn warn_unavailable(shell: &str, path: &Path, error: &std::io::Error) {
268 tracing::warn!(
269 event_name = "terminal_integration_unavailable",
270 shell,
271 path = %path.display(),
272 %error,
273 "the shell integration could not be written; the terminal starts without OSC 133 marks"
274 );
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn env() -> HashMap<String, String> {
282 let mut env = HashMap::new();
283 env.insert("HOME".to_owned(), "/home/person".to_owned());
284 env
285 }
286
287 #[test]
290 fn zsh_gets_a_zdotdir_shim_that_keeps_the_persons_rc() {
291 let plan = plan("/bin/zsh", &env());
292 assert_eq!(plan.program, "/bin/zsh");
293 assert_eq!(plan.args, ["-l"]);
294 let shim = plan.shim.as_ref().expect("a shim");
295 assert_eq!(plan.env.get("ZDOTDIR"), Some(&shim.display().to_string()));
296 assert_eq!(plan.env.get("FORGE_REAL_ZDOTDIR"), Some(&"/home/person".to_owned()));
297 assert_eq!(plan.env.get("TERM").map(String::as_str), Some("xterm-256color"));
298 assert_eq!(plan.env.get("COLORTERM").map(String::as_str), Some("truecolor"));
299 let zshenv = std::fs::read_to_string(shim.join(".zshenv")).expect("a .zshenv");
300 assert!(
301 zshenv.contains("source \"/home/person/.zshenv\""),
302 "the person's env file is sourced, as zsh always reads it"
303 );
304 let zshrc = std::fs::read_to_string(shim.join(".zshrc")).expect("a .zshrc");
305 assert!(zshrc.contains("source \"/home/person/.zshrc\""), "the person's rc is sourced");
306 assert!(zshrc.contains("133;D"), "and the integration follows it");
307 plan.cleanup();
308 assert!(!shim.exists(), "the shim is removed");
309 }
310
311 #[test]
313 fn bash_gets_an_rcfile_that_keeps_the_persons_rc() {
314 let plan = plan("/bin/bash", &env());
315 assert_eq!(plan.program, "/bin/bash");
316 assert_eq!(plan.args[0], "--rcfile");
317 assert_eq!(plan.args[2], "-i");
318 let rcfile = PathBuf::from(&plan.args[1]);
319 let rc = std::fs::read_to_string(&rcfile).expect("an rcfile");
320 assert!(rc.contains("source \"/home/person/.bashrc\""), "the person's rc is sourced");
321 assert!(rc.contains("133;D"), "and the integration follows it");
322 plan.cleanup();
323 }
324
325 #[test]
327 fn fish_gets_an_init_command() {
328 let plan = plan("/opt/homebrew/bin/fish", &env());
329 assert_eq!(plan.program, "/opt/homebrew/bin/fish");
330 assert_eq!(plan.args[0], "-l", "a login shell, so the person's config ran");
331 assert_eq!(plan.args[1], "-C");
332 assert!(plan.args[2].starts_with("source "), "{}", plan.args[2]);
333 plan.cleanup();
334 }
335
336 #[test]
339 fn an_unknown_shell_gets_no_integration() {
340 let plan = plan("/bin/sh", &env());
341 assert!(plan.shim.is_none());
342 assert_eq!(plan.args, ["-l"]);
343 }
344}