php-version-manager 1.3.1

A blazing fast, zero-configuration PHP version manager
use crate::constants::{ENV_UPDATE_FILE, PVM_DIR_VAR};
use std::path::Path;

pub trait Shell {
    fn path(&self, path: &Path) -> String;
    fn set_env_var(&self, name: &str, value: &str) -> String;
    fn use_on_cd(&self) -> String;
    fn wrapper_fn(&self) -> String;
}

/// Quote a string for POSIX shells (bash/zsh) by wrapping it in single quotes
/// and escaping any embedded single quotes via the `'\''` idiom.
fn posix_single_quote(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('\'');
    for c in value.chars() {
        if c == '\'' {
            out.push_str("'\\''");
        } else {
            out.push(c);
        }
    }
    out.push('\'');
    out
}

/// Quote a string for fish by wrapping it in single quotes and escaping `\` and `'`.
fn fish_single_quote(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('\'');
    for c in value.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '\'' => out.push_str("\\'"),
            other => out.push(other),
        }
    }
    out.push('\'');
    out
}

pub struct Bash;

impl Shell for Bash {
    fn path(&self, path: &Path) -> String {
        format!(
            "export PATH={}:\"$PATH\"",
            posix_single_quote(&path.display().to_string())
        )
    }

    fn set_env_var(&self, name: &str, value: &str) -> String {
        format!("export {}={}", name, posix_single_quote(value))
    }

    fn use_on_cd(&self) -> String {
        "
_pvm_cd_hook() {
  if [[ -f .php-version ]]; then
    pvm use --silent \"$(cat .php-version)\" || true
  fi
}
if [[ -n \"$BASH_VERSION\" ]]; then
  if [[ ! \"$PROMPT_COMMAND\" == *\"_pvm_cd_hook\"* ]]; then
    PROMPT_COMMAND=\"_pvm_cd_hook; ${PROMPT_COMMAND:-}\"
  fi
fi
"
        .to_string()
    }

    fn wrapper_fn(&self) -> String {
        format!(
            "
export PATH=\"${{{}}}/bin:$PATH\"

pvm() {{
  local command=$1
  if [[ \"$command\" == \"env\" ]]; then
    command pvm \"$@\"
  else
    if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then
      local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\"
      [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null
      PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\"
      local exit_code=$?
      if [[ -f \"$env_file\" ]]; then
        eval \"$(cat \"$env_file\")\"
        command rm -f \"$env_file\" 2>/dev/null
      fi
      return $exit_code
    else
      command pvm \"$@\"
    fi
  fi
}}
",
            PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE
        )
    }
}

pub struct Zsh;

impl Shell for Zsh {
    fn path(&self, path: &Path) -> String {
        format!(
            "export PATH={}:\"$PATH\"",
            posix_single_quote(&path.display().to_string())
        )
    }

    fn set_env_var(&self, name: &str, value: &str) -> String {
        format!("export {}={}", name, posix_single_quote(value))
    }

    fn use_on_cd(&self) -> String {
        "
_pvm_cd_hook() {
  if [[ -f .php-version ]]; then
    pvm use --silent \"$(cat .php-version)\" || true
  fi
}
autoload -U add-zsh-hook
add-zsh-hook chpwd _pvm_cd_hook
"
        .to_string()
    }

    fn wrapper_fn(&self) -> String {
        format!(
            "
export PATH=\"${{{}}}/bin:$PATH\"

pvm() {{
  local command=$1
  if [[ \"$command\" == \"env\" ]]; then
    command pvm \"$@\"
  else
    if [[ -n \"${{{}}}\" && -d \"${{{}}}\" ]]; then
      local env_file=\"${{{}}}/{}_$$_${{RANDOM}}${{RANDOM}}_$(date +%s)\"
      [[ -f \"$env_file\" ]] && command rm -f \"$env_file\" 2>/dev/null
      PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm \"$@\"
      local exit_code=$?
      if [[ -f \"$env_file\" ]]; then
        eval \"$(cat \"$env_file\")\"
        command rm -f \"$env_file\" 2>/dev/null
      fi
      return $exit_code
    else
      command pvm \"$@\"
    fi
  fi
}}
",
            PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE
        )
    }
}

pub struct Fish;

impl Shell for Fish {
    fn path(&self, path: &Path) -> String {
        format!(
            "set -gx PATH {} $PATH",
            fish_single_quote(&path.display().to_string())
        )
    }

    fn set_env_var(&self, name: &str, value: &str) -> String {
        format!("set -gx {} {}", name, fish_single_quote(value))
    }

    fn use_on_cd(&self) -> String {
        "
function _pvm_cd_hook --on-variable PWD
    if test -f .php-version
        pvm use --silent (cat .php-version)
    end
end
"
        .to_string()
    }

    fn wrapper_fn(&self) -> String {
        format!(
            "
set -gx PATH \"${}/bin\" $PATH

function pvm
    set command $argv[1]
    if test \"$command\" = \"env\"
        command pvm $argv
    else
        if test -n \"${}\"; and test -d \"${}\"
            set env_file \"${}/{}_$fish_pid\"_(random)(random)_(date +%s)
            if test -f \"$env_file\"
                command rm -f \"$env_file\" &>/dev/null
            end
            PVM_ENV_UPDATE_PATH=\"$env_file\" command pvm $argv
            set exit_code $status
            if test -f \"$env_file\"
                source \"$env_file\"
                command rm -f \"$env_file\" &>/dev/null
            end
            return $exit_code
        else
            command pvm $argv
        end
    end
end
",
            PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, PVM_DIR_VAR, ENV_UPDATE_FILE
        )
    }
}

pub fn detect_shell() -> Box<dyn Shell> {
    let shell = std::env::var("SHELL").unwrap_or_default();
    if shell.ends_with("zsh") {
        Box::new(Zsh)
    } else if shell.ends_with("fish") {
        Box::new(Fish)
    } else {
        Box::new(Bash)
    }
}

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

    #[test]
    fn test_bash_path_generation() {
        let bash = Bash;
        let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin");
        assert_eq!(
            bash.path(path),
            "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin':\"$PATH\""
        );
    }

    #[test]
    fn test_bash_set_env() {
        let bash = Bash;
        assert_eq!(
            bash.set_env_var("PVM_MULTISHELL_PATH", "/some/path"),
            "export PVM_MULTISHELL_PATH='/some/path'"
        );
    }

    #[test]
    fn test_bash_set_env_escapes_special_chars() {
        let bash = Bash;
        assert_eq!(
            bash.set_env_var("X", "evil$(whoami)`id`\"$PATH\"'quote"),
            "export X='evil$(whoami)`id`\"$PATH\"'\\''quote'"
        );
    }

    #[test]
    fn test_zsh_path_generation() {
        let zsh = Zsh;
        let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin");
        assert_eq!(
            zsh.path(path),
            "export PATH='/home/user/.local/share/pvm/versions/8.3.1/bin':\"$PATH\""
        );
    }

    #[test]
    fn test_fish_path_generation() {
        let fish = Fish;
        let path = std::path::Path::new("/home/user/.local/share/pvm/versions/8.3.1/bin");
        assert_eq!(
            fish.path(path),
            "set -gx PATH '/home/user/.local/share/pvm/versions/8.3.1/bin' $PATH"
        );
    }

    #[test]
    fn test_fish_set_env_escapes_special_chars() {
        let fish = Fish;
        assert_eq!(fish.set_env_var("X", "a'b\\c"), "set -gx X 'a\\'b\\\\c'");
    }
}