use super::env_file::{self, EnvFileStatus, find_cue_module_root};
use super::{CommandExecutor, convert_engine_error, relative_path_from_root};
use cuengine::ModuleEvalOptions;
use cuenv_core::manifest::Project;
use cuenv_core::{Error, ModuleEvaluation, Result, shell::Shell};
use cuenv_hooks::{
ApprovalManager, ApprovalStatus, ConfigSummary, ExecutionStatus, HookExecutionConfig,
HookExecutionState, HookExecutor, StateManager, check_approval_status, compute_instance_hash,
execute_hooks,
};
use std::collections::HashMap;
use std::io::{IsTerminal, Write};
use std::path::Path;
use std::time::{Duration, Instant};
use tracing::{debug, info};
const PENDING_APPROVAL_ENV: &str = "CUENV_PENDING_APPROVAL_DIR";
const LOADED_DIR_ENV: &str = "CUENV_LOADED_DIR";
fn evaluate_project(
directory: &Path,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<Project> {
let target_path = directory
.canonicalize()
.map_err(|e| cuenv_core::Error::Io {
source: e,
path: Some(directory.to_path_buf().into_boxed_path()),
operation: "canonicalize path".to_string(),
})?;
if let Some(exec) = executor {
tracing::debug!("Using cached module evaluation from executor");
let module = exec.get_module(&target_path)?;
let rel_path = relative_path_from_root(&module.root, &target_path);
let instance = module.get(&rel_path).ok_or_else(|| {
cuenv_core::Error::configuration(format!(
"No CUE instance found at path: {} (relative: {})",
target_path.display(),
rel_path.display()
))
})?;
return match instance.kind {
cuenv_core::InstanceKind::Project => instance.deserialize(),
cuenv_core::InstanceKind::Base => Err(cuenv_core::Error::configuration(
"This directory uses schema.#Base which doesn't support export.\n\
To use export, update your env.cue to use schema.#Project:\n\n\
schema.#Project\n\
name: \"your-project-name\"",
)),
};
}
tracing::debug!("Using fresh module evaluation (no executor)");
let module_root = find_cue_module_root(&target_path).ok_or_else(|| {
cuenv_core::Error::configuration(format!(
"No CUE module found (looking for cue.mod/) starting from: {}",
target_path.display()
))
})?;
let options = ModuleEvalOptions {
recursive: false,
target_dir: Some(target_path.to_string_lossy().to_string()),
..Default::default()
};
let raw_result = cuengine::evaluate_module(&module_root, package, Some(&options))
.map_err(convert_engine_error)?;
let module = ModuleEvaluation::from_raw(
module_root.clone(),
raw_result.instances,
raw_result.projects,
None,
);
let rel_path = relative_path_from_root(&module_root, &target_path);
let instance = module.get(&rel_path).ok_or_else(|| {
cuenv_core::Error::configuration(format!(
"No CUE instance found at path: {} (relative: {})",
target_path.display(),
rel_path.display()
))
})?;
match instance.kind {
cuenv_core::InstanceKind::Project => instance.deserialize(),
cuenv_core::InstanceKind::Base => Err(cuenv_core::Error::configuration(
"This directory uses schema.#Base which doesn't support export.\n\
To use export, update your env.cue to use schema.#Project:\n\n\
schema.#Project\n\
name: \"your-project-name\"",
)),
}
}
pub fn execute_export_sync(
shell_type: Option<&str>,
path: &str,
package: &str,
) -> Result<Option<String>> {
let shell = Shell::detect(shell_type);
let target_dir = Path::new(path);
let directory = match env_file::find_env_file(target_dir, package)? {
EnvFileStatus::Match(dir) => dir,
EnvFileStatus::Missing => {
debug!(
"No env.cue found in {} (sync fast path)",
target_dir.display()
);
return Ok(Some(format_no_op(shell)));
}
EnvFileStatus::PackageMismatch { found_package } => {
debug!(
"env.cue package mismatch in {}: found {:?}, expected {} (sync fast path)",
target_dir.display(),
found_package,
package
);
return Ok(Some(format_no_op(shell)));
}
};
let state_manager = StateManager::with_default_dir()?;
if !state_manager.has_active_marker(&directory) {
debug!(
"No active marker for {} - falling back to async",
directory.display()
);
return Ok(None);
}
if let Some(instance_hash) = state_manager.get_marker_instance_hash_sync(&directory)
&& let Ok(Some(state)) = state_manager.load_state_sync(&instance_hash)
{
match state.status {
ExecutionStatus::Completed => {
debug!(
"State completed for {} - need async for CUE eval",
directory.display()
);
return Ok(None);
}
ExecutionStatus::Running => {
debug!(
"Hooks still running for {} (sync fast path)",
directory.display()
);
return Ok(Some(format_no_op(shell)));
}
ExecutionStatus::Failed | ExecutionStatus::Cancelled => {
debug!(
"Hooks {:?} for {} (sync fast path)",
state.status,
directory.display()
);
return Ok(Some(format_no_op(shell)));
}
}
}
Ok(None)
}
#[allow(clippy::too_many_lines, clippy::uninlined_format_args)]
pub async fn execute_export(
shell_type: Option<&str>,
path: &str,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<String> {
let shell = Shell::detect(shell_type);
let target_dir = Path::new(path);
let directory = match env_file::find_env_file(target_dir, package)? {
EnvFileStatus::Match(dir) => dir,
EnvFileStatus::Missing => {
debug!("No env.cue found in {}", target_dir.display());
return Ok(format_no_op(shell));
}
EnvFileStatus::PackageMismatch { found_package } => {
debug!(
"env.cue package mismatch in {}: found {:?}, expected {}",
target_dir.display(),
found_package,
package
);
return Ok(format_no_op(shell));
}
};
debug!("Evaluating CUE for {}", directory.display());
let config: Project = evaluate_project(&directory, package, executor)?;
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
debug!("Checking approval for directory: {}", directory.display());
let approval_status =
check_approval_status(&approval_manager, &directory, config.hooks.as_ref())?;
match approval_status {
ApprovalStatus::NotApproved { .. } | ApprovalStatus::RequiresApproval { .. } => {
let summary = ConfigSummary::from_hooks(config.hooks.as_ref());
if summary.has_hooks {
return Ok(format_not_allowed(&directory, shell, summary.hook_count));
}
debug!("Auto-approving configuration with no hooks");
}
ApprovalStatus::Approved => {
}
}
let config_hash = cuenv_hooks::compute_approval_hash(config.hooks.as_ref());
let executor = HookExecutor::with_default_config()?;
if let Some(state) = executor
.get_execution_status_for_instance(&directory, &config_hash)
.await?
{
match state.status {
ExecutionStatus::Completed => {
let env_vars = collect_all_env_vars(&config, &state.environment_vars);
return Ok(format_env_diff_with_unset(
&directory,
env_vars,
state.previous_env.as_ref(),
shell,
));
}
ExecutionStatus::Failed => {
debug!(
"Hooks failed for {}: {:?}",
directory.display(),
state.error_message
);
return Ok(format_no_op(shell));
}
ExecutionStatus::Running => {
debug!("Hooks still running for {}", directory.display());
}
ExecutionStatus::Cancelled => {
return Ok(format_no_op(shell));
}
}
} else {
info!("Starting hook execution for {}", directory.display());
let hooks = extract_hooks_from_config(&config);
if !hooks.is_empty() {
executor
.execute_hooks_background(directory.clone(), config_hash.clone(), hooks)
.await?;
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
if let Some(state) = executor
.get_execution_status_for_instance(&directory, &config_hash)
.await?
&& state.status == ExecutionStatus::Completed
{
let env_vars = collect_all_env_vars(&config, &state.environment_vars);
return Ok(format_env_diff_with_unset(
&directory,
env_vars,
state.previous_env.as_ref(),
shell,
));
}
let static_env = extract_static_env_vars(&config);
if !static_env.is_empty() {
debug!(
"Returning partial environment ({} vars) while hooks run",
static_env.len()
);
return Ok(format_env_diff(&directory, static_env, shell));
}
Ok(format_no_op(shell))
}
fn extract_hooks_from_config(config: &Project) -> Vec<cuenv_hooks::Hook> {
config.on_enter_hooks()
}
fn resolve_hook_dir(hook: &mut cuenv_hooks::Hook, env_cue_dir: &Path) {
let relative_dir = hook.dir.as_deref().unwrap_or(".");
let absolute_dir = env_cue_dir.join(relative_dir);
let resolved = absolute_dir.canonicalize().unwrap_or(absolute_dir);
hook.dir = Some(resolved.to_string_lossy().to_string());
}
fn extract_hooks_with_resolved_dirs(
config: &Project,
env_cue_dir: &Path,
) -> Vec<cuenv_hooks::Hook> {
let mut hooks = config.on_enter_hooks();
for hook in &mut hooks {
resolve_hook_dir(hook, env_cue_dir);
}
hooks
}
#[must_use]
pub fn extract_static_env_vars(config: &Project) -> HashMap<String, String> {
let mut env_vars = HashMap::new();
if let Some(env) = &config.env {
for (key, value) in &env.base {
if value.is_secret() {
continue;
}
env_vars.insert(key.clone(), value.to_string_value());
}
}
env_vars
}
fn collect_all_env_vars(
config: &Project,
hook_env: &HashMap<String, String>,
) -> HashMap<String, String> {
let mut all_vars = extract_static_env_vars(config);
for (key, value) in hook_env {
all_vars.insert(key.clone(), value.clone());
}
all_vars
}
async fn run_hooks_foreground(
directory: &Path,
config_hash: &str,
hooks: Vec<cuenv_hooks::Hook>,
config: &Project,
) -> Result<HashMap<String, String>> {
let instance_hash = compute_instance_hash(directory, config_hash);
let state_dir = if let Ok(dir) = std::env::var("CUENV_STATE_DIR") {
std::path::PathBuf::from(dir)
} else {
StateManager::default_state_dir()?
};
let state_manager = StateManager::new(state_dir);
let hook_config = HookExecutionConfig {
default_timeout_seconds: 600, fail_fast: true,
state_dir: None,
};
let mut state = HookExecutionState::new(
directory.to_path_buf(),
instance_hash.clone(),
config_hash.to_string(),
hooks.clone(),
);
debug!(
"Executing {} hooks in foreground for {}",
hooks.len(),
directory.display()
);
execute_hooks(hooks, directory, &hook_config, &state_manager, &mut state).await?;
match state.status {
ExecutionStatus::Completed => {
info!(
"Foreground hooks completed successfully, captured {} env vars",
state.environment_vars.len()
);
Ok(collect_all_env_vars(config, &state.environment_vars))
}
ExecutionStatus::Failed => {
let msg = state
.error_message
.unwrap_or_else(|| "unknown error".to_string());
Err(Error::execution_with_help(
format!("Hook execution failed for {}: {msg}", directory.display()),
"Check the hook command output above for details",
))
}
_ => Err(Error::execution(format!(
"Hook execution did not complete normally for {}",
directory.display()
))),
}
}
fn collect_hooks_from_ancestors(
directory: &Path,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<Vec<cuenv_hooks::Hook>> {
let ancestors = env_file::find_ancestor_env_files(directory, package)?;
let mut all_hooks = Vec::new();
let ancestors_len = ancestors.len();
for (i, ancestor_dir) in ancestors.into_iter().enumerate() {
let is_current_dir = i == ancestors_len - 1;
let config: Project = match evaluate_project(&ancestor_dir, package, executor) {
Ok(c) => c,
Err(e) => {
debug!(
"Failed to evaluate {} for hooks: {}",
ancestor_dir.display(),
e
);
continue;
}
};
let mut hooks = extract_hooks_with_resolved_dirs(&config, &ancestor_dir);
if !is_current_dir {
hooks.retain(|h| h.propagate);
}
if !hooks.is_empty() {
debug!(
"Found {} hooks in {} (is_current={})",
hooks.len(),
ancestor_dir.display(),
is_current_dir
);
}
all_hooks.extend(hooks);
}
Ok(all_hooks)
}
#[allow(clippy::too_many_lines)]
pub async fn get_environment_with_hooks(
directory: &Path,
config: &Project,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<HashMap<String, String>> {
let static_env = extract_static_env_vars(config);
let all_hooks = collect_hooks_from_ancestors(directory, package, executor)?;
if all_hooks.is_empty() {
return Ok(static_env);
}
debug!(
"Collected {} hooks from ancestors for {}",
all_hooks.len(),
directory.display()
);
let config_hash = cuenv_hooks::compute_execution_hash(&all_hooks, directory);
let foreground_hooks = std::env::var("CUENV_FOREGROUND_HOOKS")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false);
if foreground_hooks {
info!(
"Running {} hooks in foreground for {} (CUENV_FOREGROUND_HOOKS=1)",
all_hooks.len(),
directory.display()
);
return run_hooks_foreground(directory, &config_hash, all_hooks, config).await;
}
let executor = HookExecutor::with_default_config()?;
let status = executor
.get_execution_status_for_instance(directory, &config_hash)
.await?;
if status.is_none() {
info!(
"Starting execution of {} hooks for {}",
all_hooks.len(),
directory.display()
);
executor
.execute_hooks_background(directory.to_path_buf(), config_hash.clone(), all_hooks)
.await?;
}
debug!("Waiting for hooks to complete for {}", directory.display());
let poll_interval = Duration::from_millis(50);
let start_time = Instant::now();
let timeout_seconds = std::env::var("CUENV_HOOK_TIMEOUT")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(600);
let is_tty = std::io::stderr().is_terminal();
loop {
if let Some(state) = executor
.get_execution_status_for_instance(directory, &config_hash)
.await?
{
if is_tty && state.status == ExecutionStatus::Running {
let elapsed = start_time.elapsed().as_secs();
let hook_name = state
.current_hook_display()
.unwrap_or_else(|| "hook".to_string());
#[allow(clippy::print_stderr)] {
eprint!("\r\x1b[KWaiting for hook `{hook_name}` to complete... [{elapsed}s]");
}
let _ = std::io::stderr().flush();
}
if state.is_complete() {
if is_tty {
#[allow(clippy::print_stderr)] {
eprint!("\r\x1b[K");
}
let _ = std::io::stderr().flush();
}
return match state.status {
ExecutionStatus::Completed => {
Ok(collect_all_env_vars(config, &state.environment_vars))
}
ExecutionStatus::Failed => {
let msg = state
.error_message
.unwrap_or_else(|| "unknown error".to_string());
Err(Error::execution_with_help(
format!("Hook execution failed for {}: {msg}", directory.display()),
"Run with CUENV_LOG=debug for more details, or CUENV_FOREGROUND_HOOKS=1 to see hook output directly",
))
}
ExecutionStatus::Cancelled => Err(Error::execution(format!(
"Hook execution was cancelled for {}",
directory.display()
))),
ExecutionStatus::Running => {
unreachable!("is_complete() returned true but status is Running")
}
};
}
} else {
return Err(Error::execution(format!(
"Hook execution state lost for {}. This is a bug — hooks were started but no state was recorded.",
directory.display()
)));
}
if start_time.elapsed().as_secs() >= timeout_seconds {
if is_tty {
#[allow(clippy::print_stderr)] {
eprint!("\r\x1b[K");
}
let _ = std::io::stderr().flush();
}
return Err(Error::Timeout {
seconds: timeout_seconds,
});
}
tokio::time::sleep(poll_interval).await;
}
}
#[allow(clippy::uninlined_format_args)]
fn format_loaded_check(dir: &Path, shell: Shell) -> String {
let dir_display = dir.to_string_lossy();
let escaped_dir = escape_shell_value(&dir_display);
match shell {
Shell::Bash | Shell::Zsh => format!(
r#"if [ "${{{loaded}:-}}" != "{escaped_dir}" ]; then
echo "Project environment loaded" >&2
export {loaded}="{escaped_dir}"
unset {pending} 2>/dev/null
fi"#,
loaded = LOADED_DIR_ENV,
pending = PENDING_APPROVAL_ENV,
escaped_dir = escaped_dir,
),
Shell::Fish => format!(
r#"if not set -q {loaded}
echo "Project environment loaded" >&2
set -x {loaded} "{escaped_dir}"
set -e {pending} 2>/dev/null
else if test "${loaded}" != "{escaped_dir}"
echo "Project environment loaded" >&2
set -x {loaded} "{escaped_dir}"
set -e {pending} 2>/dev/null
end"#,
loaded = LOADED_DIR_ENV,
pending = PENDING_APPROVAL_ENV,
escaped_dir = escaped_dir,
),
Shell::PowerShell => {
let ps_dir = dir_display.replace('\'', "''");
format!(
r"if ($env:{loaded} -ne '{ps_dir}') {{
Write-Host 'Project environment loaded'
$env:{loaded} = '{ps_dir}'
Remove-Item Env:{pending} -ErrorAction SilentlyContinue
}}",
loaded = LOADED_DIR_ENV,
pending = PENDING_APPROVAL_ENV,
ps_dir = ps_dir,
)
}
}
}
fn format_env_diff(dir: &Path, env: HashMap<String, String>, shell: Shell) -> String {
use std::fmt::Write;
let mut output = String::new();
output.push_str(&format_loaded_check(dir, shell));
output.push('\n');
for (key, value) in env {
let escaped_value = escape_shell_value(&value);
match shell {
Shell::Bash | Shell::Zsh => {
let _ = writeln!(&mut output, "export {key}=\"{escaped_value}\"");
}
Shell::Fish => {
let _ = writeln!(&mut output, "set -x {key} \"{escaped_value}\"");
}
Shell::PowerShell => {
let _ = writeln!(&mut output, "$env:{key} = \"{escaped_value}\"");
}
}
}
output
}
fn format_env_diff_with_unset(
dir: &Path,
current_env: HashMap<String, String>,
previous_env: Option<&HashMap<String, String>>,
shell: Shell,
) -> String {
use std::fmt::Write;
let mut output = String::new();
output.push_str(&format_loaded_check(dir, shell));
output.push('\n');
if let Some(prev) = previous_env {
for key in prev.keys() {
if !current_env.contains_key(key) {
match shell {
Shell::Bash | Shell::Zsh => {
let _ = writeln!(&mut output, "unset {key}");
}
Shell::Fish => {
let _ = writeln!(&mut output, "set -e {key}");
}
Shell::PowerShell => {
let _ = writeln!(&mut output, "Remove-Item Env:{key}");
}
}
}
}
}
for (key, value) in current_env {
let escaped_value = escape_shell_value(&value);
match shell {
Shell::Bash | Shell::Zsh => {
let _ = writeln!(&mut output, "export {key}=\"{escaped_value}\"");
}
Shell::Fish => {
let _ = writeln!(&mut output, "set -x {key} \"{escaped_value}\"");
}
Shell::PowerShell => {
let _ = writeln!(&mut output, "$env:{key} = \"{escaped_value}\"");
}
}
}
output
}
#[allow(clippy::uninlined_format_args)]
fn format_not_allowed(dir: &Path, shell: Shell, hook_count: usize) -> String {
let dir_display = dir.to_string_lossy();
let escaped = escape_shell_value(&dir_display);
let hooks_str = if hook_count == 1 { "hook" } else { "hooks" };
match shell {
Shell::Bash | Shell::Zsh => format!(
r#"if [ "${{{pending}:-}}" != "{dir}" ]; then
printf '%s\n' "cuenv detected env.cue, but approval is required because this configuration contains {count} {hooks}."
printf '%s\n' "Run 'cuenv allow' to approve."
export {pending}="{dir}"
unset {loaded} 2>/dev/null
fi
:"#,
pending = PENDING_APPROVAL_ENV,
loaded = LOADED_DIR_ENV,
dir = escaped,
count = hook_count,
hooks = hooks_str,
),
Shell::Fish => {
let pending_ref = format!("${PENDING_APPROVAL_ENV}");
format!(
r#"if not set -q {pending}
printf '%s\n' "cuenv detected env.cue, but approval is required because this configuration contains {count} {hooks}."
printf '%s\n' "Run 'cuenv allow' to approve."
set -x {pending} "{dir}"
set -e {loaded} 2>/dev/null
else if test "{pending_ref}" != "{dir}"
printf '%s\n' "cuenv detected env.cue, but approval is required because this configuration contains {count} {hooks}."
printf '%s\n' "Run 'cuenv allow' to approve."
set -x {pending} "{dir}"
set -e {loaded} 2>/dev/null
end
true"#,
pending = PENDING_APPROVAL_ENV,
pending_ref = pending_ref,
loaded = LOADED_DIR_ENV,
dir = escaped,
count = hook_count,
hooks = hooks_str,
)
}
Shell::PowerShell => {
let ps_dir = dir_display.replace('\'', "''");
format!(
r"if ($env:{pending} -ne '{dir}') {{
Write-Host 'cuenv detected env.cue, but approval is required because this configuration contains {count} {hooks}.'
Write-Host 'Run ''cuenv allow'' to approve.'
$env:{pending} = '{dir}'
Remove-Item Env:{loaded} -ErrorAction SilentlyContinue
}}",
pending = PENDING_APPROVAL_ENV,
loaded = LOADED_DIR_ENV,
dir = ps_dir,
count = hook_count,
hooks = hooks_str,
)
}
}
}
fn escape_shell_value(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
}
#[allow(clippy::uninlined_format_args)]
fn format_no_op(shell: Shell) -> String {
match shell {
Shell::Bash | Shell::Zsh => format!(
r"unset {pending} {loaded} 2>/dev/null
:",
pending = PENDING_APPROVAL_ENV,
loaded = LOADED_DIR_ENV,
),
Shell::Fish => format!(
r"if set -q {pending}
set -e {pending}
end
if set -q {loaded}
set -e {loaded}
end
true",
pending = PENDING_APPROVAL_ENV,
loaded = LOADED_DIR_ENV,
),
Shell::PowerShell => format!(
r"if (Test-Path Env:{pending}) {{ Remove-Item Env:{pending} }}
if (Test-Path Env:{loaded}) {{ Remove-Item Env:{loaded} }}
# no changes",
pending = PENDING_APPROVAL_ENV,
loaded = LOADED_DIR_ENV,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use cuenv_core::environment::{Env, EnvValue, EnvValueSimple, EnvVarWithPolicies};
use cuenv_core::manifest::Project;
use cuenv_core::secrets::Secret;
use std::collections::HashMap;
use std::path::Path;
#[test]
fn test_escape_shell_value() {
assert_eq!(escape_shell_value("simple"), "simple");
assert_eq!(escape_shell_value("hello \"world\""), "hello \\\"world\\\"");
assert_eq!(escape_shell_value("path\\to\\file"), "path\\\\to\\\\file");
assert_eq!(escape_shell_value("$HOME"), "\\$HOME");
assert_eq!(escape_shell_value("test $var"), "test \\$var");
assert_eq!(escape_shell_value("`command`"), "\\`command\\`");
assert_eq!(
escape_shell_value("$HOME/path\\with\"quotes`and`backticks"),
"\\$HOME/path\\\\with\\\"quotes\\`and\\`backticks"
);
assert_eq!(escape_shell_value(""), "");
assert_eq!(escape_shell_value("line1\nline2"), "line1\nline2");
assert_eq!(escape_shell_value("col1\tcol2"), "col1\tcol2");
}
#[test]
fn test_format_no_op_clears_state() {
let bash = format_no_op(Shell::Bash);
assert!(bash.contains("unset"));
assert!(bash.contains("CUENV_PENDING_APPROVAL_DIR"));
assert!(bash.contains("CUENV_LOADED_DIR"));
assert!(bash.trim().ends_with(':'));
let fish = format_no_op(Shell::Fish);
assert!(fish.contains("set -e CUENV_PENDING_APPROVAL_DIR"));
assert!(fish.contains("set -e CUENV_LOADED_DIR"));
assert!(fish.trim().ends_with("true"));
let zsh = format_no_op(Shell::Zsh);
assert!(zsh.contains("unset"));
assert!(zsh.contains("CUENV_PENDING_APPROVAL_DIR"));
assert!(zsh.contains("CUENV_LOADED_DIR"));
let pwsh = format_no_op(Shell::PowerShell);
assert!(pwsh.contains("Remove-Item Env:CUENV_PENDING_APPROVAL_DIR"));
assert!(pwsh.contains("Remove-Item Env:CUENV_LOADED_DIR"));
}
#[test]
fn test_format_not_allowed_emits_notice_and_clears_loaded() {
let dir = Path::new("/tmp/project");
let bash_notice = format_not_allowed(dir, Shell::Bash, 1);
assert!(bash_notice.contains("cuenv detected env.cue"));
assert!(bash_notice.contains("cuenv allow'")); assert!(bash_notice.contains("contains 1 hook"));
assert!(bash_notice.contains("export CUENV_PENDING_APPROVAL_DIR="));
assert!(bash_notice.contains("unset CUENV_LOADED_DIR"));
let fish_notice = format_not_allowed(dir, Shell::Fish, 2);
assert!(fish_notice.contains("set -x CUENV_PENDING_APPROVAL_DIR"));
assert!(fish_notice.contains("cuenv detected env.cue"));
assert!(fish_notice.contains("contains 2 hooks"));
assert!(fish_notice.contains("set -e CUENV_LOADED_DIR"));
}
#[test]
fn test_format_env_diff_exports_and_loaded_message() {
let dir = Path::new("/tmp/project");
let mut env = HashMap::new();
env.insert("FOO".to_string(), "bar baz".to_string());
env.insert("NUM".to_string(), "42".to_string());
let bash = format_env_diff(dir, env.clone(), Shell::Bash);
assert!(bash.contains("echo \"Project environment loaded\" >&2"));
assert!(bash.contains("export CUENV_LOADED_DIR=\"/tmp/project\""));
assert!(bash.contains("export FOO=\"bar baz\""));
assert!(bash.contains("export NUM=\"42\""));
let zsh = format_env_diff(dir, env.clone(), Shell::Zsh);
assert!(zsh.contains("echo \"Project environment loaded\" >&2"));
assert!(zsh.contains("export FOO=\"bar baz\""));
let fish = format_env_diff(dir, env.clone(), Shell::Fish);
assert!(fish.contains("echo \"Project environment loaded\" >&2"));
assert!(fish.contains("set -x CUENV_LOADED_DIR \"/tmp/project\""));
assert!(fish.contains("set -x FOO \"bar baz\""));
let pwsh = format_env_diff(dir, env, Shell::PowerShell);
assert!(pwsh.contains("Write-Host 'Project environment loaded'"));
assert!(pwsh.contains("$env:CUENV_LOADED_DIR = '/tmp/project'"));
assert!(pwsh.contains("$env:FOO = \"bar baz\""));
}
#[test]
fn test_format_env_diff_with_unset() {
let dir = Path::new("/tmp/project");
let current = HashMap::from([
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string()),
]);
let previous = HashMap::from([
("A".to_string(), "old".to_string()),
("REMOVED".to_string(), "x".to_string()),
]);
let out_bash =
format_env_diff_with_unset(dir, current.clone(), Some(&previous), Shell::Bash);
assert!(out_bash.lines().any(|l| l == "unset REMOVED"));
assert!(out_bash.contains("export A=\"1\""));
assert!(out_bash.contains("echo \"Project environment loaded\""));
let out_fish =
format_env_diff_with_unset(dir, current.clone(), Some(&previous), Shell::Fish);
assert!(out_fish.lines().any(|l| l == "set -e REMOVED"));
let out_pwsh = format_env_diff_with_unset(dir, current, Some(&previous), Shell::PowerShell);
assert!(out_pwsh.lines().any(|l| l == "Remove-Item Env:REMOVED"));
}
#[test]
fn test_extract_static_env_vars_skips_secrets() {
let mut base = HashMap::new();
base.insert("PLAIN".to_string(), EnvValue::String("value".to_string()));
let secret = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
base.insert("SECRET".to_string(), EnvValue::Secret(secret));
let env_cfg = Env {
base,
environment: None,
};
let cfg = Project {
config: None,
env: Some(env_cfg),
hooks: None,
ci: None,
tasks: HashMap::new(),
name: "test".to_string(),
codegen: None,
runtime: None,
formatters: None,
services: HashMap::new(),
images: HashMap::new(),
};
let vars = extract_static_env_vars(&cfg);
assert!(vars.get("PLAIN") == Some(&"value".to_string()));
assert!(!vars.contains_key("SECRET"));
}
#[test]
fn test_collect_all_env_vars_override() {
let mut base = HashMap::new();
base.insert("OVERRIDE".to_string(), EnvValue::String("base".to_string()));
base.insert(
"BASE_ONLY".to_string(),
EnvValue::WithPolicies(EnvVarWithPolicies {
value: EnvValueSimple::String("plain".to_string()),
policies: None,
}),
);
let cfg = Project {
config: None,
env: Some(Env {
base,
environment: None,
}),
hooks: None,
ci: None,
tasks: HashMap::new(),
name: "test".to_string(),
codegen: None,
runtime: None,
formatters: None,
services: HashMap::new(),
images: HashMap::new(),
};
let hook_env = HashMap::from([
("OVERRIDE".to_string(), "hook".to_string()),
("HOOK_ONLY".to_string(), "x".to_string()),
]);
let merged = collect_all_env_vars(&cfg, &hook_env);
assert_eq!(merged.get("OVERRIDE"), Some(&"hook".to_string()));
assert_eq!(merged.get("BASE_ONLY"), Some(&"plain".to_string()));
assert_eq!(merged.get("HOOK_ONLY"), Some(&"x".to_string()));
}
}