use crate::config::loader;
use crate::config::types::NestedBehavior;
use crate::crypto::decrypt_variables;
use crate::shell::{
build_shell_environment, detect_user_shell, get_active_environment, is_stand_shell_active,
spawn_shell,
};
use anyhow::{anyhow, Result};
use std::io::{self, IsTerminal, Write};
use std::path::Path;
fn is_interactive_terminal() -> bool {
if std::env::var("STAND_FORCE_NON_TTY").is_ok() {
return false;
}
io::stdin().is_terminal()
}
fn prompt_confirmation(env_name: &str) -> Result<bool> {
print!(
"Environment '{}' requires confirmation.\nAre you sure you want to proceed? [y/N]: ",
env_name
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
Ok(response == "y" || response == "yes")
}
fn check_nesting_allowed(behavior: Option<NestedBehavior>, current_env: &str) -> Result<bool> {
let behavior = behavior.unwrap_or(NestedBehavior::Prevent);
match behavior {
NestedBehavior::Prevent => Err(anyhow!(
"Already inside a Stand shell (environment: '{}').\n\
Exit the current shell first, or use 'stand exec' for one-off commands.\n\
Tip: Set nested_shell_behavior = \"allow\" in settings to permit nesting.",
current_env
)),
NestedBehavior::Warn => {
eprintln!(
"Warning: Already inside a Stand shell (environment: '{}').\n\
Continuing with nested shell...",
current_env
);
Ok(true)
}
NestedBehavior::Allow => Ok(true),
}
}
#[derive(Debug)]
pub struct ValidatedShellEnv {
pub shell_path: String,
pub env_vars: std::collections::HashMap<String, String>,
pub env_name: String,
}
pub fn validate_shell_environment(
project_path: &Path,
env_name: &str,
skip_confirmation: bool,
shell_override: Option<String>,
) -> Result<ValidatedShellEnv> {
let config = loader::load_config_toml_with_inheritance(project_path)?;
if is_stand_shell_active() {
let current_env = get_active_environment().unwrap_or_else(|| "unknown".to_string());
check_nesting_allowed(config.settings.nested_shell_behavior, ¤t_env)?;
}
let env = config.environments.get(env_name).ok_or_else(|| {
let mut available: Vec<_> = config.environments.keys().cloned().collect();
available.sort();
anyhow!(
"Environment '{}' not found. Available: {}",
env_name,
available.join(", ")
)
})?;
if env.requires_confirmation.unwrap_or(false) && !skip_confirmation {
if !is_interactive_terminal() {
return Err(anyhow!(
"Environment '{}' requires confirmation but stdin is not a terminal.\n\
Use -y or --yes to skip confirmation in non-interactive environments.",
env_name
));
}
if !prompt_confirmation(env_name)? {
return Err(anyhow!(
"Execution cancelled. Use -y or --yes to skip confirmation."
));
}
}
let shell_path = shell_override.unwrap_or_else(detect_user_shell);
let decrypted_vars = decrypt_variables(env.variables.clone(), project_path)
.map_err(|e| anyhow!("Failed to decrypt variables: {}", e))?;
let project_root = project_path
.to_str()
.ok_or_else(|| anyhow!("Invalid project path"))?;
let mut shell_env =
build_shell_environment(decrypted_vars, env_name, project_root, &shell_path);
if let Some(ref color) = env.color {
shell_env.insert("STAND_ENV_COLOR".to_string(), color.clone());
}
if config.settings.auto_exit_on_dir_change != Some(false) {
shell_env.insert("STAND_AUTO_EXIT".to_string(), "1".to_string());
}
Ok(ValidatedShellEnv {
shell_path,
env_vars: shell_env,
env_name: env_name.to_string(),
})
}
pub fn start_shell_with_environment(
project_path: &Path,
env_name: &str,
skip_confirmation: bool,
shell_override: Option<String>,
) -> Result<i32> {
let validated =
validate_shell_environment(project_path, env_name, skip_confirmation, shell_override)?;
eprintln!(
"Starting shell with environment '{}'. Type 'exit' to return.",
validated.env_name
);
spawn_shell(&validated.shell_path, validated.env_vars)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_check_nesting_allowed_prevent_returns_error() {
let result = check_nesting_allowed(Some(NestedBehavior::Prevent), "dev");
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Already inside a Stand shell"));
assert!(error_msg.contains("dev"));
}
#[test]
fn test_check_nesting_allowed_allow_returns_ok() {
let result = check_nesting_allowed(Some(NestedBehavior::Allow), "dev");
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_check_nesting_allowed_warn_returns_ok() {
let result = check_nesting_allowed(Some(NestedBehavior::Warn), "dev");
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_check_nesting_allowed_default_is_prevent() {
let result = check_nesting_allowed(None, "dev");
assert!(result.is_err());
}
#[test]
#[serial]
fn test_shell_nonexistent_environment() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[environments.dev]
description = "Development environment"
DATABASE_URL = "postgres://localhost:5432/dev"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "nonexistent", false, None);
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Environment 'nonexistent' not found"));
assert!(error_msg.contains("Available: dev"));
}
#[test]
#[serial]
fn test_shell_detects_nesting() {
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
nested_shell_behavior = "prevent"
[environments.dev]
description = "Development environment"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
env::set_var("STAND_ACTIVE", "1");
env::set_var("STAND_ENVIRONMENT", "production");
let result = validate_shell_environment(dir.path(), "dev", false, None);
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("Already inside a Stand shell"));
assert!(error_msg.contains("production"));
}
#[test]
#[serial]
fn test_shell_allows_nesting_when_configured() {
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[settings]
nested_shell_behavior = "allow"
[environments.dev]
description = "Development environment"
TEST_VAR = "test_value"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
env::set_var("STAND_ACTIVE", "1");
env::set_var("STAND_ENVIRONMENT", "production");
let result = validate_shell_environment(dir.path(), "dev", false, None);
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.env_name, "dev");
assert!(validated.env_vars.contains_key("TEST_VAR"));
assert!(validated.env_vars.contains_key("STAND_ACTIVE"));
}
#[test]
#[serial]
fn test_shell_requires_confirmation_non_tty() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
env::set_var("STAND_FORCE_NON_TTY", "1");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[environments.prod]
description = "Production environment"
requires_confirmation = true
DATABASE_URL = "postgres://prod:5432/prod"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "prod", false, None);
env::remove_var("STAND_FORCE_NON_TTY");
assert!(result.is_err());
let error_msg = format!("{}", result.unwrap_err());
assert!(error_msg.contains("requires confirmation"));
assert!(error_msg.contains("not a terminal"));
}
#[test]
#[serial]
fn test_shell_skips_confirmation_with_yes_flag() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[environments.prod]
description = "Production environment"
requires_confirmation = true
DATABASE_URL = "postgres://prod:5432/prod"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "prod", true, None);
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(validated.env_name, "prod");
}
#[test]
#[serial]
fn test_shell_auto_exit_sets_env_var_when_enabled() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[settings]
auto_exit_on_dir_change = true
[environments.dev]
description = "Development environment"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "dev", false, None);
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(
validated.env_vars.get("STAND_AUTO_EXIT"),
Some(&"1".to_string())
);
}
#[test]
#[serial]
fn test_shell_auto_exit_not_set_when_disabled() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[settings]
auto_exit_on_dir_change = false
[environments.dev]
description = "Development environment"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "dev", false, None);
assert!(result.is_ok());
let validated = result.unwrap();
assert!(!validated.env_vars.contains_key("STAND_AUTO_EXIT"));
}
#[test]
#[serial]
fn test_shell_auto_exit_enabled_by_default() {
env::remove_var("STAND_ACTIVE");
env::remove_var("STAND_ENVIRONMENT");
let dir = tempdir().unwrap();
let config_content = r#"
version = "2.0"
[environments.dev]
description = "Development environment"
"#;
let config_path = dir.path().join(".stand.toml");
fs::write(&config_path, config_content).unwrap();
let result = validate_shell_environment(dir.path(), "dev", false, None);
assert!(result.is_ok());
let validated = result.unwrap();
assert_eq!(
validated.env_vars.get("STAND_AUTO_EXIT"),
Some(&"1".to_string())
);
}
}