use std::env;
use anyhow::{Result, bail};
use crate::{cli::ShellKind, launch::Tool};
const HELPER: &str = "__ditto_launch";
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")
}
}
}
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))
}
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),
)
}
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
}
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",
}
}
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));
assert_eq!(from_path("-zsh"), Some(ShellKind::Zsh));
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() {
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() {
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() {
for shell in [ShellKind::Bash, ShellKind::Zsh] {
assert!(script(shell).contains("command \"$tool\" \"$@\""));
}
assert!(script(ShellKind::Fish).contains("command $tool $argv"));
}
}