// Prompt customization module
//
// Generates shell-specific prompt modifications to display
// the active Stand environment.
use crate::shell::detector::ShellType;
use std::collections::HashMap;
/// Environment variable for Stand prompt prefix
pub const STAND_PROMPT: &str = "STAND_PROMPT";
/// Environment variable to enable auto-exit when leaving project directory
pub const STAND_AUTO_EXIT: &str = "STAND_AUTO_EXIT";
/// Generate the prompt prefix for displaying the active environment
///
/// Returns a string like "(stand:dev) " that can be prepended to PS1
pub fn generate_prompt_prefix(env_name: &str) -> String {
format!("(stand:{}) ", env_name)
}
/// Get environment variables needed for prompt customization
///
/// Returns a HashMap of environment variables to set based on shell type.
/// Each shell type has a different mechanism for modifying the prompt.
pub fn get_prompt_env_vars(shell_type: &ShellType, env_name: &str) -> HashMap<String, String> {
let mut vars = HashMap::new();
let prefix = generate_prompt_prefix(env_name);
// Set STAND_PROMPT for all shells (can be used in custom prompts)
vars.insert(STAND_PROMPT.to_string(), prefix.clone());
match shell_type {
ShellType::Bash => {
// For bash, PROMPT_COMMAND runs before each prompt display.
// We capture the original PS1 on first run, then prepend our prefix with color.
// Uses $STAND_ENVIRONMENT and $STAND_ENV_COLOR for dynamic values.
// Color codes: bold=1, reverse=7, green=32, reset=0
// Note: Using tr for uppercase conversion for compatibility with Bash 3.x (macOS default)
//
// Directory guard (when STAND_AUTO_EXIT=1):
// Checks if current directory is still within STAND_PROJECT_ROOT.
// If outside, reverts to the previous directory and shows a warning.
// Uses logical paths ($PWD) instead of physical paths (pwd -P) to allow
// symlinks within the project to work as expected.
let prompt_command = r#"if [ -z "$_stand_prev_dir" ]; then _stand_prev_dir="$PWD"; fi; if [ -z "$STAND_ORIGINAL_PS1" ]; then export STAND_ORIGINAL_PS1="$PS1"; fi; if [ "$STAND_AUTO_EXIT" = "1" ] && [ -n "$STAND_PROJECT_ROOT" ]; then case "$PWD" in "$STAND_PROJECT_ROOT"|"$STAND_PROJECT_ROOT"/*) _stand_prev_dir="$PWD";; *) if ! cd "$_stand_prev_dir" 2>/dev/null; then if ! cd "$STAND_PROJECT_ROOT" 2>/dev/null; then echo "⚠️ Cannot return to project directory. Exiting Stand shell."; exit 1; fi; fi; echo "⚠️ Cannot leave project directory while in Stand shell."; echo " Type 'exit' to leave the Stand shell first.";; esac; fi; _c="${STAND_ENV_COLOR:-green}"; case "$_c" in red) _cc=31;; green) _cc=32;; yellow) _cc=33;; blue) _cc=34;; magenta|purple) _cc=35;; cyan) _cc=36;; *) _cc=32;; esac; _env_upper=$(echo "$STAND_ENVIRONMENT" | tr '[:lower:]' '[:upper:]'); PS1=$'\n\e[1;7;'"$_cc"'m stand:'"$_env_upper"$' \e[0m'"$STAND_ORIGINAL_PS1""#;
vars.insert("PROMPT_COMMAND".to_string(), prompt_command.to_string());
}
ShellType::Zsh => {
// Zsh: Set STAND_ZSH_PRECMD which will be evaled by the spawner's init command.
// This ensures our precmd runs after .zshrc has loaded.
}
ShellType::Fish => {
// Fish handles prompts via fish_prompt function, not environment variables.
// We set STAND_PROMPT here, and the spawner injects an init command
// that wraps the existing fish_prompt to prepend STAND_PROMPT.
}
ShellType::Other(_) => {
// For other shells (sh, dash, etc.), try basic PS1 modification
vars.insert("PS1".to_string(), format!("{}$ ", prefix));
}
}
vars
}
/// Generate a colored prompt prefix with ANSI escape codes
///
/// Uses green color for the environment name
pub fn generate_colored_prompt_prefix(env_name: &str, color: Option<&str>) -> String {
let color_code = match color {
Some("red") => "\x1b[31m",
Some("green") => "\x1b[32m",
Some("yellow") => "\x1b[33m",
Some("blue") => "\x1b[34m",
Some("magenta") | Some("purple") => "\x1b[35m",
Some("cyan") => "\x1b[36m",
_ => "\x1b[32m", // Default to green
};
let reset = "\x1b[0m";
format!("({}stand:{}{}){} ", color_code, env_name, reset, reset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_prompt_prefix() {
assert_eq!(generate_prompt_prefix("dev"), "(stand:dev) ");
assert_eq!(generate_prompt_prefix("production"), "(stand:production) ");
assert_eq!(generate_prompt_prefix("staging"), "(stand:staging) ");
}
#[test]
fn test_get_prompt_env_vars_includes_stand_prompt() {
let vars = get_prompt_env_vars(&ShellType::Bash, "dev");
assert_eq!(vars.get(STAND_PROMPT), Some(&"(stand:dev) ".to_string()));
}
#[test]
fn test_get_prompt_env_vars_bash_sets_prompt_command() {
let vars = get_prompt_env_vars(&ShellType::Bash, "dev");
assert!(vars.contains_key("PROMPT_COMMAND"));
// Should capture original PS1 before modifying
let prompt_cmd = vars.get("PROMPT_COMMAND").unwrap();
assert!(prompt_cmd.contains("STAND_ORIGINAL_PS1"));
// Uses $STAND_ENVIRONMENT variable instead of embedded name for safety
assert!(prompt_cmd.contains("STAND_ENVIRONMENT"));
// Uses $STAND_ENV_COLOR for color customization
assert!(prompt_cmd.contains("STAND_ENV_COLOR"));
}
#[test]
fn test_get_prompt_env_vars_bash_includes_directory_guard() {
let vars = get_prompt_env_vars(&ShellType::Bash, "dev");
let prompt_cmd = vars.get("PROMPT_COMMAND").unwrap();
// Should check STAND_AUTO_EXIT and STAND_PROJECT_ROOT
assert!(prompt_cmd.contains("STAND_AUTO_EXIT"));
assert!(prompt_cmd.contains("STAND_PROJECT_ROOT"));
// Should track previous directory
assert!(prompt_cmd.contains("_stand_prev_dir"));
// Should revert to previous directory and show warning
assert!(prompt_cmd.contains("Cannot leave project directory"));
}
#[test]
fn test_get_prompt_env_vars_zsh_only_sets_stand_prompt() {
let vars = get_prompt_env_vars(&ShellType::Zsh, "staging");
// STAND_PROMPT is set for all shells
assert_eq!(
vars.get(STAND_PROMPT),
Some(&"(stand:staging) ".to_string())
);
// Zsh prompt customization is handled via ZDOTDIR in spawner,
// so only STAND_PROMPT is set here
assert!(!vars.contains_key("RPS1"));
assert!(!vars.contains_key("PROMPT"));
}
#[test]
fn test_get_prompt_env_vars_fish_only_sets_stand_prompt() {
let vars = get_prompt_env_vars(&ShellType::Fish, "prod");
assert_eq!(vars.get(STAND_PROMPT), Some(&"(stand:prod) ".to_string()));
// Fish doesn't use PROMPT_COMMAND or PS1
assert!(!vars.contains_key("PROMPT_COMMAND"));
assert!(!vars.contains_key("PS1"));
}
#[test]
fn test_get_prompt_env_vars_other_sets_ps1() {
let vars = get_prompt_env_vars(&ShellType::Other("sh".to_string()), "dev");
assert!(vars.contains_key("PS1"));
let ps1 = vars.get("PS1").unwrap();
assert!(ps1.contains("(stand:dev)"));
}
#[test]
fn test_generate_colored_prompt_prefix_default_green() {
let prefix = generate_colored_prompt_prefix("dev", None);
assert!(prefix.contains("\x1b[32m")); // Green
assert!(prefix.contains("stand:dev"));
assert!(prefix.contains("\x1b[0m")); // Reset
}
#[test]
fn test_generate_colored_prompt_prefix_red() {
let prefix = generate_colored_prompt_prefix("prod", Some("red"));
assert!(prefix.contains("\x1b[31m")); // Red
}
#[test]
fn test_generate_colored_prompt_prefix_magenta() {
let prefix = generate_colored_prompt_prefix("staging", Some("magenta"));
assert!(prefix.contains("\x1b[35m")); // Magenta
}
}