ditto-cli 0.4.2

Switch between Claude Code, Codex, opencode, OMP, Prime Agent, Pi, and 29 more coding-agent accounts
//! The shell functions that put Ditto in front of the tools it launches.
//!
//! A directory's binding decides something only for a launch that goes through
//! Ditto, and the name every user's fingers already know is the tool's own.
//! Nothing Ditto does at launch time can reach that case: typing `omp` runs OMP,
//! which has never heard of `.ditto.toml`, and Ditto is not in the process to be
//! asked. The shell is the only place the bare name can be intercepted, so Ditto
//! writes the functions rather than leaving every user to write them again.
//!
//! Ditto starts a tool through `execvp`, which searches `PATH` and never looks at
//! shell functions, so a function named after a tool cannot arrive back at
//! itself. That is what makes the interception safe rather than a loop.

use std::env;

use anyhow::{Result, bail};

use crate::{cli::ShellKind, launch::Tool};

/// Named for where it came from, because it lands in the user's shell alongside
/// everything else they have defined and has to be recognisable there.
const HELPER: &str = "__ditto_launch";

/// The shell to write for when the command names none.
///
/// `SHELL` is the login shell rather than the running one, so it is the wrong
/// answer for a shell started inside another. It is also the only answer
/// available, since a process cannot see what started it by name. Naming the
/// shell is the way out of both, so an unrecognised value asks for one instead
/// of guessing and writing syntax the shell cannot read.
pub fn detect() -> Result<ShellKind> {
    let shell = env::var("SHELL").unwrap_or_default();
    match from_path(&shell) {
        Some(shell) => Ok(shell),
        None if shell.is_empty() => {
            bail!("SHELL is not set; name the shell, as in `ditto-cli shell-init zsh`")
        }
        None => {
            bail!("'{shell}' is not a shell Ditto can write functions for; name bash, fish, or zsh")
        }
    }
}

/// The shell behind a path like `/bin/zsh` or `/opt/homebrew/bin/fish`, or
/// behind the `-zsh` that a login shell is handed as its own name.
fn from_path(shell: &str) -> Option<ShellKind> {
    match shell.rsplit('/').next()?.trim_start_matches('-') {
        "bash" => Some(ShellKind::Bash),
        "fish" => Some(ShellKind::Fish),
        "zsh" => Some(ShellKind::Zsh),
        _ => None,
    }
}

pub fn script(shell: ShellKind) -> String {
    let body = match shell {
        ShellKind::Bash | ShellKind::Zsh => posix_body(),
        ShellKind::Fish => fish_body(),
    };
    format!("{}{body}", preamble(shell))
}

/// The generated script is read far more often than it is generated: it sits in
/// a shell configuration file, or is printed by someone working out where a
/// function came from. So it carries the same explanation the documentation
/// does, including how to get a tool back without Ditto in front of it.
fn preamble(shell: ShellKind) -> String {
    format!(
        "# Ditto CLI shell integration, generated by `ditto-cli shell-init {name}`.\n\
         #\n\
         # Each function puts Ditto in front of the tool it is named for, so the\n\
         # profile a directory is bound to decides `omp` as much as it already\n\
         # decides `ditto-cli omp`.\n\
         #\n\
         # To launch another profile once, name it: `ditto-cli omp <profile>`.\n\
         # To leave Ditto out of a single run, bypass the function: `command omp`.\n\
         #\n\
         # Load it from {rc}:\n\
         #   {load}\n\
         \n",
        name = key(shell),
        rc = rc_file(shell),
        load = load_line(shell),
    )
}

/// Every tool gets a function, taken from [`Tool::ALL`] rather than listed here,
/// so a tool added to Ditto is not one users are left typing around.
fn posix_body() -> String {
    let mut body = format!(
        "{HELPER}() {{\n\
        \x20 local tool=$1\n\
        \x20 shift\n\
        \x20 # A missing Ditto, from a half-finished update or a PATH not yet\n\
        \x20 # read, should not take the tool down with it.\n\
        \x20 if command -v ditto-cli >/dev/null 2>&1; then\n\
        \x20   command ditto-cli \"$tool\" -- \"$@\"\n\
        \x20 else\n\
        \x20   command \"$tool\" \"$@\"\n\
        \x20 fi\n\
         }}\n\
         \n"
    );

    for tool in Tool::ALL {
        let name = tool.key();
        body.push_str(&format!("{name}() {{ {HELPER} {name} \"$@\"; }}\n"));
    }
    body
}

fn fish_body() -> String {
    let mut body = format!(
        "function {HELPER}\n\
        \x20   set --local tool $argv[1]\n\
        \x20   set --erase argv[1]\n\
        \x20   # A missing Ditto, from a half-finished update or a PATH not yet\n\
        \x20   # read, should not take the tool down with it.\n\
        \x20   if command --query ditto-cli\n\
        \x20       command ditto-cli $tool -- $argv\n\
        \x20   else\n\
        \x20       command $tool $argv\n\
        \x20   end\n\
         end\n\
         \n"
    );

    for tool in Tool::ALL {
        let name = tool.key();
        body.push_str(&format!(
            "function {name}\n\
            \x20   {HELPER} {name} $argv\n\
             end\n"
        ));
    }
    body
}

/// The name the shell answers to on the command line, which is also the name
/// the generated script quotes back in its own comments.
fn key(shell: ShellKind) -> &'static str {
    match shell {
        ShellKind::Bash => "bash",
        ShellKind::Fish => "fish",
        ShellKind::Zsh => "zsh",
    }
}

fn rc_file(shell: ShellKind) -> &'static str {
    match shell {
        ShellKind::Bash => "~/.bashrc",
        ShellKind::Fish => "~/.config/fish/config.fish",
        ShellKind::Zsh => "~/.zshrc",
    }
}

/// Fish has no `eval` reading standard input the way the others do, and pipes
/// the command into `source` instead.
fn load_line(shell: ShellKind) -> String {
    match shell {
        ShellKind::Fish => "ditto-cli shell-init fish | source".to_owned(),
        shell => format!("eval \"$(ditto-cli shell-init {})\"", key(shell)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reads_the_shell_out_of_the_path_it_is_named_by() {
        assert_eq!(from_path("/bin/zsh"), Some(ShellKind::Zsh));
        assert_eq!(from_path("/usr/bin/bash"), Some(ShellKind::Bash));
        assert_eq!(from_path("/opt/homebrew/bin/fish"), Some(ShellKind::Fish));

        // A login shell is given its own name with a leading dash, which is the
        // form `SHELL` carries on some systems.
        assert_eq!(from_path("-zsh"), Some(ShellKind::Zsh));

        // Anything else is asked about rather than guessed at, since guessing
        // writes syntax the shell reading it cannot parse.
        assert_eq!(from_path("/bin/sh"), None);
        assert_eq!(from_path("/usr/bin/pwsh"), None);
        assert_eq!(from_path(""), None);
    }

    #[test]
    fn writes_a_function_for_every_tool_ditto_launches() {
        // The functions are generated from `Tool::ALL`, so this fails when a
        // tool is added to Ditto without one, which is the whole point of
        // generating them rather than listing them.
        let posix = script(ShellKind::Zsh);
        let fish = script(ShellKind::Fish);
        for tool in Tool::ALL {
            let name = tool.key();
            assert!(
                posix.contains(&format!("\n{name}() {{ {HELPER} {name} \"$@\"; }}")),
                "no {name} function in {posix}"
            );
            assert!(
                fish.contains(&format!("\nfunction {name}\n")),
                "no {name} function in {fish}"
            );
        }
    }

    #[test]
    fn tells_each_shell_how_to_load_the_script_it_was_given() {
        // Copying the line out of the comment is how this gets installed, so a
        // line naming the wrong shell is a broken instruction rather than a
        // cosmetic slip.
        assert!(script(ShellKind::Zsh).contains("eval \"$(ditto-cli shell-init zsh)\""));
        assert!(script(ShellKind::Bash).contains("eval \"$(ditto-cli shell-init bash)\""));
        assert!(script(ShellKind::Fish).contains("ditto-cli shell-init fish | source"));

        assert!(script(ShellKind::Zsh).contains("~/.zshrc"));
        assert!(script(ShellKind::Fish).contains("~/.config/fish/config.fish"));
    }

    #[test]
    fn keeps_the_tool_reachable_when_ditto_is_not() {
        // The functions shadow the tools' own names, so a Ditto that cannot be
        // found has to fall through to the tool rather than fail the command.
        for shell in [ShellKind::Bash, ShellKind::Zsh] {
            assert!(script(shell).contains("command \"$tool\" \"$@\""));
        }
        assert!(script(ShellKind::Fish).contains("command $tool $argv"));
    }
}