use crate::ShellType;
use crate::config::CompiledConfig;
use crate::plugin::PluginManager;
#[derive(Debug)]
pub struct ShellIntegration {
shell_type: ShellType,
config: CompiledConfig,
plugins: PluginManager,
}
impl ShellIntegration {
#[must_use]
pub fn new(shell_type: ShellType, config: CompiledConfig) -> Self {
let mut plugins = PluginManager::new();
for plugin_name in &config.plugins_enabled {
let _ = plugins.load(plugin_name);
}
Self {
shell_type,
config,
plugins,
}
}
#[must_use]
pub fn generate(&self) -> String {
let mut output = String::with_capacity(4096);
output.push_str(&self.generate_header());
output.push_str(&self.generate_env_exports());
output.push_str(&self.generate_aliases());
output.push_str(&self.generate_prompt());
output.push_str(&self.generate_completion_setup());
output.push_str(&self.generate_history_config());
output.push_str(&self.generate_keybindings());
output.push_str(&self.plugins.shell_init(self.shell_type));
output.push_str(&self.generate_footer());
output
}
fn generate_header(&self) -> String {
let shell_name = match self.shell_type {
ShellType::Zsh => "zsh",
ShellType::Bash => "bash",
};
format!(
"# pzsh shell integration for {shell_name}\n\
# Generated by pzsh v{}\n\
# Source with: eval \"$(pzsh init {shell_name})\"\n\n",
env!("CARGO_PKG_VERSION")
)
}
fn generate_env_exports(&self) -> String {
let mut output = String::from("# Environment variables\n");
for (key, value) in &self.config.env {
match self.shell_type {
ShellType::Zsh | ShellType::Bash => {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
output.push_str(&format!("export {key}=\"{escaped}\"\n"));
}
}
}
output.push('\n');
output
}
fn generate_aliases(&self) -> String {
let mut output = String::from("# Aliases\n");
for (name, expansion) in &self.config.aliases {
let escaped = expansion.replace('\'', "'\\''");
output.push_str(&format!("alias {name}='{escaped}'\n"));
}
for (name, expansion) in self.plugins.all_aliases() {
let escaped = expansion.replace('\'', "'\\''");
output.push_str(&format!("alias {name}='{escaped}'\n"));
}
output.push('\n');
output
}
fn generate_prompt(&self) -> String {
match self.shell_type {
ShellType::Zsh => self.generate_zsh_prompt(),
ShellType::Bash => self.generate_bash_prompt(),
}
}
fn generate_zsh_prompt(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from("# Prompt configuration\n");
output.push_str("setopt PROMPT_SUBST\n");
if colors_enabled {
output.push_str("autoload -U colors && colors\n\n");
output.push_str(
r#"# Git status (cached, fast)
__pzsh_git_info() {
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return
local dirty=""
[[ -n $(git status --porcelain 2>/dev/null) ]] && dirty="*"
if [[ -n "$dirty" ]]; then
echo "%F{yellow}($branch$dirty)%f"
else
echo "%F{green}($branch)%f"
fi
}
"#,
);
output.push_str("PROMPT='%F{green}%B%n%b%f@%F{blue}%B%m%b%f %F{cyan}%~%f $(__pzsh_git_info) %F{white}%B%#%b%f '\n");
} else {
output.push_str("PROMPT='%n@%m %~ %# '\n");
}
output.push('\n');
output
}
fn generate_bash_prompt(&self) -> String {
let colors_enabled = self.config.colors_enabled;
let mut output = String::from("# Prompt configuration\n");
if colors_enabled {
output.push_str(
r#"# Git status (cached, fast)
__pzsh_git_info() {
local branch
branch=$(git symbolic-ref --short HEAD 2>/dev/null) || return
local dirty=""
[[ -n $(git status --porcelain 2>/dev/null) ]] && dirty="*"
if [[ -n "$dirty" ]]; then
echo -e "\033[33m($branch$dirty)\033[0m"
else
echo -e "\033[32m($branch)\033[0m"
fi
}
"#,
);
output.push_str(r"PS1='\[\033[1;32m\]\u\[\033[0m\]@\[\033[1;34m\]\h\[\033[0m\] \[\033[36m\]\w\[\033[0m\] $(__pzsh_git_info) \[\033[1;37m\]\\$\[\033[0m\] '
");
} else {
output.push_str("PS1='\\u@\\h \\w \\$ '\n");
}
output.push('\n');
output
}
fn generate_completion_setup(&self) -> String {
match self.shell_type {
ShellType::Zsh => self.generate_zsh_completion(),
ShellType::Bash => self.generate_bash_completion(),
}
}
#[allow(clippy::unused_self)]
fn generate_zsh_completion(&self) -> String {
r#"# Completion system
autoload -Uz compinit
compinit -C -d "${ZDOTDIR:-$HOME}/.zcompdump"
# Completion styling
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}' 'r:|=*' 'l:|=* r:|=*'
zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}"
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'
zstyle ':completion:*:warnings' format '%F{red}-- no matches --%f'
# Case-insensitive completion
zstyle ':completion:*' matcher-list '' 'm:{a-zA-Z}={A-Za-z}'
"#
.to_string()
}
#[allow(clippy::unused_self)]
fn generate_bash_completion(&self) -> String {
r"# Completion system
if [[ -f /etc/bash_completion ]]; then
. /etc/bash_completion
elif [[ -f /usr/share/bash-completion/bash_completion ]]; then
. /usr/share/bash-completion/bash_completion
fi
# Case-insensitive completion
bind 'set completion-ignore-case on'
"
.to_string()
}
fn generate_history_config(&self) -> String {
match self.shell_type {
ShellType::Zsh => r#"# History configuration
HISTSIZE=50000
SAVEHIST=50000
HISTFILE="${ZDOTDIR:-$HOME}/.zsh_history"
setopt EXTENDED_HISTORY
setopt HIST_EXPIRE_DUPS_FIRST
setopt HIST_IGNORE_DUPS
setopt HIST_IGNORE_SPACE
setopt HIST_VERIFY
setopt SHARE_HISTORY
setopt INC_APPEND_HISTORY
"#
.to_string(),
ShellType::Bash => r#"# History configuration
HISTSIZE=50000
HISTFILESIZE=100000
HISTCONTROL=ignoreboth:erasedups
shopt -s histappend
PROMPT_COMMAND="history -a;$PROMPT_COMMAND"
"#
.to_string(),
}
}
fn generate_keybindings(&self) -> String {
match self.shell_type {
ShellType::Zsh => r"# Key bindings
bindkey -e # Emacs mode
bindkey '^[[A' history-search-backward
bindkey '^[[B' history-search-forward
bindkey '^R' history-incremental-search-backward
bindkey '^S' history-incremental-search-forward
bindkey '^[[1;5C' forward-word
bindkey '^[[1;5D' backward-word
bindkey '^[[H' beginning-of-line
bindkey '^[[F' end-of-line
bindkey '^[[3~' delete-char
"
.to_string(),
ShellType::Bash => r#"# Key bindings
bind '"\e[A": history-search-backward'
bind '"\e[B": history-search-forward'
bind '"\C-r": reverse-search-history'
"#
.to_string(),
}
}
#[allow(clippy::unused_self)]
fn generate_footer(&self) -> String {
"# pzsh loaded in <10ms\n\
# Run 'pzsh status' to verify\n"
.to_string()
}
}
#[must_use]
pub fn generate_init(shell_type: ShellType, config: CompiledConfig) -> String {
ShellIntegration::new(shell_type, config).generate()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> CompiledConfig {
let mut config = CompiledConfig::default();
config.colors_enabled = true;
config
.aliases
.insert("ll".to_string(), "ls -la".to_string());
config
.aliases
.insert("gs".to_string(), "git status".to_string());
config.env.insert("EDITOR".to_string(), "vim".to_string());
config.plugins_enabled = vec!["git".to_string()];
config
}
#[test]
fn test_zsh_init_contains_header() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("pzsh shell integration for zsh"));
assert!(output.contains("eval \"$(pzsh init zsh)\""));
}
#[test]
fn test_zsh_init_exports_env_vars() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("export EDITOR=\"vim\""));
}
#[test]
fn test_zsh_init_defines_aliases() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias ll='ls -la'"));
assert!(output.contains("alias gs='git status'"));
}
#[test]
fn test_zsh_init_includes_plugin_aliases() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias g='git'"));
}
#[test]
fn test_zsh_init_sets_colored_prompt() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("PROMPT="));
assert!(output.contains("%F{green}"));
assert!(output.contains("__pzsh_git_info"));
}
#[test]
fn test_zsh_init_configures_completion() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("autoload -Uz compinit"));
assert!(output.contains("compinit -C"));
assert!(output.contains("zstyle ':completion:*'"));
}
#[test]
fn test_zsh_init_configures_history() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("HISTSIZE=50000"));
assert!(output.contains("SAVEHIST=50000"));
assert!(output.contains("setopt SHARE_HISTORY"));
}
#[test]
fn test_zsh_init_sets_keybindings() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("bindkey -e"));
assert!(output.contains("history-search-backward"));
assert!(output.contains("'^R'"));
}
#[test]
fn test_zsh_init_no_colors() {
let mut config = test_config();
config.colors_enabled = false;
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("PROMPT='%n@%m %~ %# '"));
assert!(!output.contains("%F{green}"));
}
#[test]
fn test_bash_init_contains_header() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("pzsh shell integration for bash"));
}
#[test]
fn test_bash_init_exports_env_vars() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("export EDITOR=\"vim\""));
}
#[test]
fn test_bash_init_defines_aliases() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("alias ll='ls -la'"));
}
#[test]
fn test_bash_init_sets_colored_prompt() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("PS1="));
assert!(output.contains("\\033["));
assert!(output.contains("__pzsh_git_info"));
}
#[test]
fn test_bash_init_configures_history() {
let config = test_config();
let output = generate_init(ShellType::Bash, config);
assert!(output.contains("HISTSIZE=50000"));
assert!(output.contains("HISTCONTROL=ignoreboth"));
assert!(output.contains("shopt -s histappend"));
}
#[test]
fn test_alias_with_quotes_escaped() {
let mut config = CompiledConfig::default();
config
.aliases
.insert("test".to_string(), "echo 'hello world'".to_string());
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("alias test='echo '\\''hello world'\\'''"));
}
#[test]
fn test_env_with_quotes_escaped() {
let mut config = CompiledConfig::default();
config
.env
.insert("MSG".to_string(), "say \"hello\"".to_string());
let output = generate_init(ShellType::Zsh, config);
assert!(output.contains("export MSG=\"say \\\"hello\\\"\""));
}
#[test]
fn test_generation_is_fast() {
let config = test_config();
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = generate_init(ShellType::Zsh, config.clone());
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"Generation too slow: {:?}",
elapsed
);
}
#[test]
fn test_output_size_reasonable() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
assert!(
output.len() < 10240,
"Output too large: {} bytes",
output.len()
);
}
#[test]
fn test_output_is_valid_shell_syntax() {
let config = test_config();
let output = generate_init(ShellType::Zsh, config);
let open_braces: Vec<_> = output.match_indices("${").collect();
for (pos, _) in open_braces {
let rest = &output[pos..];
assert!(rest.contains('}'), "Unclosed ${{ at position {pos}");
}
assert!(!output.contains(";;"), "Should not have empty commands");
}
#[test]
fn test_deterministic_output() {
let config = test_config();
let output1 = generate_init(ShellType::Zsh, config.clone());
let output2 = generate_init(ShellType::Zsh, config);
assert!(output1.contains("# pzsh shell integration"));
assert!(output2.contains("# pzsh shell integration"));
}
}