Skip to main content

taimux_cli/
install.rs

1//! Putting taimux on PATH and binding the key.
2//!
3//! This is the part that writes to a real configuration file, so the rules it
4//! follows are about not making a mess of somebody's tmux config:
5//!
6//! - **Idempotent by the command string.** A past install's block is stripped
7//!   from every file it could have touched before a new one is written, or a
8//!   reinstall registers the binding twice and the popup opens twice.
9//! - **Oh My Tmux gets `.tmux.conf.local`**, never `.tmux.conf`. That file is
10//!   both configuration AND a shell script (its `_apply_configuration` is run
11//!   through `cut | sh`), so appending raw config to the wrong one breaks every
12//!   reload.
13//! - **Inside the "user customizations" section** where there is one, because in
14//!   Oh My Tmux that section sits inside a heredoc and is therefore safe from
15//!   that pass.
16//! - **A plugin-manager checkout is left alone entirely.** There the plugin
17//!   entry point binds the keys on every launch and reload, so writing to the
18//!   config would be a second, stale copy of the same binding.
19//! - **The bindings call the bare launcher name**, never the checkout path, so
20//!   moving the checkout does not break them.
21
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use taimux_core::tmux;
26
27/// tmux's `#{<:}` is a string compare, so the numeric width test is done in the
28/// shell, evaluated against the triggering client's width at key-press time.
29const POPUP_COND: &str = "[ \"#{client_width}\" -lt 100 ]";
30
31/// How much of the client a popup takes: near-full on a small screen (a phone
32/// over ssh), 80% on a roomy terminal.
33///
34/// One rule, two callers. The binding below opens the popup with it at
35/// key-press time, and the picker reopens itself with it when the terminal has
36/// grown since (see `tui::outgrown`), because tmux never grows a popup past the
37/// size it was asked for. Two copies of this would drift into a picker that
38/// resizes itself to a geometry the binding would never have chosen.
39pub fn popup_geometry(client_width: usize) -> (u16, u16) {
40    if client_width < 100 {
41        (100, 90)
42    } else {
43        (80, 80)
44    }
45}
46
47/// `#{pane_id}` in there is inert on purpose: `display-popup` expands no format
48/// in the command it runs, so bare like this the shell drops it as a comment and
49/// the picker asks tmux which pane it was opened from. Quoting it would hand the
50/// picker the format itself as a string.
51///
52/// `-e TAIMUX_POPUP=1` is how the picker knows it is IN a popup rather than
53/// inline in a pane, which decides whether it may reopen itself on a resize. It
54/// is set here rather than guessed over there because every guess available (our
55/// size against the active pane's, a leaked `$TMUX_PANE`) is wrong for some real
56/// layout, and guessing wrong means an inline picker closing itself and coming
57/// back as a popup.
58pub fn popup_cmd(launcher: &str, small: bool) -> String {
59    let (w, h) = popup_geometry(if small { 80 } else { 100 });
60    format!(
61        "display-popup -E -e TAIMUX_POPUP=1 -w {}% -h {}% \"{} pick #{{pane_id}}\"",
62        w, h, launcher
63    )
64}
65
66/// A tmux user option, or a default when it is not SET at all.
67///
68/// Set-but-empty and unset are different answers: an empty `@taimux-key` means
69/// "do not bind that one", which is why this asks whether the option exists
70/// before reading its value.
71pub fn tmux_opt(name: &str, default: &str) -> String {
72    match tmux::ask(&["show-options", "-gq", name]) {
73        Some(shown) if !shown.trim().is_empty() => {
74            tmux::ask(&["show-options", "-gqv", name]).unwrap_or_default()
75        }
76        _ => default.to_string(),
77    }
78}
79
80/// Bind the keys in the RUNNING server, and say what got bound.
81pub fn bind_live(launcher: &str, key: &str, root: &str) -> String {
82    let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
83    let mut desc = String::new();
84    if !key.is_empty() && tmux::run(&["bind-key", key, "if-shell", POPUP_COND, &small, &large]) {
85        desc = format!("prefix + {}", key);
86    }
87    if !root.is_empty()
88        && tmux::run(&[
89            "bind-key", "-n", root, "if-shell", POPUP_COND, &small, &large,
90        ])
91    {
92        if desc.is_empty() {
93            desc = root.to_string();
94        } else {
95            desc = format!("{} and {}", desc, root);
96        }
97    }
98    desc
99}
100
101/// Is this checkout owned by a plugin manager?
102///
103/// If it is, the plugin entry point binds the keys on every launch and reload, so
104/// `install` must not also write them to a config file.
105pub fn plugin_checkout(exe: &Path) -> bool {
106    let Some(parent) = exe.parent().and_then(|p| p.parent()) else {
107        return false;
108    };
109    let home = std::env::var("HOME").unwrap_or_default();
110    let xdg = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| format!("{}/.config", home));
111    [
112        std::env::var("TMUX_PLUGIN_MANAGER_PATH").unwrap_or_default(),
113        format!("{}/.tmux/plugins", home),
114        format!("{}/tmux/plugins", xdg),
115    ]
116    .iter()
117    .any(|p| !p.is_empty() && parent == Path::new(p.trim_end_matches('/')))
118}
119
120/// Is this an Oh My Tmux configuration?
121///
122/// Two tells, and both are needed: the resolved path living under `~/.tmux/`, and
123/// the `_apply_configuration` function that makes that file a shell script as
124/// well as a config.
125pub fn is_oh_my_tmux(resolved: &Path) -> bool {
126    let home = std::env::var("HOME").unwrap_or_default();
127    if resolved.starts_with(format!("{}/.tmux/", home)) {
128        return true;
129    }
130    std::fs::read_to_string(resolved)
131        .map(|t| t.contains("_apply_configuration"))
132        .unwrap_or(false)
133}
134
135/// Which file to write to.
136///
137/// `.tmux.conf.local` whenever it exists OR the config is Oh My Tmux, because
138/// that is the file Oh My Tmux reserves for exactly this and the only one it is
139/// safe to append to.
140pub fn choose_conf(resolved: &Path, local: &Path) -> PathBuf {
141    if local.exists() || is_oh_my_tmux(resolved) {
142        local.to_path_buf()
143    } else {
144        resolved.to_path_buf()
145    }
146}
147
148/// Remove any block a past install added, under any of this tool's former names.
149///
150/// A file with nothing of ours in it is left completely untouched, mtime
151/// included: a reinstall must not look like an edit to something watching the
152/// file.
153pub fn strip_block(text: &str) -> Option<String> {
154    // Every name this tool has installed under. A config edited years ago still
155    // has one of them in it, and leaving it would bind the popup twice.
156    let ours = |l: &str| {
157        l.contains("claude-tmux-locator pick")
158            || l.contains("muxhop pick")
159            || l.contains("taimux pick")
160            || l.starts_with("# claude-tmux-locator")
161            || l.starts_with("#claude-tmux-locator")
162            || l.starts_with("# muxhop")
163            || l.starts_with("#muxhop")
164            || l.starts_with("# taimux")
165            || l.starts_with("#taimux")
166    };
167    if !text.lines().any(ours) {
168        return None;
169    }
170    Some(
171        text.lines()
172            .filter(|l| !ours(l))
173            .map(|l| format!("{}\n", l))
174            .collect(),
175    )
176}
177
178/// Insert the block, under the "user customizations" heading where there is one.
179///
180/// In Oh My Tmux that heading sits inside a heredoc, which is what makes the
181/// insertion safe from the `cut | sh` pass that file gets put through. Appended
182/// at the end otherwise.
183pub fn insert_block(text: &str, block: &str) -> String {
184    let mut out = String::new();
185    let mut done = false;
186    for l in text.lines() {
187        out.push_str(l);
188        out.push('\n');
189        if !done && l.contains("-- user customizations") {
190            out.push_str(block);
191            done = true;
192        }
193    }
194    if !done {
195        out.push_str(block);
196    }
197    out
198}
199
200/// The block itself: a marker line saying what it is, then one bind per key.
201pub fn block_for(launcher: &str, key: &str, root: &str, desc: &str) -> String {
202    let (small, large) = (popup_cmd(launcher, true), popup_cmd(launcher, false));
203    let mut b = format!(
204        "\n# taimux - jump between agent sessions - {} (adaptive popup width)\n",
205        if desc.is_empty() { "unbound" } else { desc }
206    );
207    if !key.is_empty() {
208        b.push_str(&format!(
209            "bind-key {} if-shell '{}' '{}' '{}'\n",
210            key, POPUP_COND, small, large
211        ));
212    }
213    if !root.is_empty() {
214        // no prefix, so it is easy to send from a phone ssh app
215        b.push_str(&format!(
216            "bind-key -n {} if-shell '{}' '{}' '{}'\n",
217            root, POPUP_COND, small, large
218        ));
219    }
220    b
221}
222
223/// How the two keys read in a sentence.
224pub fn describe(key: &str, root: &str) -> String {
225    match (key.is_empty(), root.is_empty()) {
226        (true, true) => String::new(),
227        (false, true) => format!("prefix + {}", key),
228        (true, false) => root.to_string(),
229        (false, false) => format!("prefix + {} and {}", key, root),
230    }
231}
232
233/// `taimux bind`: bind the keys in the running server and write nothing.
234///
235/// This is what the tmux plugin entry point calls on every launch and reload, and
236/// it is also the repair for a binding whose checkout has moved.
237pub fn bind(exe: &str) -> i32 {
238    if tmux::ask(&["show-options", "-g"]).is_none() {
239        eprintln!(
240            "no tmux server to bind in: run it from inside tmux, or let your plugin manager run it"
241        );
242        return 1;
243    }
244    let key = tmux_opt("@taimux-key", "a");
245    let root = tmux_opt("@taimux-root-key", "F1");
246    let desc = bind_live(exe, &key, &root);
247    if desc.is_empty() {
248        println!("nothing bound: @taimux-key and @taimux-root-key are both set to empty");
249        return 0;
250    }
251    println!("bound {} -> {}", desc, exe);
252    0
253}
254
255/// `taimux install`: symlink the launcher and bind the key.
256pub fn install(exe: &str) -> i32 {
257    let home = std::env::var("HOME").unwrap_or_default();
258    let bindir = PathBuf::from(&home).join(".local/bin");
259    let link = bindir.join("taimux");
260    let _ = std::fs::create_dir_all(&bindir);
261    let _ = std::fs::remove_file(&link);
262    if std::os::unix::fs::symlink(exe, &link).is_err() {
263        eprintln!("could not symlink {}", link.display());
264        return 1;
265    }
266    println!("symlinked {} -> {}", link.display(), exe);
267
268    // Drop stale symlinks from earlier names of this tool, if they are ours.
269    //
270    // "ours" is judged by what the link POINTS AT, never by the name alone:
271    // `cj` is two letters and could easily be somebody else's.
272    for old in ["cj", "muxhop"] {
273        let p = bindir.join(old);
274        if let Ok(t) = std::fs::read_link(&p) {
275            let t = t.to_string_lossy();
276            if t.contains("claude-tmux-locator") || t.ends_with("/muxhop") || t.ends_with("/taimux")
277            {
278                let _ = std::fs::remove_file(&p);
279                println!("removed old `{}` symlink", old);
280            }
281        }
282    }
283
284    let key = tmux_opt("@taimux-key", "a");
285    let root = tmux_opt("@taimux-root-key", "F1");
286    let desc = describe(&key, &root);
287    let in_tmux = std::env::var("TMUX")
288        .map(|v| !v.is_empty())
289        .unwrap_or(false);
290
291    // A checkout a plugin manager owns gets its bindings from the plugin entry
292    // point on every launch and reload, so the only half of `install` left to do
293    // is the symlink: that is what puts restart, resurrect and install-hooks on
294    // PATH, and what answers ssh when this host is listed from another one.
295    if plugin_checkout(Path::new(exe)) {
296        println!("plugin checkout, so your plugin manager owns the bindings: nothing");
297        println!("written to your tmux config.");
298        if in_tmux && !bind_live(exe, &key, &root).is_empty() {
299            println!("bound {} in the running tmux server", desc);
300        }
301        println!("\nDone. Optional: taimux install-hooks, so sessions report their own state.");
302        return 0;
303    }
304
305    let resolved = std::fs::canonicalize(PathBuf::from(&home).join(".tmux.conf"))
306        .unwrap_or_else(|_| PathBuf::from(&home).join(".tmux.conf"));
307    let local = PathBuf::from(&home).join(".tmux.conf.local");
308    let target = choose_conf(&resolved, &local);
309    if !target.exists() {
310        let _ = std::fs::write(&target, "");
311    }
312    println!("writing bindings to {}", target.display());
313
314    // Idempotent: strip any prior block from every file a past install may have
315    // touched, not just the one being written now.
316    for f in [&resolved, &local] {
317        if let Ok(text) = std::fs::read_to_string(f) {
318            if let Some(stripped) = strip_block(&text) {
319                let _ = std::fs::write(f, stripped);
320            }
321        }
322    }
323
324    let text = std::fs::read_to_string(&target).unwrap_or_default();
325    // The bare launcher name, never the checkout path, so moving the checkout
326    // does not break the binding.
327    let body = insert_block(&text, &block_for("taimux", &key, &root, &desc));
328    if std::fs::write(&target, body).is_err() {
329        eprintln!("could not write {}", target.display());
330        return 1;
331    }
332    println!(
333        "bound {} in {}",
334        if desc.is_empty() { "nothing" } else { &desc },
335        target.display()
336    );
337
338    if in_tmux && !bind_live("taimux", &key, &root).is_empty() {
339        println!("bound {} in the running tmux server", desc);
340    }
341    if desc.is_empty() {
342        println!("\nDone. Both key options are set to empty, so no key is bound; run: taimux");
343    } else {
344        println!("\nDone. Press {}, or run: taimux", desc);
345    }
346    println!("Optional: taimux install-hooks, so sessions report their own state.");
347    let _ = std::io::stdout().flush();
348    0
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn the_popup_is_wider_on_a_narrow_client() {
357        assert!(popup_cmd("taimux", true).contains("-w 100%"));
358        assert!(popup_cmd("taimux", false).contains("-w 80%"));
359        // the pane id goes in BARE: display-popup expands no format, so the shell
360        // drops it as a comment and the picker asks tmux instead
361        assert!(popup_cmd("taimux", false).contains("pick #{pane_id}"));
362    }
363
364    #[test]
365    fn the_spelling_reported_is_the_one_in_the_file() {
366        // the launcher symlink, which is what a settings file written before the
367        // binary moved will be holding
368        let js = br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"/home/p/.local/bin/taimux hook"}]}]}}"#;
369        assert_eq!(
370            registered_cmd(js, "/build/target/release/taimux hook"),
371            "/home/p/.local/bin/taimux hook"
372        );
373        // nothing of ours in there: what we asked for is what landed
374        assert_eq!(
375            registered_cmd(br#"{"hooks":{}}"#, "/build/taimux hook"),
376            "/build/taimux hook"
377        );
378    }
379
380    #[test]
381    fn the_keys_read_as_a_sentence() {
382        assert_eq!(describe("a", "F1"), "prefix + a and F1");
383        assert_eq!(describe("a", ""), "prefix + a");
384        assert_eq!(describe("", "F1"), "F1");
385        assert_eq!(describe("", ""), "");
386    }
387
388    /// A file with nothing of ours in it is left completely untouched, mtime
389    /// included: a reinstall must not look like an edit to anything watching it.
390    #[test]
391    fn a_file_without_our_block_is_not_rewritten() {
392        assert_eq!(strip_block("set -g mouse on\n"), None);
393    }
394
395    /// Every former name of this tool, because a config edited years ago still
396    /// has one of them in it and leaving it would bind the popup twice.
397    #[test]
398    fn a_past_block_is_stripped_under_any_of_the_old_names() {
399        let text = "set -g mouse on\n\
400                    # taimux - jump between agent sessions\n\
401                    bind-key a if-shell '…' 'display-popup -E … \"taimux pick\"' '…'\n\
402                    # muxhop\n\
403                    bind-key -n F1 run 'muxhop pick'\n\
404                    bind-key X run 'claude-tmux-locator pick'\n\
405                    set -g status on\n";
406        let out = strip_block(text).expect("stripped");
407        assert_eq!(out, "set -g mouse on\nset -g status on\n");
408    }
409
410    /// In Oh My Tmux the "user customizations" heading sits inside a heredoc,
411    /// which is what makes an insertion there safe from the `cut | sh` pass that
412    /// file gets put through.
413    #[test]
414    fn the_block_goes_under_the_user_customizations_heading() {
415        let text = "# -- user customizations\n# after\n";
416        let out = insert_block(text, "\nBLOCK\n");
417        assert_eq!(out, "# -- user customizations\n\nBLOCK\n# after\n");
418    }
419
420    #[test]
421    fn without_that_heading_it_goes_at_the_end() {
422        let out = insert_block("set -g mouse on\n", "\nBLOCK\n");
423        assert_eq!(out, "set -g mouse on\n\nBLOCK\n");
424    }
425
426    /// Only the FIRST heading, or a config mentioning it twice gets two blocks.
427    #[test]
428    fn only_the_first_heading_takes_the_block() {
429        let text = "# -- user customizations\nx\n# -- user customizations\n";
430        let out = insert_block(text, "\nBLOCK\n");
431        assert_eq!(out.matches("BLOCK").count(), 1);
432    }
433
434    /// Oh My Tmux reserves `.tmux.conf.local` for exactly this, and appending to
435    /// `.tmux.conf` there breaks every reload, since that file is a shell script
436    /// as well as a config.
437    #[test]
438    fn oh_my_tmux_gets_the_local_file() {
439        // HOME is process-global and the harness runs threads.
440        let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
441        let d = std::env::temp_dir().join(format!("jminst{}", std::process::id()));
442        std::fs::create_dir_all(&d).unwrap();
443        let conf = d.join("tmux.conf");
444        let local = d.join("tmux.conf.local");
445
446        // a plain config, no local file: write to the config itself
447        std::fs::write(&conf, "set -g mouse on\n").unwrap();
448        assert_eq!(choose_conf(&conf, &local), conf);
449
450        // Oh My Tmux, detected by its own function: the local file, even though
451        // it does not exist yet
452        std::fs::write(&conf, "_apply_configuration() {\n  :\n}\n").unwrap();
453        assert_eq!(choose_conf(&conf, &local), local);
454
455        // and a local file that exists always wins
456        std::fs::write(&conf, "set -g mouse on\n").unwrap();
457        std::fs::write(&local, "").unwrap();
458        assert_eq!(choose_conf(&conf, &local), local);
459        let _ = std::fs::remove_dir_all(&d);
460    }
461
462    /// A plugin-manager checkout must not have bindings written to a config file:
463    /// the plugin entry point binds them on every launch, and a written copy
464    /// would be a second, stale one.
465    #[test]
466    fn a_plugin_checkout_is_recognised() {
467        // HOME is process-global and the harness runs threads.
468        let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
469        let home = std::env::temp_dir().join(format!("jmplug{}", std::process::id()));
470        std::env::set_var("HOME", &home);
471        std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
472        std::env::remove_var("XDG_CONFIG_HOME");
473        let inside = home.join(".tmux/plugins/taimux/taimux");
474        assert!(plugin_checkout(&inside));
475        let outside = home.join("workspaces/ai/taimux/taimux");
476        assert!(!plugin_checkout(&outside));
477        // …and the manager's own path, wherever it points
478        std::env::set_var("TMUX_PLUGIN_MANAGER_PATH", home.join("elsewhere"));
479        assert!(plugin_checkout(&home.join("elsewhere/taimux/taimux")));
480        std::env::remove_var("TMUX_PLUGIN_MANAGER_PATH");
481        std::env::remove_var("HOME");
482        let _ = std::fs::remove_dir_all(&home);
483    }
484}
485
486/// Register the hook for the turn boundaries, and for a tool having run.
487///
488/// **Through `jq`, deliberately**, and this is the one place a fork is the right
489/// answer rather than a leftover. `settings.json` is a file the AGENT rewrites
490/// for itself, so anything that mangles it is a config lost, and hand-rolled JSON
491/// editing over somebody's real configuration is exactly how that happens. jq
492/// gets the merge right, preserves everything it does not touch, and refuses an
493/// unparseable file rather than replacing it.
494///
495/// The command string is what makes the entry idempotent, so it has to be spelled
496/// the same way every time: a config managed from somewhere else (chezmoi) that
497/// spells it differently would otherwise end up with the hook registered twice,
498/// firing twice per event. Which is why a spelling already IN the file wins over
499/// this binary's own path. `current_exe()` resolves symlinks, so a file holding
500/// `~/.local/bin/taimux hook`, written when the launcher was installed there,
501/// would otherwise gain a second entry under the build path the symlink points
502/// at the first time anyone adds an event. Found by adding two.
503pub fn install_hooks(exe: &str) -> i32 {
504    // The two tool events carry no `matcher`, which is what the entry below
505    // writes, and a matcher-less entry fires for every tool: checked against a
506    // live session rather than assumed, since a matcher that matched nothing
507    // would register cleanly and then simply never fire.
508    const EVENTS: &str =
509        "SessionStart UserPromptSubmit Stop PermissionRequest SessionEnd PostToolUse PostToolUseFailure";
510    let home = std::env::var("HOME").unwrap_or_default();
511    let dir = std::env::var("CLAUDE_CONFIG_DIR").unwrap_or_else(|_| format!("{}/.claude", home));
512    let settings = PathBuf::from(&dir).join("settings.json");
513    let cmd = format!("{} hook", exe);
514
515    if which("jq").is_none() {
516        println!("jq not found, so settings.json is left alone. Add this by hand:\n");
517        println!("  command: {}\n  events:  {}\n", cmd, EVENTS);
518        return 1;
519    }
520    let _ = std::fs::create_dir_all(&dir);
521    if !settings.exists() {
522        let _ = std::fs::write(&settings, "{}\n");
523    }
524    let prog = r#"
525        def ensure($event; $c):
526          .hooks //= {}
527          | .hooks[$event] //= []
528          | if [.hooks[$event][]?.hooks[]?.command] | index($c) then .
529            else .hooks[$event] += [{hooks: [{type: "command", command: $c}]}]
530            end;
531        ( [.hooks[]?[]?.hooks[]?.command // empty]
532          | map(select(test("(^|/)taimux hook$")))
533          | first ) as $found
534        | reduce ($events | split(" ")[]) as $e (.; ensure($e; $found // $cmd))
535    "#;
536    let out = std::process::Command::new("jq")
537        .args([
538            "--indent", "2", "--arg", "cmd", &cmd, "--arg", "events", EVENTS, prog,
539        ])
540        .arg(&settings)
541        .output();
542    let Ok(out) = out else {
543        eprintln!("could not run jq");
544        return 1;
545    };
546    // An unparseable settings file is left exactly as it was. It is not ours to
547    // repair, and replacing it would lose whatever is in there.
548    if !out.status.success() || out.stdout.is_empty() {
549        println!(
550            "{} is not valid JSON, so it was left alone.",
551            settings.display()
552        );
553        return 1;
554    }
555    let tmp = settings.with_extension(format!("taimux.{}", std::process::id()));
556    if std::fs::write(&tmp, &out.stdout).is_err() || std::fs::rename(&tmp, &settings).is_err() {
557        let _ = std::fs::remove_file(&tmp);
558        eprintln!("could not write {}", settings.display());
559        return 1;
560    }
561    println!(
562        "registered `{}` for {} in {}",
563        registered_cmd(&out.stdout, &cmd),
564        EVENTS,
565        settings.display()
566    );
567    println!("Sessions already running keep reporting nothing until they restart.");
568    0
569}
570
571/// The command string that actually landed, read back out of what jq produced.
572///
573/// A spelling already in the file wins over this binary's path, so what was asked
574/// for and what is now registered are not always the same string, and the line
575/// printed afterwards should be the one somebody could go and look for.
576fn registered_cmd(settings_json: &[u8], fallback: &str) -> String {
577    let text = String::from_utf8_lossy(settings_json);
578    let Some(end) = text.find("taimux hook\"") else {
579        return fallback.to_string();
580    };
581    let head = &text[..end + "taimux hook".len()];
582    match head.rfind('"') {
583        Some(q) => head[q + 1..].to_string(),
584        None => fallback.to_string(),
585    }
586}
587
588/// Is a program on PATH? Our own, so the check costs no fork.
589fn which(name: &str) -> Option<PathBuf> {
590    use std::os::unix::fs::PermissionsExt;
591    for d in std::env::var("PATH").unwrap_or_default().split(':') {
592        if d.is_empty() {
593            continue;
594        }
595        let p = Path::new(d).join(name);
596        if p.is_file()
597            && std::fs::metadata(&p)
598                .map(|m| m.permissions().mode() & 0o111 != 0)
599                .unwrap_or(false)
600        {
601            return Some(p);
602        }
603    }
604    None
605}
606
607#[cfg(test)]
608mod which_tests {
609    use super::*;
610
611    /// A file that is there but NOT executable is not a program, which is the
612    /// difference between "jq is missing" and "jq is broken".
613    #[test]
614    fn an_executable_on_path_is_found_and_a_plain_file_is_not() {
615        assert!(which("sh").is_some());
616        assert!(which("no-such-program-anywhere").is_none());
617        let d = std::env::temp_dir().join(format!("jmwhich{}", std::process::id()));
618        std::fs::create_dir_all(&d).unwrap();
619        std::fs::write(d.join("notexec"), "x").unwrap();
620        let _g = taimux_core::env::ENV_LOCK.lock().unwrap();
621        let old = std::env::var("PATH").unwrap_or_default();
622        std::env::set_var("PATH", d.to_string_lossy().as_ref());
623        assert!(which("notexec").is_none());
624        std::env::set_var("PATH", old);
625        let _ = std::fs::remove_dir_all(&d);
626    }
627}