use super::env_file::{self, EnvFileStatus, find_cue_module_root};
use super::{CommandExecutor, convert_engine_error, relative_path_from_root};
use crate::cli::StatusFormat;
use cuengine::ModuleEvalOptions;
use cuenv_core::manifest::Project;
use cuenv_core::{ModuleEvaluation, Result};
use cuenv_hooks::{
ApprovalManager, ApprovalStatus, ConfigSummary, ExecutionStatus, Hook, HookExecutionState,
HookExecutor, StateManager, check_approval_status, compute_instance_hash,
};
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use tracing::info;
fn env_file_issue_message(path: &str, package: &str, status: EnvFileStatus) -> String {
match status {
EnvFileStatus::Missing => format!("No env.cue file found in '{path}'"),
EnvFileStatus::PackageMismatch { found_package } => match found_package {
Some(found) => {
format!("env.cue in '{path}' uses package '{found}', expected '{package}'")
}
None => format!(
"env.cue in '{path}' is missing a package declaration (expected '{package}')"
),
},
EnvFileStatus::Match(_) => {
unreachable!("env_file_issue_message should not be called with a match")
}
}
}
fn require_env_file(path: &Path, package: &str) -> Result<PathBuf> {
match env_file::find_env_file(path, package)? {
EnvFileStatus::Match(dir) => Ok(dir),
EnvFileStatus::Missing => Err(cuenv_core::Error::configuration(format!(
"No env.cue file found in '{}'",
path.display()
))),
EnvFileStatus::PackageMismatch { found_package } => {
let message = match found_package {
Some(found) => format!(
"env.cue in '{}' uses package '{found}', expected '{package}'",
path.display()
),
None => format!(
"env.cue in '{}' is missing a package declaration (expected '{package}')",
path.display()
),
};
Err(cuenv_core::Error::configuration(message))
}
}
}
fn evaluate_config(
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 instance.deserialize();
}
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()
))
})?;
instance.deserialize()
}
fn get_config_hash(
directory: &Path,
package: &str,
approval_manager: &ApprovalManager,
executor: Option<&CommandExecutor>,
) -> Result<String> {
if let Some(approval) = approval_manager.get_approval(directory.to_str().unwrap_or("")) {
Ok(approval.config_hash.clone())
} else {
let config = evaluate_config(directory, package, executor)?;
Ok(cuenv_hooks::compute_approval_hash(config.hooks.as_ref()))
}
}
fn format_status(state: &HookExecutionState, format: StatusFormat) -> String {
match format {
StatusFormat::Text => state.progress_display(),
StatusFormat::Short => match state.status {
ExecutionStatus::Running => {
format!("[{}/{}]", state.completed_hooks, state.total_hooks)
}
ExecutionStatus::Completed => "[OK]".to_string(),
ExecutionStatus::Failed => "[ERR]".to_string(),
ExecutionStatus::Cancelled => "[X]".to_string(),
},
StatusFormat::Starship => format_starship_status(state),
}
}
fn format_starship_status(state: &HookExecutionState) -> String {
use cuenv_hooks::HookExecutionState;
match state.status {
ExecutionStatus::Running => {
if let Some(hook_display) = state.current_hook_display() {
if let Some(duration) = state.current_hook_duration() {
let duration_str = HookExecutionState::format_duration(duration);
format!("cuenv hook {hook_display} ({duration_str})")
} else {
let duration = state.duration();
let duration_str = HookExecutionState::format_duration(duration);
format!("cuenv hook {hook_display} ({duration_str})")
}
} else {
format!("🔄 {}/{}", state.completed_hooks, state.total_hooks)
}
}
ExecutionStatus::Completed => {
if state.should_display_completed() {
let duration = state.duration();
let duration_str = HookExecutionState::format_duration(duration);
format!("✅ {duration_str}")
} else {
String::new()
}
}
ExecutionStatus::Failed => {
if state.should_display_completed() {
if let Some(error_msg) = &state.error_message {
format!("❌ {}", error_msg.lines().next().unwrap_or("failed"))
} else {
"❌ failed".to_string()
}
} else {
String::new()
}
}
ExecutionStatus::Cancelled => {
if state.should_display_completed() {
"🚫 cancelled".to_string()
} else {
String::new()
}
}
}
}
pub async fn execute_env_load(
path: &str,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<String> {
let directory = match env_file::find_env_file(Path::new(path), package)? {
EnvFileStatus::Match(dir) => dir,
status => return Ok(env_file_issue_message(path, package, status)),
};
let config = evaluate_config(&directory, package, executor)?;
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
let approval_status =
check_approval_status(&approval_manager, &directory, config.hooks.as_ref())?;
match approval_status {
ApprovalStatus::Approved => {
let hooks = extract_hooks_from_config(&config);
if hooks.is_empty() {
return Ok("No hooks to execute".to_string());
}
let executor = HookExecutor::with_default_config()?;
let config_hash = cuenv_hooks::compute_approval_hash(config.hooks.as_ref());
let result = executor
.execute_hooks_background(directory.clone(), config_hash, hooks)
.await?;
Ok(result)
}
ApprovalStatus::RequiresApproval { current_hash } => {
let summary = ConfigSummary::from_hooks(config.hooks.as_ref());
Ok(format!(
"Configuration has changed and requires approval.\n\
This configuration contains: {}\n\
Hash: {}\n\
Run 'cuenv allow --path {}' to approve the new configuration",
summary.description(),
¤t_hash[..16],
path
))
}
ApprovalStatus::NotApproved { current_hash } => {
let summary = ConfigSummary::from_hooks(config.hooks.as_ref());
Ok(format!(
"Configuration not approved.\n\
This configuration contains: {}\n\
Hash: {}\n\
Run 'cuenv allow --path {}' to approve this configuration",
summary.description(),
¤t_hash[..16],
path
))
}
}
}
pub async fn execute_env_status(
path: &str,
package: &str,
wait: bool,
timeout_seconds: u64,
format: StatusFormat,
cmd_executor: Option<&CommandExecutor>,
) -> Result<String> {
let directory = match env_file::find_env_file(Path::new(path), package)? {
EnvFileStatus::Match(dir) => dir,
status => return Ok(env_file_issue_message(path, package, status)),
};
let hook_executor = HookExecutor::with_default_config()?;
if wait {
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
let config_hash = get_config_hash(&directory, package, &approval_manager, cmd_executor)?;
match hook_executor
.wait_for_completion(&directory, &config_hash, Some(timeout_seconds))
.await
{
Ok(state) => Ok(format_status(&state, format)),
Err(cuenv_hooks::Error::Timeout { .. }) => {
if let Some(state) = hook_executor
.get_execution_status_for_instance(&directory, &config_hash)
.await?
{
Ok(format!(
"Timeout after {} seconds. Current status: {}",
timeout_seconds,
format_status(&state, format)
))
} else {
Ok("No hook execution in progress".to_string())
}
}
Err(e) => Err(e.into()),
}
} else {
if let Some(state) = hook_executor.get_fast_status(&directory).await? {
Ok(format_status(&state, format))
} else {
match format {
StatusFormat::Text => Ok("No hook execution in progress".to_string()),
StatusFormat::Short => Ok("-".to_string()),
StatusFormat::Starship => Ok(String::new()), }
}
}
}
pub fn execute_env_status_sync(path: &str, package: &str, format: StatusFormat) -> Result<String> {
let directory = match env_file::find_env_file(Path::new(path), package)? {
EnvFileStatus::Match(dir) => dir,
status => return Ok(env_file_issue_message(path, package, status)),
};
let executor = HookExecutor::with_default_config()?;
if let Some(state) = executor.get_fast_status_sync(&directory)? {
Ok(format_status(&state, format))
} else {
match format {
StatusFormat::Text => Ok("No hook execution in progress".to_string()),
StatusFormat::Short => Ok("-".to_string()),
StatusFormat::Starship => Ok(String::new()), }
}
}
pub async fn execute_env_inspect(
path: &str,
package: &str,
executor: Option<&CommandExecutor>,
) -> Result<String> {
use std::fmt::Write;
let directory = require_env_file(Path::new(path), package)?;
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
let config_hash = get_config_hash(&directory, package, &approval_manager, executor)?;
let instance_hash = compute_instance_hash(&directory, &config_hash);
let state_manager = StateManager::with_default_dir()?;
let state_path = state_manager.get_state_file_path(&instance_hash);
if let Some(state) = state_manager.load_state(&instance_hash).await? {
let mut output = String::new();
writeln!(&mut output, "Directory: {}", directory.display()).ok();
writeln!(&mut output, "Config hash: {config_hash}").ok();
writeln!(&mut output, "Instance hash: {instance_hash}").ok();
writeln!(&mut output, "State file: {}", state_path.display()).ok();
writeln!(&mut output, "Status: {:?}", state.status).ok();
writeln!(
&mut output,
"Hooks: {}/{}",
state.completed_hooks, state.total_hooks
)
.ok();
writeln!(&mut output, "Started: {}", state.started_at).ok();
if let Some(finished) = state.finished_at {
writeln!(&mut output, "Finished: {finished}").ok();
}
let mut env_keys: Vec<_> = state.environment_vars.keys().collect();
env_keys.sort();
writeln!(&mut output, "Captured env ({} vars):", env_keys.len()).ok();
for key in env_keys {
if let Some(value) = state.environment_vars.get(key) {
writeln!(&mut output, " {key}={value}").ok();
}
}
if let Some(prev) = state.previous_env.as_ref() {
let mut prev_keys: Vec<_> = prev.keys().collect();
prev_keys.sort();
writeln!(
&mut output,
"Previous env snapshot ({} vars):",
prev_keys.len()
)
.ok();
for key in prev_keys {
if let Some(value) = prev.get(key) {
writeln!(&mut output, " {key}={value}").ok();
}
}
}
return Ok(output);
}
let mut matching_states = Vec::new();
for state in state_manager.list_active_states().await? {
if state.directory_path == directory {
matching_states.push(state);
}
}
let mut output = String::new();
writeln!(
&mut output,
"No cached state found for {} (config hash {}, instance hash {}).",
directory.display(),
config_hash,
instance_hash
)
.ok();
writeln!(&mut output, "Expected state file: {}", state_path.display()).ok();
if matching_states.is_empty() {
writeln!(
&mut output,
"No other states for this directory were found."
)
.ok();
} else {
writeln!(
&mut output,
"Found {} state(s) for this directory with different config hashes:",
matching_states.len()
)
.ok();
for state in matching_states {
writeln!(
&mut output,
" status={:?} config_hash={} state_file={}",
state.status,
state.config_hash,
state_manager
.get_state_file_path(&state.instance_hash)
.display()
)
.ok();
}
}
Ok(output)
}
pub async fn execute_allow(
path: &str,
package: &str,
note: Option<String>,
yes: bool,
executor: Option<&CommandExecutor>,
) -> Result<String> {
let directory = require_env_file(Path::new(path), package)?;
let config = evaluate_config(&directory, package, executor)?;
let config_hash = cuenv_hooks::compute_approval_hash(config.hooks.as_ref());
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
if approval_manager.is_approved(&directory, &config_hash)? {
return Ok(format!(
"Configuration is already approved for directory: {}",
directory.display()
));
}
let summary = ConfigSummary::from_hooks(config.hooks.as_ref());
if !yes {
let hooks = extract_hooks_from_config(&config);
if !hooks.is_empty() {
#[allow(clippy::print_stdout)] {
println!("The following hooks will be allowed:");
for hook in &hooks {
println!(" - Command: {}", hook.command);
if !hook.args.is_empty() {
println!(" Args: {:?}", hook.args);
}
}
println!();
print!("Do you want to allow this configuration? [y/N] ");
}
io::stdout()
.flush()
.map_err(|e| cuenv_core::Error::configuration(format!("IO error: {e}")))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|e| cuenv_core::Error::configuration(format!("IO error: {e}")))?;
if !input.trim().eq_ignore_ascii_case("y") {
return Ok("Aborted by user.".to_string());
}
}
}
approval_manager
.approve_config(&directory, config_hash.clone(), note)
.await?;
info!(
"Approved configuration for directory: {}",
directory.display()
);
Ok(format!(
"Configuration approved for directory: {}\n\
Contains: {}\n\
Hash: {}\n\
(Note: You may need to reload your environment, e.g., `cd .`, to apply changes)",
directory.display(),
summary.description(),
&config_hash[..16]
))
}
pub async fn execute_deny(path: &str, package: &str) -> Result<String> {
let directory =
if let Ok(EnvFileStatus::Match(dir)) = env_file::find_env_file(Path::new(path), package) {
dir
} else {
Path::new(path).canonicalize().map_err(|e| {
cuenv_core::Error::configuration(format!("Failed to resolve path '{path}': {e}"))
})?
};
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
if approval_manager.revoke_approval(&directory).await? {
Ok(format!(
"Revoked approval for directory: {}",
directory.display()
))
} else {
Ok(format!(
"No approval found for directory: {}",
directory.display()
))
}
}
pub async fn execute_env_check(
path: &str,
package: &str,
shell: crate::cli::ShellType,
cmd_executor: Option<&CommandExecutor>,
) -> Result<String> {
let EnvFileStatus::Match(directory) = env_file::find_env_file(Path::new(path), package)? else {
return Ok(String::new()); };
let mut approval_manager = ApprovalManager::with_default_file()?;
approval_manager.load_approvals().await?;
let config_hash = get_config_hash(&directory, package, &approval_manager, cmd_executor)?;
let hook_executor = HookExecutor::with_default_config()?;
if let Some(state) = hook_executor
.get_execution_status_for_instance(&directory, &config_hash)
.await?
&& state.is_complete()
&& state.status == ExecutionStatus::Completed
{
let mut output = String::new();
let mut all_env_vars = HashMap::new();
let config = evaluate_config(&directory, package, cmd_executor)?;
if let Some(env) = &config.env {
for (key, value) in &env.base {
if value.is_secret() {
continue;
}
all_env_vars.insert(key.clone(), value.to_string_value());
}
}
for (key, value) in &state.environment_vars {
all_env_vars.insert(key.clone(), value.clone());
}
for (key, value) in &all_env_vars {
match shell {
crate::cli::ShellType::Fish => {
use std::fmt::Write;
writeln!(&mut output, "set -x {key} \"{value}\"").expect("write to string");
}
crate::cli::ShellType::Bash | crate::cli::ShellType::Zsh => {
use std::fmt::Write;
writeln!(&mut output, "export {key}=\"{value}\"").expect("write to string");
}
}
}
return Ok(output);
}
Ok(String::new())
}
#[must_use]
pub fn execute_shell_init(shell: crate::cli::ShellType) -> String {
match shell {
crate::cli::ShellType::Fish => generate_fish_integration(),
crate::cli::ShellType::Bash => generate_bash_integration(),
crate::cli::ShellType::Zsh => generate_zsh_integration(),
}
}
fn extract_hooks_from_config(config: &Project) -> Vec<Hook> {
config.on_enter_hooks()
}
fn generate_fish_integration() -> String {
r"# cuenv Fish shell integration
# Add this to your ~/.config/fish/config.fish
# Mark that shell integration is active
set -x CUENV_SHELL_INTEGRATION 1
# Hook function that loads environment on each prompt
function __cuenv_hook --on-variable PWD
# The export command handles everything:
# - Checks if env.cue exists
# - Loads cached state if available (fast path)
# - Evaluates CUE only when needed
# - Starts hooks in background if needed
# - Returns safe no-op if nothing to do
source (cuenv export --shell fish 2>/dev/null | psub)
end
# Also run on shell startup
source (cuenv export --shell fish 2>/dev/null | psub)"
.to_string()
}
fn generate_bash_integration() -> String {
r#"# cuenv Bash shell integration
# Add this to your ~/.bashrc
# Mark that shell integration is active
export CUENV_SHELL_INTEGRATION=1
# Hook function that loads environment on each prompt
__cuenv_hook() {
# The export command handles everything:
# - Checks if env.cue exists
# - Loads cached state if available (fast path)
# - Evaluates CUE only when needed
# - Starts hooks in background if needed
# - Returns safe no-op if nothing to do
eval "$(cuenv export --shell bash 2>/dev/null)"
}
# Set up the hook via PROMPT_COMMAND
if [[ -n "$PROMPT_COMMAND" ]]; then
PROMPT_COMMAND="__cuenv_hook; $PROMPT_COMMAND"
else
PROMPT_COMMAND="__cuenv_hook"
fi
# Also run on shell startup
__cuenv_hook"#
.to_string()
}
fn generate_zsh_integration() -> String {
r#"# cuenv Zsh shell integration
# Add this to your ~/.zshrc
# Mark that shell integration is active
export CUENV_SHELL_INTEGRATION=1
# Hook function that loads environment on each prompt
__cuenv_hook() {
# The export command handles everything:
# - Checks if env.cue exists
# - Loads cached state if available (fast path)
# - Evaluates CUE only when needed
# - Starts hooks in background if needed
# - Returns safe no-op if nothing to do
eval "$(cuenv export --shell zsh 2>/dev/null)"
}
# Set up the hook via precmd
autoload -U add-zsh-hook
add-zsh-hook precmd __cuenv_hook
# Also run on shell startup
__cuenv_hook"#
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_extract_hooks_from_config() {
use cuenv_core::manifest::Project;
use cuenv_hooks::{Hook, Hooks};
use std::collections::HashMap;
let mut on_enter = HashMap::new();
on_enter.insert(
"npm".to_string(),
Hook {
order: 100,
propagate: false,
command: "npm".to_string(),
args: vec!["install".to_string()],
dir: None,
inputs: vec![],
source: None,
},
);
on_enter.insert(
"docker".to_string(),
Hook {
order: 100,
propagate: false,
command: "docker-compose".to_string(),
args: vec!["up".to_string(), "-d".to_string()],
dir: None,
inputs: vec![],
source: None,
},
);
let config = Project {
config: None,
env: None,
hooks: Some(Hooks {
on_enter: Some(on_enter),
on_exit: None,
pre_push: None,
}),
ci: None,
tasks: std::collections::HashMap::new(),
name: "test".to_string(),
codegen: None,
formatters: None,
runtime: None,
services: std::collections::HashMap::new(),
images: std::collections::HashMap::new(),
};
let hooks = extract_hooks_from_config(&config);
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[0].command, "docker-compose");
assert_eq!(hooks[1].command, "npm");
}
#[test]
fn test_extract_hooks_single_hook() {
use cuenv_core::manifest::Project;
use cuenv_hooks::{Hook, Hooks};
use std::collections::HashMap;
let mut on_enter = HashMap::new();
on_enter.insert(
"echo".to_string(),
Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["hello".to_string()],
dir: None,
inputs: vec![],
source: None,
},
);
let config = Project {
config: None,
env: None,
hooks: Some(Hooks {
on_enter: Some(on_enter),
on_exit: None,
pre_push: None,
}),
ci: None,
tasks: std::collections::HashMap::new(),
name: "test".to_string(),
codegen: None,
formatters: None,
runtime: None,
services: std::collections::HashMap::new(),
images: std::collections::HashMap::new(),
};
let hooks = extract_hooks_from_config(&config);
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0].command, "echo");
assert_eq!(hooks[0].args, vec!["hello"]);
}
#[test]
fn test_extract_hooks_empty_config() {
use cuenv_core::manifest::Project;
let config = Project {
config: None,
env: None,
hooks: None,
ci: None,
tasks: std::collections::HashMap::new(),
name: "test".to_string(),
codegen: None,
formatters: None,
runtime: None,
services: std::collections::HashMap::new(),
images: std::collections::HashMap::new(),
};
let hooks = extract_hooks_from_config(&config);
assert_eq!(hooks.len(), 0);
}
#[test]
fn test_shell_integration_generation() {
let fish_script = generate_fish_integration();
assert!(fish_script.contains("function __cuenv_hook"));
assert!(fish_script.contains("on-variable PWD"));
let bash_script = generate_bash_integration();
assert!(bash_script.contains("__cuenv_hook()"));
assert!(bash_script.contains("PROMPT_COMMAND"));
let zsh_script = generate_zsh_integration();
assert!(zsh_script.contains("add-zsh-hook"));
assert!(zsh_script.contains("precmd"));
}
#[tokio::test]
async fn test_execute_allow_no_directory() {
let result = execute_allow("/nonexistent/directory", "cuenv", None, false, None).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
cuenv_core::Error::Configuration { .. }
));
}
#[tokio::test]
async fn test_execute_allow_no_env_cue() {
let temp_dir = TempDir::new().unwrap();
let result = execute_allow(
temp_dir.path().to_str().unwrap(),
"cuenv",
None,
false,
None,
)
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
cuenv_core::Error::Configuration { .. }
));
}
#[tokio::test]
async fn test_execute_env_load_no_file() {
let temp_dir = TempDir::new().unwrap();
let result = execute_env_load(temp_dir.path().to_str().unwrap(), "cuenv", None).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("No env.cue file found"));
}
#[tokio::test]
async fn test_execute_env_status_no_file() {
let temp_dir = TempDir::new().unwrap();
let result = execute_env_status(
temp_dir.path().to_str().unwrap(),
"cuenv",
false,
30,
StatusFormat::Text,
None,
)
.await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("No env.cue file found"));
}
#[tokio::test]
async fn test_execute_env_load_package_mismatch_message() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("env.cue"), "package other\n\nenv: {}").unwrap();
let output = execute_env_load(temp_dir.path().to_str().unwrap(), "cuenv", None)
.await
.unwrap();
assert!(output.contains("uses package 'other'"));
}
}