1use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
32
33use taimux_core::{env, index, proc};
34
35const REMOTE_TAIMUX: &str = "PATH=\"$HOME/.local/bin:$HOME/bin:$PATH\"; \
42 for d in \"$HOME\"/.tmux/plugins/taimux*/ \"$HOME\"/.config/tmux/plugins/taimux*/; do \
43 [ -x \"$d/taimux\" ] && PATH=\"$PATH:${d%/}\"; done; \
44 command -v taimux >/dev/null 2>&1 || exit 127; exec taimux";
45
46pub fn remote_taimux() -> &'static str {
48 REMOTE_TAIMUX
49}
50
51const INDEX_DUMP_CMD: &str = "index-dump";
58
59fn now() -> i64 {
60 SystemTime::now()
61 .duration_since(UNIX_EPOCH)
62 .map(|d| d.as_secs() as i64)
63 .unwrap_or(0)
64}
65
66pub fn enabled() -> bool {
67 env::on("TAIMUX_REMOTE")
68}
69
70fn bounded(secs: u64, mut cmd: Command) -> (i32, String) {
76 let Ok(mut child) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else {
77 return (255, String::new());
78 };
79 let deadline = Instant::now() + Duration::from_secs(secs);
80 let mut out = child.stdout.take();
81 loop {
82 match child.try_wait() {
83 Ok(Some(st)) => {
84 let mut s = String::new();
85 if let Some(o) = out.as_mut() {
86 let _ = o.read_to_string(&mut s);
87 }
88 return (st.code().unwrap_or(255), s);
89 }
90 Ok(None) => {
91 if Instant::now() >= deadline {
92 let _ = child.kill();
93 let _ = child.wait();
94 return (124, String::new()); }
96 std::thread::sleep(Duration::from_millis(20));
97 }
98 Err(_) => return (255, String::new()),
99 }
100 }
101}
102
103pub fn ssh(host: &str, remote: &str, timeout: u64) -> (i32, String) {
106 let mut c = Command::new("ssh");
107 c.args([
108 "-n",
109 "-o",
110 "BatchMode=yes",
111 "-o",
112 &format!(
113 "ConnectTimeout={}",
114 env::num("TAIMUX_SSH_CONNECT_TIMEOUT", 2)
115 ),
116 "--",
117 host,
118 remote,
119 ]);
120 taimux_core::stat::ssh(|| bounded(timeout, c))
121}
122
123pub fn ssh_why(status: i32) -> &'static str {
125 match status {
126 127 => "no taimux on that host",
127 124 | 137 => "timed out",
128 255 => "unreachable",
129 _ => "ssh failed",
130 }
131}
132
133pub fn ssh_host(argv: &str) -> Option<String> {
148 const TAKES_VALUE: &str = "BbcDEeFIiJLlmOopQRSWw";
150 let toks: Vec<&str> = argv.split(' ').filter(|t| !t.is_empty()).collect();
151 let mut saw_ssh = false;
152 let mut host: Option<&str> = None;
153 let mut saw_tmux = false;
154 let mut i = 0;
155 while i < toks.len() {
156 let tok = toks[i];
157 if !saw_ssh {
158 let base = tok.rsplit('/').next().unwrap_or(tok);
159 if base == "ssh" {
160 saw_ssh = true;
161 }
162 i += 1;
163 continue;
164 }
165 if host.is_none() {
166 if tok.starts_with('-') {
167 let mut c = tok.chars();
168 c.next();
169 if let (Some(f), None) = (c.next(), c.next()) {
170 if TAKES_VALUE.contains(f) {
171 i += 1; }
173 }
174 i += 1;
175 continue;
176 }
177 host = Some(tok);
178 i += 1;
179 continue;
180 }
181 if tok == "tmux" {
182 saw_tmux = true;
183 }
184 i += 1;
185 }
186 let host = host?;
187 if !saw_tmux {
188 return None;
189 }
190 if host.is_empty()
194 || !host
195 .bytes()
196 .all(|b| b.is_ascii_alphanumeric() || b"._@-".contains(&b))
197 {
198 return None;
199 }
200 Some(host.to_string())
201}
202
203pub fn ssh_tmux_panes(panes: &[(String, String)]) -> Vec<(String, String)> {
205 let fg = proc::foreground_map();
209 let mut out = Vec::new();
210 for (id, tty) in panes {
211 let tty = tty.trim_start_matches("/dev/");
212 for f in fg.iter().filter(|f| f.tty == tty) {
213 if let Some(h) = ssh_host(&f.argv) {
214 out.push((h, id.clone()));
215 break;
216 }
217 }
218 }
219 out
220}
221
222pub fn cache_dir() -> PathBuf {
223 taimux_core::paths::runtime_dir().join("remote")
224}
225
226fn hosts_file(dir: &Path) -> PathBuf {
232 let sock = std::env::var("TMUX").unwrap_or_default();
233 let sock = sock.split(',').next().unwrap_or("").to_string();
234 if sock.is_empty() {
235 dir.join(".hosts")
236 } else {
237 dir.join(format!(".hosts.{}", sock.replace('/', "%")))
238 }
239}
240
241fn write_atomic(f: &Path, body: &str) {
244 if let Some(d) = f.parent() {
245 let _ = std::fs::create_dir_all(d);
246 }
247 let tmp = f.with_extension(format!("t{}", std::process::id()));
248 if std::fs::write(&tmp, body).is_ok() && !body.is_empty() && std::fs::rename(&tmp, f).is_ok() {
249 return;
250 }
251 let _ = std::fs::remove_file(&tmp);
252}
253
254fn header(f: &Path) -> Option<(String, i64, String)> {
260 let text = std::fs::read_to_string(f).ok()?;
261 let line = text.lines().next()?;
262 let mut it = line.splitn(3, ' ');
263 let status = it.next()?.to_string();
264 let stamp = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
265 Some((status, stamp, it.next().unwrap_or("").to_string()))
266}
267
268pub fn hosts_cached(dir: &Path, panes: &[(String, String)]) -> Vec<String> {
276 let f = hosts_file(dir);
277 if let Some((status, stamp, _)) = header(&f) {
278 if status == "hosts" && now() - stamp < env::num("TAIMUX_HOSTS_TTL", 5) as i64 {
279 return std::fs::read_to_string(&f)
280 .unwrap_or_default()
281 .lines()
282 .skip(1)
283 .filter(|l| !l.is_empty())
284 .map(|l| l.to_string())
285 .collect();
286 }
287 }
288 let mut hosts: Vec<String> = ssh_tmux_panes(panes).into_iter().map(|(h, _)| h).collect();
289 hosts.sort();
290 hosts.dedup();
291 write_atomic(&f, &format!("hosts {}\n{}\n", now(), hosts.join("\n")));
298 hosts
299}
300
301pub fn fetch(host: &str, dir: &Path) {
303 let (rc, out) = ssh(
304 host,
305 &format!("{} list", REMOTE_TAIMUX),
306 env::num("TAIMUX_SSH_TIMEOUT", 4),
307 );
308 let f = dir.join(host);
309 if rc == 0 {
310 write_atomic(&f, &format!("ok {}\n{}", now(), out));
311 let _ = std::fs::write(dir.join(format!("{}.seen", host)), "");
318 } else {
319 write_atomic(&f, &format!("err {} {}\n", now(), ssh_why(rc)));
320 }
321}
322
323pub fn render(host: &str, dir: &Path) -> String {
330 let f = dir.join(host);
331 let Some((status, _, reason)) = header(&f) else {
332 return String::new();
333 };
334 let text = std::fs::read_to_string(&f).unwrap_or_default();
335 let mut s = String::new();
336 if status == "ok" {
337 let mut bad = 0;
338 for line in text.lines().skip(1) {
339 if line.is_empty() {
340 continue;
341 }
342 if line.split('\t').count() == 8 {
343 s.push_str(&format!("{}:{}\n", host, line));
344 } else {
345 bad += 1;
346 }
347 }
348 if bad > 0 {
351 s.push_str(&format!(
352 "{}:!\t{}\t-\t-\t\tunknown\t-\tits taimux answers in another format ({} row(s) dropped)\n",
353 host, host, bad
354 ));
355 }
356 } else if dir.join(format!("{}.seen", host)).exists() {
357 s.push_str(&format!(
358 "{}:!\t{}\t-\t-\t\tunknown\t-\t{}\n",
359 host,
360 host,
361 if reason.is_empty() {
362 "unreachable"
363 } else {
364 &reason
365 }
366 ));
367 }
368 s
369}
370
371fn classify(hosts: &[String], dir: &Path, n: i64) -> (Vec<String>, Vec<String>) {
382 let (mut cold, mut stale) = (Vec::new(), Vec::new());
383 for h in hosts {
384 match header(&dir.join(h)) {
385 None => cold.push(h.clone()),
386 Some((status, stamp, _)) => {
387 let ttl = match status.as_str() {
388 "ok" => env::num("TAIMUX_REMOTE_TTL", 3),
389 "err" => env::num("TAIMUX_REMOTE_FAIL_TTL", 60),
390 _ => {
391 cold.push(h.clone());
392 continue;
393 }
394 };
395 if n - stamp >= ttl as i64 {
396 stale.push(h.clone());
397 }
398 }
399 }
400 }
401 (cold, stale)
402}
403
404pub fn rows(panes: &[(String, String)]) -> String {
406 if !enabled() {
407 return String::new();
408 }
409 let dir = cache_dir();
410 let hosts = hosts_cached(&dir, panes);
411 if hosts.is_empty() {
412 return String::new();
413 }
414
415 let (cold, stale) = classify(&hosts, &dir, now());
416
417 let mut handles = Vec::new();
422 for h in cold {
423 let d = dir.clone();
424 handles.push(std::thread::spawn(move || fetch(&h, &d)));
425 }
426 for j in handles {
427 let _ = j.join();
428 }
429
430 for h in stale {
434 let d = dir.clone();
435 std::thread::spawn(move || fetch(&h, &d));
436 }
437
438 hosts.iter().map(|h| render(h, &dir)).collect()
439}
440
441pub fn resolve_cur(pane: &str, panes: &[(String, String)], pane_cmd: &str) -> String {
446 if !pane.starts_with('%') || !enabled() {
447 return pane.to_string();
448 }
449 if pane_cmd != "ssh" {
452 return pane.to_string();
453 }
454 let Some((host, _)) = ssh_tmux_panes(panes).into_iter().find(|(_, p)| p == pane) else {
455 return pane.to_string();
456 };
457 if let Some((status, _, _)) = header(&cache_dir().join(&host)) {
461 if status == "err" {
462 return pane.to_string();
463 }
464 }
465 let (rc, out) = ssh(
466 &host,
467 "tmux display-message -p \"#{pane_id}\"",
468 env::num("TAIMUX_SSH_CUR_TIMEOUT", 2),
469 );
470 let rid = out.trim();
471 if rc == 0 && rid.starts_with('%') {
472 format!("{}:{}", host, rid)
473 } else {
474 pane.to_string()
475 }
476}
477
478pub fn index_dump() -> String {
483 let mut s = String::new();
484 for e in std::fs::read_dir(index::index_dir())
485 .into_iter()
486 .flatten()
487 .flatten()
488 {
489 let Ok(text) = std::fs::read_to_string(e.path()) else {
490 continue;
491 };
492 let mut lines = text.lines();
493 let Some(head) = lines.next() else { continue };
494 let f: Vec<&str> = head.split(' ').collect();
495 if f.first() != Some(&"idx") || f.len() < 4 {
496 continue;
497 }
498 let pane = f[3];
499 if !(pane.starts_with('%') && pane[1..].bytes().all(|b| b.is_ascii_digit())) {
500 continue;
501 }
502 s.push_str(&format!("{}\t{}\n", pane, lines.next().unwrap_or("")));
503 }
504 s
505}
506
507pub fn index_fetch(host: &str, dir: &Path) {
520 let (rc, out) = ssh(
521 host,
522 &format!("{} {}", REMOTE_TAIMUX, INDEX_DUMP_CMD),
523 env::num("TAIMUX_SEARCH_SSH_TIMEOUT", 20),
524 );
525 if rc != 0 {
526 return;
527 }
528 let n = now();
529 for line in out.lines() {
530 let Some((pane, blob)) = line.split_once('\t') else {
531 continue;
532 };
533 if !(pane.starts_with('%') && pane[1..].bytes().all(|b| b.is_ascii_digit())) {
534 continue;
535 }
536 let id = format!("{}:{}", host, pane);
537 let f = dir.join(index::key_for(&id));
538 let _ = std::fs::write(&f, format!("idx 0 {} {} -\n{}\n", n, id, blob));
539 }
540}
541
542pub fn index_remote(panes: &[(String, String)]) {
547 if !enabled() || !env::on("TAIMUX_SEARCH_REMOTE") {
548 return;
549 }
550 let dir = index::index_dir();
551 let ttl = env::num("TAIMUX_SEARCH_REMOTE_TTL", 60) as i64;
552 let n = now();
553 for h in hosts_cached(&cache_dir(), panes) {
554 let stampfile = dir.join(format!(".{}.stamp", h));
555 let last: i64 = std::fs::read_to_string(&stampfile)
556 .ok()
557 .and_then(|s| s.trim().parse().ok())
558 .unwrap_or(0);
559 if n - last < ttl {
560 continue;
561 }
562 let _ = std::fs::create_dir_all(&dir);
563 let _ = std::fs::write(&stampfile, format!("{}\n", n));
564 index_fetch(&h, &dir);
565 }
566}
567
568pub fn pane_host(id: &str) -> Option<&str> {
570 if id.is_empty() || id.starts_with('%') {
571 return None;
572 }
573 id.split_once(':').map(|(h, _)| h)
574}
575
576pub fn pane_local(id: &str) -> &str {
577 if id.is_empty() || id.starts_with('%') {
578 return id;
579 }
580 id.split_once(':').map(|(_, p)| p).unwrap_or(id)
581}
582
583pub fn switch_remote(host: &str, lid: &str, local_pane: &str, zoom: bool) -> Result<(), String> {
590 if !lid.starts_with('%') {
591 return Err("not a pane id".into());
592 }
593 if local_pane.is_empty() {
594 return Err(format!("nothing is ssh'd into {} any more", host));
595 }
596 let mut remote = format!(
597 "tmux select-window -t {0} \\; select-pane -t {0} \\; switch-client -t {0}",
598 lid
599 );
600 if zoom {
601 remote.push_str(&format!(
604 "\n[ \"$(tmux display-message -p -t {0} '#{{window_zoomed_flag}}')\" = 1 ] || tmux resize-pane -Z -t {0}",
605 lid
606 ));
607 }
608 let (rc, _) = ssh(host, &remote, env::num("TAIMUX_SSH_TIMEOUT", 4));
609 let lost = rc != 0;
613 if taimux_core::tmux::switch_local(local_pane, zoom) {
614 if lost {
615 Err(format!("{} did not answer, going to its pane anyway", host))
616 } else {
617 Ok(())
618 }
619 } else {
620 Err(format!("could not reach {}", local_pane))
621 }
622}
623
624pub fn tmux_pane_ttys() -> Vec<(String, String)> {
627 taimux_core::tmux::ask_raw(&["list-panes", "-a", "-F", "#{pane_id}\t#{pane_tty}"])
628 .unwrap_or_default()
629 .lines()
630 .filter_map(|l| l.split_once('\t'))
631 .map(|(a, b)| (a.to_string(), b.to_string()))
632 .collect()
633}
634
635pub fn all_panes(local: &str, panes: &[(String, String)]) -> String {
637 format!("{}{}", local, rows(panes))
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn an_ssh_tmux_argv_names_its_host() {
646 assert_eq!(
647 ssh_host("ssh ha -t tmux new-session -As main").as_deref(),
648 Some("ha")
649 );
650 assert_eq!(
651 ssh_host("/usr/bin/ssh laptop-two -t tmux attach").as_deref(),
652 Some("laptop-two")
653 );
654 }
655
656 #[test]
660 fn ssh_is_found_when_it_is_not_argv_zero() {
661 assert_eq!(
662 ssh_host("bash /home/p/bin/ssh ha -t tmux attach").as_deref(),
663 Some("ha")
664 );
665 }
666
667 #[test]
670 fn without_a_later_tmux_it_is_not_a_window_onto_a_server() {
671 assert_eq!(ssh_host("ssh ha journalctl -f"), None);
672 assert_eq!(ssh_host("ssh ha"), None);
673 assert_eq!(ssh_host("tmux attach"), None);
674 assert_eq!(ssh_host(""), None);
675 }
676
677 #[test]
680 fn an_options_value_is_not_mistaken_for_a_host() {
681 assert_eq!(
682 ssh_host("ssh -p 2222 ha -t tmux attach").as_deref(),
683 Some("ha")
684 );
685 assert_eq!(
686 ssh_host("ssh -i /k/id_ed25519 -o Foo=bar ha tmux attach").as_deref(),
687 Some("ha")
688 );
689 assert_eq!(ssh_host("ssh -4 -A ha tmux attach").as_deref(), Some("ha"));
691 }
692
693 #[test]
696 fn an_unexpected_character_in_a_host_drops_it() {
697 assert_eq!(ssh_host("ssh ha;rm -rf / tmux attach"), None);
698 assert_eq!(ssh_host("ssh ../etc tmux attach"), None);
699 assert_eq!(ssh_host("ssh $HOST tmux attach"), None);
700 assert_eq!(
702 ssh_host("ssh user@ha.example.com tmux attach").as_deref(),
703 Some("user@ha.example.com")
704 );
705 }
706
707 #[test]
708 fn a_composite_id_splits_into_host_and_pane() {
709 assert_eq!(pane_host("ha:%6"), Some("ha"));
710 assert_eq!(pane_local("ha:%6"), "%6");
711 assert_eq!(pane_host("%17"), None);
712 assert_eq!(pane_local("%17"), "%17");
713 assert_eq!(pane_host(""), None);
714 assert_eq!(pane_local(""), "");
715 assert_eq!(pane_host("ha:!"), Some("ha"));
717 assert_eq!(pane_local("ha:!"), "!");
718 }
719
720 #[test]
721 fn an_ssh_failure_says_what_kind() {
722 assert_eq!(ssh_why(127), "no taimux on that host");
723 assert_eq!(ssh_why(124), "timed out");
724 assert_eq!(ssh_why(137), "timed out");
725 assert_eq!(ssh_why(255), "unreachable");
726 assert_eq!(ssh_why(3), "ssh failed");
727 }
728
729 fn fixture(tag: &str) -> PathBuf {
730 let d = std::env::temp_dir().join(format!("jmrem{}{}", std::process::id(), tag));
731 let _ = std::fs::remove_dir_all(&d);
732 std::fs::create_dir_all(&d).unwrap();
733 d
734 }
735
736 #[test]
737 fn a_cached_reply_becomes_host_prefixed_rows() {
738 let d = fixture("r");
739 std::fs::write(
740 d.join("ha"),
741 "ok 100\n%6\tmain:1.7\t/w\tclaude\t2.1.1\trun\t-\tover there\n",
742 )
743 .unwrap();
744 let out = render("ha", &d);
745 assert!(out.starts_with("ha:%6\tmain:1.7\t"));
746 assert_eq!(out.lines().count(), 1);
747 let _ = std::fs::remove_dir_all(&d);
748 }
749
750 #[test]
754 fn only_a_host_that_has_answered_before_keeps_a_row() {
755 let d = fixture("e");
756 std::fs::write(d.join("ha"), "err 100 unreachable\n").unwrap();
757 assert_eq!(render("ha", &d), "");
758 std::fs::write(d.join("ha.seen"), "").unwrap();
759 let out = render("ha", &d);
760 assert!(out.starts_with("ha:!\tha\t"));
761 assert!(out.contains("unreachable"));
762 let _ = std::fs::remove_dir_all(&d);
763 }
764
765 #[test]
768 fn a_reply_in_another_format_says_so_once() {
769 let d = fixture("f");
770 std::fs::write(
771 d.join("ha"),
772 "ok 100\n%6\ttoo\tfew\tfields\n%7\talso\tshort\n",
773 )
774 .unwrap();
775 let out = render("ha", &d);
776 assert_eq!(out.lines().count(), 1);
777 assert!(out.contains("2 row(s) dropped"));
778 let _ = std::fs::remove_dir_all(&d);
779 }
780
781 #[test]
782 fn a_file_with_no_header_renders_nothing() {
783 let d = fixture("h");
784 assert_eq!(render("ha", &d), ""); std::fs::write(d.join("ha"), "").unwrap();
786 assert_eq!(render("ha", &d), "");
787 let _ = std::fs::remove_dir_all(&d);
788 }
789
790 #[test]
795 fn the_host_cache_is_written_even_with_no_hosts() {
796 let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
799 let d = fixture("c");
800 std::env::set_var("TMUX", "/tmp/sock,1,0");
801 let hosts = hosts_cached(&d, &[]);
802 assert!(hosts.is_empty());
803 let f = hosts_file(&d);
804 assert!(f.exists(), "no cache file was written");
805 assert!(std::fs::read_to_string(&f).unwrap().starts_with("hosts "));
806 let left: Vec<String> = std::fs::read_dir(&d)
808 .unwrap()
809 .flatten()
810 .map(|e| e.file_name().to_string_lossy().into_owned())
811 .filter(|n| n.contains(".t"))
812 .collect();
813 assert!(left.is_empty(), "temp files left: {:?}", left);
814 std::env::remove_var("TMUX");
815 let _ = std::fs::remove_dir_all(&d);
816 }
817
818 #[test]
821 fn the_host_cache_is_keyed_by_server() {
822 let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
825 let d = fixture("k");
826 std::env::set_var("TMUX", "/tmp/one,1,0");
827 let a = hosts_file(&d);
828 std::env::set_var("TMUX", "/tmp/two,1,0");
829 let b = hosts_file(&d);
830 assert_ne!(a, b);
831 std::env::remove_var("TMUX");
832 let _ = std::fs::remove_dir_all(&d);
833 }
834
835 #[test]
839 fn a_wedged_child_is_killed_rather_than_waited_for() {
840 let mut c = Command::new("sleep");
841 c.arg("30");
842 let started = Instant::now();
843 let (rc, out) = bounded(1, c);
844
845 if rc == 255 && started.elapsed() < Duration::from_millis(500) {
854 eprintln!(
855 "no child was spawned to wedge, skipping: rc={} in {:?}",
856 rc,
857 started.elapsed()
858 );
859 return;
860 }
861
862 assert_eq!(
863 rc,
864 124,
865 "not the status `timeout` uses; came back in {:?}",
866 started.elapsed()
867 );
868 assert!(out.is_empty());
869 assert!(
870 started.elapsed() < Duration::from_secs(5),
871 "waited {:?} for a 1 s limit",
872 started.elapsed()
873 );
874 }
875
876 #[test]
877 fn a_prompt_child_runs_to_completion_and_its_output_comes_back() {
878 let mut c = Command::new("printf");
879 c.arg("hi");
880 assert_eq!(bounded(5, c), (0, "hi".to_string()));
881 let c = Command::new("false");
883 assert_eq!(bounded(5, c).0, 1);
884 let c = Command::new("no-such-program-at-all");
886 assert_eq!(bounded(5, c).0, 255);
887 }
888
889 #[test]
893 fn a_fresh_answer_is_left_alone_and_a_stale_one_is_refreshed() {
894 let d = fixture("q");
895 std::fs::write(d.join("fresh"), "ok 1000\n").unwrap();
896 std::fs::write(d.join("stale"), "ok 900\n").unwrap();
897 std::fs::write(d.join("dead"), "err 995 unreachable\n").unwrap();
898 std::fs::write(d.join("junk"), "nonsense\n").unwrap();
899 let hosts: Vec<String> = ["fresh", "stale", "dead", "junk", "never"]
900 .iter()
901 .map(|s| s.to_string())
902 .collect();
903 let (cold, refresh) = classify(&hosts, &d, 1001);
904 assert_eq!(cold, vec!["junk", "never"]);
907 assert_eq!(refresh, vec!["stale"]);
909 let _ = std::fs::remove_dir_all(&d);
910 }
911
912 #[test]
917 fn the_remote_preamble_finds_a_taimux_or_exits_127() {
918 let home = fixture("p");
919 let run = || -> (i32, String) {
920 let out = Command::new("sh")
921 .args(["-c", REMOTE_TAIMUX])
922 .arg("list")
923 .env("HOME", &home)
924 .env("PATH", "/usr/bin:/bin")
925 .output()
926 .expect("sh");
927 (
928 out.status.code().unwrap_or(-1),
929 String::from_utf8_lossy(&out.stdout).trim().to_string(),
930 )
931 };
932 assert_eq!(run().0, 127);
934
935 let plug = home.join(".tmux/plugins/taimux-9f2c1b");
937 std::fs::create_dir_all(&plug).unwrap();
938 std::fs::write(
939 plug.join("taimux"),
940 "#!/bin/sh\necho answered-from-the-plugin-copy\n",
941 )
942 .unwrap();
943 set_exec(&plug.join("taimux"));
944 assert_eq!(run(), (0, "answered-from-the-plugin-copy".into()));
945
946 let bin = home.join(".local/bin");
948 std::fs::create_dir_all(&bin).unwrap();
949 std::fs::write(
950 bin.join("taimux"),
951 "#!/bin/sh\necho answered-from-the-real-install\n",
952 )
953 .unwrap();
954 set_exec(&bin.join("taimux"));
955 assert_eq!(run(), (0, "answered-from-the-real-install".into()));
956 let _ = std::fs::remove_dir_all(&home);
957 }
958
959 fn set_exec(p: &Path) {
960 use std::os::unix::fs::PermissionsExt;
961 let mut m = std::fs::metadata(p).unwrap().permissions();
962 m.set_mode(0o755);
963 std::fs::set_permissions(p, m).unwrap();
964 }
965
966 #[test]
969 fn resolve_cur_leaves_an_ordinary_pane_alone() {
970 assert_eq!(resolve_cur("%7", &[], "bash"), "%7");
971 assert_eq!(resolve_cur("%7", &[], "ssh"), "%7"); assert_eq!(resolve_cur("dead:/x", &[], "ssh"), "dead:/x");
973 }
974}