Skip to main content

cuenv_hooks/
executor.rs

1//! Hook execution engine with background processing and state management
2
3use crate::state::{HookExecutionState, StateManager, compute_instance_hash};
4use crate::types::{ExecutionStatus, Hook, HookExecutionConfig, HookResult};
5use crate::{Error, Result};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::time::{Duration, Instant};
10use tokio::process::Command;
11use tokio::time::timeout;
12use tracing::{debug, error, info, warn};
13
14/// Manages hook execution with background processing and state persistence
15#[derive(Debug)]
16pub struct HookExecutor {
17    config: HookExecutionConfig,
18    state_manager: StateManager,
19}
20
21impl HookExecutor {
22    /// Create a new hook executor with the specified configuration
23    pub fn new(config: HookExecutionConfig) -> Result<Self> {
24        let state_dir = if let Some(dir) = config.state_dir.clone() {
25            dir
26        } else {
27            StateManager::default_state_dir()?
28        };
29
30        let state_manager = StateManager::new(state_dir);
31
32        Ok(Self {
33            config,
34            state_manager,
35        })
36    }
37
38    /// Create a hook executor with default configuration
39    pub fn with_default_config() -> Result<Self> {
40        let mut config = HookExecutionConfig::default();
41
42        // Use CUENV_STATE_DIR if set
43        if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
44            config.state_dir = Some(PathBuf::from(state_dir));
45        }
46
47        Self::new(config)
48    }
49
50    /// Start executing hooks in the background for a directory
51    pub async fn execute_hooks_background(
52        &self,
53        directory_path: PathBuf,
54        config_hash: String,
55        hooks: Vec<Hook>,
56    ) -> Result<String> {
57        use std::process::{Command, Stdio};
58
59        if hooks.is_empty() {
60            return Ok("No hooks to execute".to_string());
61        }
62
63        let instance_hash = compute_instance_hash(&directory_path, &config_hash);
64        let total_hooks = hooks.len();
65
66        // Check for existing state to preserve previous environment
67        let previous_env =
68            if let Ok(Some(existing_state)) = self.state_manager.load_state(&instance_hash).await {
69                // If we have a completed state, save its environment as previous
70                if existing_state.status == ExecutionStatus::Completed {
71                    Some(existing_state.environment_vars.clone())
72                } else {
73                    existing_state.previous_env
74                }
75            } else {
76                None
77            };
78
79        // Create initial execution state with previous environment
80        let mut state = HookExecutionState::new(
81            directory_path.clone(),
82            instance_hash.clone(),
83            config_hash.clone(),
84            hooks.clone(),
85        );
86        state.previous_env = previous_env;
87
88        // Save initial state
89        self.state_manager.save_state(&state).await?;
90
91        // Create directory marker for fast status lookups
92        self.state_manager
93            .create_directory_marker(&directory_path, &instance_hash)
94            .await?;
95
96        info!(
97            "Starting background execution of {} hooks for directory: {}",
98            total_hooks,
99            directory_path.display()
100        );
101
102        // Check if a supervisor is already running for this instance
103        let pid_file = self
104            .state_manager
105            .get_state_file_path(&instance_hash)
106            .with_extension("pid");
107
108        if pid_file.exists() {
109            // Read the PID and check if process is still running
110            if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
111                && let Ok(pid) = pid_str.trim().parse::<usize>()
112            {
113                // Check if process is still alive using sysinfo
114                use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
115                let mut system = System::new();
116                let process_pid = Pid::from(pid);
117                system.refresh_processes_specifics(
118                    ProcessesToUpdate::Some(&[process_pid]),
119                    false,
120                    ProcessRefreshKind::nothing(),
121                );
122
123                if system.process(process_pid).is_some() {
124                    info!("Supervisor already running for directory with PID {}", pid);
125                    return Ok(format!(
126                        "Supervisor already running for {} hooks (PID: {})",
127                        total_hooks, pid
128                    ));
129                }
130            }
131            // If we get here, the PID file exists but process is dead
132            std::fs::remove_file(&pid_file).ok();
133        }
134
135        // Write hooks and config to temp files to avoid argument size limits
136        let state_dir = self.state_manager.get_state_dir();
137        let hooks_file = state_dir.join(format!("{}_hooks.json", instance_hash));
138        let config_file = state_dir.join(format!("{}_config.json", instance_hash));
139
140        // Serialize and write hooks
141        let hooks_json = serde_json::to_string(&hooks)
142            .map_err(|e| Error::serialization(format!("Failed to serialize hooks: {}", e)))?;
143        std::fs::write(&hooks_file, &hooks_json).map_err(|e| Error::Io {
144            source: e,
145            path: Some(hooks_file.clone().into_boxed_path()),
146            operation: "write".to_string(),
147        })?;
148
149        // Serialize and write config
150        let config_json = serde_json::to_string(&self.config)
151            .map_err(|e| Error::serialization(format!("Failed to serialize config: {}", e)))?;
152        std::fs::write(&config_file, &config_json).map_err(|e| Error::Io {
153            source: e,
154            path: Some(config_file.clone().into_boxed_path()),
155            operation: "write".to_string(),
156        })?;
157
158        // Get the executable path to spawn as supervisor
159        // Allow override via CUENV_EXECUTABLE for testing
160        let current_exe = if let Ok(exe_path) = std::env::var("CUENV_EXECUTABLE") {
161            PathBuf::from(exe_path)
162        } else {
163            std::env::current_exe()
164                .map_err(|e| Error::process(format!("Failed to get current exe: {}", e)))?
165        };
166
167        // Spawn a detached supervisor process
168        let mut cmd = Command::new(&current_exe);
169        cmd.arg("__hook-supervisor") // Special hidden command
170            .arg("--directory")
171            .arg(directory_path.to_string_lossy().to_string())
172            .arg("--instance-hash")
173            .arg(&instance_hash)
174            .arg("--config-hash")
175            .arg(&config_hash)
176            .arg("--hooks-file")
177            .arg(hooks_file.to_string_lossy().to_string())
178            .arg("--config-file")
179            .arg(config_file.to_string_lossy().to_string())
180            .stdin(Stdio::null());
181
182        // Redirect output to log files for debugging
183        let temp_dir = std::env::temp_dir();
184        let log_file = std::fs::File::create(temp_dir.join("cuenv_supervisor.log")).ok();
185        let err_file = std::fs::File::create(temp_dir.join("cuenv_supervisor_err.log")).ok();
186
187        if let Some(log) = log_file {
188            cmd.stdout(Stdio::from(log));
189        } else {
190            cmd.stdout(Stdio::null());
191        }
192
193        if let Some(err) = err_file {
194            cmd.stderr(Stdio::from(err));
195        } else {
196            cmd.stderr(Stdio::null());
197        }
198
199        // Pass through CUENV_STATE_DIR if set
200        if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
201            cmd.env("CUENV_STATE_DIR", state_dir);
202        }
203
204        // Pass through CUENV_APPROVAL_FILE if set
205        if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
206            cmd.env("CUENV_APPROVAL_FILE", approval_file);
207        }
208
209        // Pass through RUST_LOG for debugging
210        if let Ok(rust_log) = std::env::var("RUST_LOG") {
211            cmd.env("RUST_LOG", rust_log);
212        }
213
214        // Platform-specific detachment configuration
215        #[cfg(windows)]
216        {
217            use std::os::windows::process::CommandExt;
218            // Windows-specific flags for detached process
219            const DETACHED_PROCESS: u32 = 0x00000008;
220            const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
221            cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
222        }
223
224        let _child = cmd
225            .spawn()
226            .map_err(|e| Error::process(format!("Failed to spawn supervisor: {}", e)))?;
227
228        // The child is now properly detached
229
230        info!("Spawned supervisor process for hook execution");
231
232        Ok(format!(
233            "Started execution of {} hooks in background",
234            total_hooks
235        ))
236    }
237
238    /// Get the current execution status for a directory
239    pub async fn get_execution_status(
240        &self,
241        directory_path: &Path,
242    ) -> Result<Option<HookExecutionState>> {
243        // List all active states and find one matching this directory
244        let states = self.state_manager.list_active_states().await?;
245        for state in states {
246            if state.directory_path == directory_path {
247                return Ok(Some(state));
248            }
249        }
250        Ok(None)
251    }
252
253    /// Get execution status for a specific instance (directory + config)
254    pub async fn get_execution_status_for_instance(
255        &self,
256        directory_path: &Path,
257        config_hash: &str,
258    ) -> Result<Option<HookExecutionState>> {
259        let instance_hash = compute_instance_hash(directory_path, config_hash);
260        self.state_manager.load_state(&instance_hash).await
261    }
262
263    /// Fast check if any hooks are active for a directory (no config hash needed).
264    /// This is the hot path for Starship - skips config hash computation entirely.
265    /// Returns None if no hooks running, Some(state) if hooks active.
266    pub async fn get_fast_status(
267        &self,
268        directory_path: &Path,
269    ) -> Result<Option<HookExecutionState>> {
270        // First check: does marker exist? O(1) filesystem stat
271        if !self.state_manager.has_active_marker(directory_path) {
272            return Ok(None);
273        }
274
275        // Marker exists - get instance hash and load state
276        if let Some(instance_hash) = self
277            .state_manager
278            .get_marker_instance_hash(directory_path)
279            .await
280        {
281            let state = self.state_manager.load_state(&instance_hash).await?;
282
283            match &state {
284                Some(s) if s.is_complete() && !s.should_display_completed() => {
285                    // State is complete and expired, clean up marker
286                    self.state_manager
287                        .remove_directory_marker(directory_path)
288                        .await
289                        .ok();
290                    return Ok(None);
291                }
292                None => {
293                    // State file was deleted but marker exists - clean up orphaned marker
294                    self.state_manager
295                        .remove_directory_marker(directory_path)
296                        .await
297                        .ok();
298                    return Ok(None);
299                }
300                Some(_) => return Ok(state),
301            }
302        }
303
304        Ok(None)
305    }
306
307    /// Get a reference to the state manager (for marker operations from execute_hooks)
308    #[must_use]
309    pub fn state_manager(&self) -> &StateManager {
310        &self.state_manager
311    }
312
313    /// Synchronous fast status check - no tokio runtime required.
314    /// This is the hot path for Starship/shell prompts when no async runtime is available.
315    /// Returns None if no hooks running, Some(state) if hooks active.
316    pub fn get_fast_status_sync(
317        &self,
318        directory_path: &Path,
319    ) -> Result<Option<HookExecutionState>> {
320        // First check: does marker exist? O(1) filesystem stat
321        if !self.state_manager.has_active_marker(directory_path) {
322            return Ok(None);
323        }
324
325        // Marker exists - get instance hash and load state synchronously
326        if let Some(instance_hash) = self
327            .state_manager
328            .get_marker_instance_hash_sync(directory_path)
329        {
330            let state = self.state_manager.load_state_sync(&instance_hash)?;
331
332            match &state {
333                Some(s) if s.is_complete() && !s.should_display_completed() => {
334                    // State is complete and expired - for sync path, just return None
335                    // (async cleanup will happen on next async call)
336                    return Ok(None);
337                }
338                None => {
339                    // State file was deleted but marker exists - return None
340                    // (async cleanup will happen on next async call)
341                    return Ok(None);
342                }
343                Some(_) => return Ok(state),
344            }
345        }
346
347        Ok(None)
348    }
349
350    /// Wait for hook execution to complete, with optional timeout in seconds
351    pub async fn wait_for_completion(
352        &self,
353        directory_path: &Path,
354        config_hash: &str,
355        timeout_seconds: Option<u64>,
356    ) -> Result<HookExecutionState> {
357        let instance_hash = compute_instance_hash(directory_path, config_hash);
358        let poll_interval = Duration::from_millis(500);
359        let start_time = Instant::now();
360
361        loop {
362            if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
363                if state.is_complete() {
364                    return Ok(state);
365                }
366            } else {
367                return Err(Error::state_not_found(&instance_hash));
368            }
369
370            // Check timeout
371            if let Some(timeout) = timeout_seconds
372                && start_time.elapsed().as_secs() >= timeout
373            {
374                return Err(Error::Timeout { seconds: timeout });
375            }
376
377            tokio::time::sleep(poll_interval).await;
378        }
379    }
380
381    /// Cancel execution for a directory
382    pub async fn cancel_execution(
383        &self,
384        directory_path: &Path,
385        config_hash: &str,
386        reason: Option<String>,
387    ) -> Result<bool> {
388        let instance_hash = compute_instance_hash(directory_path, config_hash);
389
390        // Try to kill the supervisor process if it exists
391        let pid_file = self
392            .state_manager
393            .get_state_file_path(&instance_hash)
394            .with_extension("pid");
395
396        if pid_file.exists()
397            && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
398            && let Ok(pid) = pid_str.trim().parse::<usize>()
399        {
400            use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System};
401
402            let mut system = System::new();
403            let process_pid = Pid::from(pid);
404
405            // Refresh the specific process
406            system.refresh_processes_specifics(
407                ProcessesToUpdate::Some(&[process_pid]),
408                false,
409                ProcessRefreshKind::nothing(),
410            );
411
412            // Check if process exists and kill it
413            if let Some(process) = system.process(process_pid) {
414                if process.kill_with(Signal::Term).is_some() {
415                    info!("Sent SIGTERM to supervisor process PID {}", pid);
416                } else {
417                    warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
418                }
419            } else {
420                info!(
421                    "Supervisor process PID {} not found (may have already exited)",
422                    pid
423                );
424            }
425
426            // Clean up PID file regardless
427            std::fs::remove_file(&pid_file).ok();
428        }
429
430        // Then update the state
431        if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
432            && !state.is_complete()
433        {
434            state.mark_cancelled(reason);
435            self.state_manager.save_state(&state).await?;
436            info!(
437                "Cancelled execution for directory: {}",
438                directory_path.display()
439            );
440            return Ok(true);
441        }
442
443        Ok(false)
444    }
445
446    /// Clean up completed execution states older than the specified duration
447    pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
448        let states = self.state_manager.list_active_states().await?;
449        let cutoff = chrono::Utc::now() - older_than;
450        let mut cleaned_count = 0;
451
452        for state in states {
453            if state.is_complete()
454                && let Some(finished_at) = state.finished_at
455                && finished_at < cutoff
456            {
457                self.state_manager
458                    .remove_state(&state.instance_hash)
459                    .await?;
460                cleaned_count += 1;
461            }
462        }
463
464        if cleaned_count > 0 {
465            info!("Cleaned up {} old execution states", cleaned_count);
466        }
467
468        Ok(cleaned_count)
469    }
470
471    /// Execute a single hook and return the result
472    pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
473        // Use the default timeout from config
474        let timeout = self.config.default_timeout_seconds;
475
476        // No validation - users approved this config with cuenv allow
477        execute_hook_with_timeout(hook, &timeout).await
478    }
479}
480
481/// Execute hooks sequentially
482pub async fn execute_hooks(
483    hooks: Vec<Hook>,
484    _directory_path: &Path,
485    config: &HookExecutionConfig,
486    state_manager: &StateManager,
487    state: &mut HookExecutionState,
488) -> Result<()> {
489    let hook_count = hooks.len();
490    debug!("execute_hooks called with {} hooks", hook_count);
491    if hook_count == 0 {
492        debug!("No hooks to execute");
493        return Ok(());
494    }
495    debug!("Starting to iterate over {} hooks", hook_count);
496    for (index, hook) in hooks.into_iter().enumerate() {
497        debug!(
498            "Processing hook {}/{}: command={}",
499            index + 1,
500            state.total_hooks,
501            hook.command
502        );
503        // Check if execution was cancelled
504        debug!("Checking if execution was cancelled");
505        if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
506            debug!("Loaded state: status = {:?}", current_state.status);
507            if current_state.status == ExecutionStatus::Cancelled {
508                debug!("Execution was cancelled, stopping");
509                break;
510            }
511        }
512
513        // No validation - users approved this config with cuenv allow
514
515        let timeout_seconds = config.default_timeout_seconds;
516
517        // Mark hook as running
518        state.mark_hook_running(index);
519
520        // Execute the hook and wait for it to complete
521        let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
522
523        // Record the result
524        match result {
525            Ok(hook_result) => {
526                // If this is a source hook, evaluate its output to capture environment variables.
527                // We do this even if the hook failed (exit code != 0), because tools like devenv
528                // might output valid environment exports before crashing or exiting with error.
529                // We rely on our robust delimiter-based parsing to extract what we can.
530                if hook.source.unwrap_or(false) {
531                    if hook_result.stdout.is_empty() {
532                        warn!(
533                            "Source hook produced empty stdout. Stderr content:\n{}",
534                            hook_result.stderr
535                        );
536                    } else {
537                        debug!(
538                            "Evaluating source hook output for environment variables (success={})",
539                            hook_result.success
540                        );
541                        match evaluate_shell_environment(
542                            &hook_result.stdout,
543                            &state.environment_vars,
544                        )
545                        .await
546                        {
547                            Ok((env_vars, removed_keys)) => {
548                                let count = env_vars.len();
549                                debug!(
550                                    "Captured {} environment variables from source hook ({} removed)",
551                                    count,
552                                    removed_keys.len()
553                                );
554                                // Merge captured environment variables into state
555                                for (key, value) in env_vars {
556                                    state.environment_vars.insert(key, value);
557                                }
558                                // Remove variables that were unset by the hook
559                                for key in &removed_keys {
560                                    state.environment_vars.remove(key);
561                                }
562                            }
563                            Err(e) => {
564                                warn!("Failed to evaluate source hook output: {}", e);
565                                // Don't fail the hook execution further, just log the error
566                            }
567                        }
568                    }
569                }
570
571                state.record_hook_result(index, hook_result.clone());
572                if !hook_result.success && config.fail_fast {
573                    warn!(
574                        "Hook {} failed and fail_fast is enabled, stopping",
575                        index + 1
576                    );
577                    break;
578                }
579            }
580            Err(e) => {
581                let error_msg = format!("Hook execution error: {}", e);
582                state.record_hook_result(
583                    index,
584                    HookResult::failure(
585                        hook.clone(),
586                        None,
587                        String::new(),
588                        error_msg.clone(),
589                        0,
590                        error_msg,
591                    ),
592                );
593                if config.fail_fast {
594                    warn!("Hook {} failed with error, stopping", index + 1);
595                    break;
596                }
597            }
598        }
599
600        // Save state after each hook completes
601        state_manager.save_state(state).await?;
602    }
603
604    // Mark execution as completed if we got here without errors
605    if state.status == ExecutionStatus::Running {
606        state.status = ExecutionStatus::Completed;
607        state.finished_at = Some(chrono::Utc::now());
608        info!(
609            "All hooks completed successfully for directory: {}",
610            state.directory_path.display()
611        );
612    }
613
614    // Save final state
615    state_manager.save_state(state).await?;
616
617    Ok(())
618}
619
620/// Execute a single source hook and return the resulting environment.
621///
622/// This bypasses hook approval and state persistence, making it suitable for
623/// runtime-backed environment materialization where the manifest itself is the
624/// source of truth.
625pub async fn capture_source_environment(
626    hook: Hook,
627    prior_env: &HashMap<String, String>,
628    timeout_seconds: u64,
629) -> Result<HashMap<String, String>> {
630    if !hook.source.unwrap_or(false) {
631        return Err(Error::configuration(
632            "capture_source_environment requires a source hook",
633        ));
634    }
635
636    let hook_result = execute_hook_with_timeout(hook, &timeout_seconds).await?;
637    let (env_delta, removed_keys) =
638        evaluate_shell_environment(&hook_result.stdout, prior_env).await?;
639
640    let mut environment = prior_env.clone();
641    for (key, value) in env_delta {
642        environment.insert(key, value);
643    }
644    for key in removed_keys {
645        environment.remove(&key);
646    }
647
648    Ok(environment)
649}
650
651/// Resolve the absolute path to the `env` command by searching PATH.
652/// Falls back to `/usr/bin/env` if not found (best-effort for non-Nix environments).
653fn find_env_command() -> String {
654    let path_var = std::env::var_os("PATH").unwrap_or_default();
655    for dir in std::env::split_paths(&path_var) {
656        let candidate = dir.join("env");
657        if candidate.is_file() {
658            return candidate.to_string_lossy().into_owned();
659        }
660    }
661    "/usr/bin/env".to_string()
662}
663
664/// Detect which shell to use for environment evaluation
665async fn detect_shell() -> String {
666    // Try bash first
667    if is_shell_capable("bash").await {
668        return "bash".to_string();
669    }
670
671    // Try zsh (common on macOS where bash is old)
672    if is_shell_capable("zsh").await {
673        return "zsh".to_string();
674    }
675
676    // Fall back to sh (likely to fail for advanced scripts but better than nothing)
677    "sh".to_string()
678}
679
680/// Check if a shell supports modern features like case fallthrough (;&)
681async fn is_shell_capable(shell: &str) -> bool {
682    let check_script = "case x in x) true ;& y) true ;; esac";
683    match Command::new(shell)
684        .arg("-c")
685        .arg(check_script)
686        .output()
687        .await
688    {
689        Ok(output) => output.status.success(),
690        Err(_) => false,
691    }
692}
693
694/// Evaluate shell script and extract resulting environment variables
695async fn evaluate_shell_environment(
696    shell_script: &str,
697    prior_env: &HashMap<String, String>,
698) -> Result<(HashMap<String, String>, Vec<String>)> {
699    const DELIMITER: &str = "__CUENV_ENV_START__";
700
701    debug!(
702        "Evaluating shell script to extract environment ({} bytes)",
703        shell_script.len()
704    );
705
706    tracing::trace!("Raw shell script from hook:\n{}", shell_script);
707
708    // Try to find the specific bash binary that produced this script (common in Nix/devenv)
709    // This avoids compatibility issues with system bash (e.g. macOS bash 3.2 vs Nix bash 5.x)
710    let mut shell = detect_shell().await;
711
712    for line in shell_script.lines() {
713        if let Some(path) = line.strip_prefix("BASH='")
714            && let Some(end) = path.find('\'')
715        {
716            let bash_path = &path[..end];
717            let path = PathBuf::from(bash_path);
718            if path.exists() {
719                debug!("Detected Nix bash in script: {}", bash_path);
720                shell = bash_path.to_string();
721                break;
722            }
723        }
724    }
725
726    debug!("Using shell: {}", shell);
727
728    let env_cmd = find_env_command();
729
730    // First, get the environment before running the script
731    let mut cmd_before = Command::new(&shell);
732    cmd_before.arg("-c");
733    cmd_before.arg(format!("{env_cmd} -0"));
734    cmd_before.stdout(Stdio::piped());
735    cmd_before.stderr(Stdio::piped());
736    // Inject prior hooks' environment so the baseline reflects accumulated state
737    for (key, value) in prior_env {
738        cmd_before.env(key, value);
739    }
740
741    let output_before = cmd_before
742        .output()
743        .await
744        .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
745
746    let env_before_output = String::from_utf8_lossy(&output_before.stdout);
747    let mut env_before = HashMap::new();
748    for line in env_before_output.split('\0') {
749        if let Some((key, value)) = line.split_once('=') {
750            env_before.insert(key.to_string(), value.to_string());
751        }
752    }
753
754    // Filter out lines that are likely status messages or not shell assignments
755    let filtered_lines: Vec<&str> = shell_script
756        .lines()
757        .filter(|line| {
758            let trimmed = line.trim();
759            if trimmed.is_empty() {
760                return false;
761            }
762
763            // Filter out known status/error prefixes that might pollute stdout
764            if trimmed.starts_with("✓")
765                || trimmed.starts_with("sh:")
766                || trimmed.starts_with("bash:")
767            {
768                return false;
769            }
770
771            // Otherwise keep it. We trust the tool to output valid shell code
772            // (including multiline strings, comments, unsets, aliases, etc.)
773            true
774        })
775        .collect();
776
777    let filtered_script = filtered_lines.join("\n");
778    tracing::trace!("Filtered shell script:\n{}", filtered_script);
779
780    // Now execute the filtered script and capture the environment after
781    let mut cmd = Command::new(shell);
782    cmd.arg("-c");
783
784    let script = format!(
785        "{}\necho -ne '\\0{}\\0'; {env_cmd} -0",
786        filtered_script, DELIMITER
787    );
788    cmd.arg(script);
789    cmd.stdout(Stdio::piped());
790    cmd.stderr(Stdio::piped());
791    // Inject prior hooks' environment so $PATH etc. expand correctly
792    for (key, value) in prior_env {
793        cmd.env(key, value);
794    }
795
796    let output = cmd.output().await.map_err(|e| {
797        Error::configuration(format!("Failed to evaluate shell environment: {}", e))
798    })?;
799
800    // If the command failed, we still try to parse the output, in case env -0 ran.
801    // But we should log the error.
802    if !output.status.success() {
803        let stderr = String::from_utf8_lossy(&output.stderr);
804        warn!(
805            "Shell script evaluation finished with error (exit code {:?}): {}",
806            output.status.code(),
807            stderr
808        );
809        // We continue to try to parse stdout.
810    }
811
812    // Parse the output. We expect: <script_output>\0<DELIMITER>\0<env_vars>\0...
813    let stdout_bytes = &output.stdout;
814    let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
815
816    // Find the delimiter in the output
817    let env_start_index = stdout_bytes
818        .windows(delimiter_bytes.len())
819        .position(|window| window == delimiter_bytes);
820
821    let env_output_bytes = if let Some(idx) = env_start_index {
822        // We found the delimiter, everything after it is the environment
823        &stdout_bytes[idx + delimiter_bytes.len()..]
824    } else {
825        debug!("Environment delimiter not found in hook output");
826        // Log the tail of stdout to diagnose why delimiter is missing
827        let len = stdout_bytes.len();
828        let start = len.saturating_sub(1000);
829        let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
830        warn!(
831            "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
832            tail
833        );
834
835        // Fallback: return empty if delimiter missing
836        &[]
837    };
838
839    let env_output = String::from_utf8_lossy(env_output_bytes);
840    let mut env_delta = HashMap::new();
841    let mut post_env_keys = std::collections::HashSet::new();
842
843    let is_skip_key = |key: &str| -> bool {
844        key.starts_with("BASH_FUNC_")
845            || key == "PS1"
846            || key == "PS2"
847            || key == "_"
848            || key == "PWD"
849            || key == "OLDPWD"
850            || key == "SHLVL"
851            || key.starts_with("BASH")
852    };
853
854    for line in env_output.split('\0') {
855        if line.is_empty() {
856            continue;
857        }
858
859        if let Some((key, value)) = line.split_once('=') {
860            if is_skip_key(key) {
861                continue;
862            }
863
864            if !key.is_empty() {
865                post_env_keys.insert(key.to_string());
866            }
867
868            // Only include variables that are new or changed
869            // We also skip empty keys which can happen with malformed output
870            if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
871                env_delta.insert(key.to_string(), value.to_string());
872            }
873        }
874    }
875
876    // Detect variables that were present in prior_env but removed by this hook
877    let removed_keys: Vec<String> = prior_env
878        .keys()
879        .filter(|key| !is_skip_key(key) && !post_env_keys.contains(key.as_str()))
880        .cloned()
881        .collect();
882
883    if env_delta.is_empty() && removed_keys.is_empty() && !output.status.success() {
884        // If we failed AND got no variables, that's a real problem.
885        let stderr = String::from_utf8_lossy(&output.stderr);
886        return Err(Error::configuration(format!(
887            "Shell script evaluation failed and no environment captured. Error: {}",
888            stderr
889        )));
890    }
891
892    debug!(
893        "Evaluated shell script and extracted {} new/changed environment variables ({} removed)",
894        env_delta.len(),
895        removed_keys.len()
896    );
897    Ok((env_delta, removed_keys))
898}
899
900/// Execute a single hook with timeout
901async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
902    let start_time = Instant::now();
903
904    debug!(
905        "Executing hook: {} {} (source: {})",
906        hook.command,
907        hook.args.join(" "),
908        hook.source.unwrap_or(false)
909    );
910
911    // Prepare the command
912    let mut cmd = Command::new(&hook.command);
913    cmd.args(&hook.args);
914    cmd.stdout(Stdio::piped());
915    cmd.stderr(Stdio::piped());
916
917    // Set working directory
918    if let Some(dir) = &hook.dir {
919        cmd.current_dir(dir);
920    }
921
922    // Force SHELL to match the evaluator shell for source hooks
923    // This ensures tools like devenv output compatible syntax (e.g. avoid fish syntax)
924    if hook.source.unwrap_or(false) {
925        cmd.env("SHELL", detect_shell().await);
926    }
927
928    // Execute with timeout
929    let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
930
931    // Truncation is fine here - a u64 can hold ~584M years in milliseconds
932    #[expect(
933        clippy::cast_possible_truncation,
934        reason = "u128 to u64 truncation is acceptable for duration"
935    )]
936    let duration_ms = start_time.elapsed().as_millis() as u64;
937
938    match execution_result {
939        Ok(Ok(output)) => {
940            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
941            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
942
943            if output.status.success() {
944                debug!("Hook completed successfully in {}ms", duration_ms);
945                Ok(HookResult::success(
946                    hook,
947                    output.status,
948                    stdout,
949                    stderr,
950                    duration_ms,
951                ))
952            } else {
953                warn!("Hook failed with exit code: {:?}", output.status.code());
954                Ok(HookResult::failure(
955                    hook,
956                    Some(output.status),
957                    stdout,
958                    stderr,
959                    duration_ms,
960                    format!("Command exited with status: {}", output.status),
961                ))
962            }
963        }
964        Ok(Err(io_error)) => {
965            error!("Failed to execute hook: {}", io_error);
966            Ok(HookResult::failure(
967                hook,
968                None,
969                String::new(),
970                String::new(),
971                duration_ms,
972                format!("Failed to execute command: {}", io_error),
973            ))
974        }
975        Err(_timeout_error) => {
976            warn!("Hook timed out after {} seconds", timeout_seconds);
977            Ok(HookResult::timeout(
978                hook,
979                String::new(),
980                String::new(),
981                *timeout_seconds,
982            ))
983        }
984    }
985}
986
987#[cfg(test)]
988#[expect(
989    clippy::print_stderr,
990    reason = "Tests may use eprintln! to report skip conditions"
991)]
992mod tests {
993    use super::*;
994    use crate::types::Hook;
995    use tempfile::TempDir;
996
997    /// Helper to set up CUENV_EXECUTABLE for tests that spawn the supervisor.
998    /// The cuenv binary must already be built (via `cargo build --bin cuenv`).
999    fn setup_cuenv_executable() -> Option<PathBuf> {
1000        // Check if already set
1001        if std::env::var("CUENV_EXECUTABLE").is_ok() {
1002            return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
1003        }
1004
1005        // Try to find the cuenv binary in target/debug
1006        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1007        let workspace_root = manifest_dir.parent()?.parent()?;
1008        let cuenv_binary = workspace_root.join("target/debug/cuenv");
1009
1010        if cuenv_binary.exists() {
1011            // SAFETY: This is only called in tests where we control the environment.
1012            // No other threads should be accessing this environment variable.
1013            #[expect(
1014                unsafe_code,
1015                reason = "Test helper setting env var in controlled test environment"
1016            )]
1017            unsafe {
1018                std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
1019            }
1020            Some(cuenv_binary)
1021        } else {
1022            None
1023        }
1024    }
1025
1026    #[tokio::test]
1027    async fn test_hook_executor_creation() {
1028        let temp_dir = TempDir::new().unwrap();
1029        let config = HookExecutionConfig {
1030            default_timeout_seconds: 60,
1031            fail_fast: true,
1032            state_dir: Some(temp_dir.path().to_path_buf()),
1033        };
1034
1035        let executor = HookExecutor::new(config).unwrap();
1036        assert_eq!(executor.config.default_timeout_seconds, 60);
1037    }
1038
1039    #[tokio::test]
1040    async fn test_execute_single_hook_success() {
1041        let executor = HookExecutor::with_default_config().unwrap();
1042
1043        let hook = Hook {
1044            order: 100,
1045            propagate: false,
1046            command: "echo".to_string(),
1047            args: vec!["hello".to_string()],
1048            dir: None,
1049            inputs: vec![],
1050            source: None,
1051        };
1052
1053        let result = executor.execute_single_hook(hook).await.unwrap();
1054        assert!(result.success);
1055        assert!(result.stdout.contains("hello"));
1056    }
1057
1058    #[tokio::test]
1059    async fn test_execute_single_hook_failure() {
1060        let executor = HookExecutor::with_default_config().unwrap();
1061
1062        let hook = Hook {
1063            order: 100,
1064            propagate: false,
1065            command: "false".to_string(), // Command that always fails
1066            args: vec![],
1067            dir: None,
1068            inputs: Vec::new(),
1069            source: Some(false),
1070        };
1071
1072        let result = executor.execute_single_hook(hook).await.unwrap();
1073        assert!(!result.success);
1074        assert!(result.exit_status.is_some());
1075        assert_ne!(result.exit_status.unwrap(), 0);
1076    }
1077
1078    #[tokio::test]
1079    async fn test_execute_single_hook_timeout() {
1080        let temp_dir = TempDir::new().unwrap();
1081        let config = HookExecutionConfig {
1082            default_timeout_seconds: 1, // Set timeout to 1 second
1083            fail_fast: true,
1084            state_dir: Some(temp_dir.path().to_path_buf()),
1085        };
1086        let executor = HookExecutor::new(config).unwrap();
1087
1088        let hook = Hook {
1089            order: 100,
1090            propagate: false,
1091            command: "sleep".to_string(),
1092            args: vec!["10".to_string()], // Sleep for 10 seconds
1093            dir: None,
1094            inputs: Vec::new(),
1095            source: Some(false),
1096        };
1097
1098        let result = executor.execute_single_hook(hook).await.unwrap();
1099        assert!(!result.success);
1100        assert!(result.error.as_ref().unwrap().contains("timed out"));
1101    }
1102
1103    #[tokio::test]
1104    async fn test_background_execution() {
1105        let temp_dir = TempDir::new().unwrap();
1106        let config = HookExecutionConfig {
1107            default_timeout_seconds: 30,
1108            fail_fast: true,
1109            state_dir: Some(temp_dir.path().to_path_buf()),
1110        };
1111
1112        let executor = HookExecutor::new(config).unwrap();
1113        let directory_path = PathBuf::from("/test/directory");
1114        let config_hash = "test_hash".to_string();
1115
1116        let hooks = vec![
1117            Hook {
1118                order: 100,
1119                propagate: false,
1120                command: "echo".to_string(),
1121                args: vec!["hook1".to_string()],
1122                dir: None,
1123                inputs: Vec::new(),
1124                source: Some(false),
1125            },
1126            Hook {
1127                order: 100,
1128                propagate: false,
1129                command: "echo".to_string(),
1130                args: vec!["hook2".to_string()],
1131                dir: None,
1132                inputs: Vec::new(),
1133                source: Some(false),
1134            },
1135        ];
1136
1137        let result = executor
1138            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1139            .await
1140            .unwrap();
1141
1142        assert!(result.contains("Started execution of 2 hooks"));
1143
1144        // Wait a bit for background execution to start
1145        tokio::time::sleep(Duration::from_millis(100)).await;
1146
1147        // Check execution status
1148        let status = executor
1149            .get_execution_status_for_instance(&directory_path, &config_hash)
1150            .await
1151            .unwrap();
1152        assert!(status.is_some());
1153
1154        let state = status.unwrap();
1155        assert_eq!(state.total_hooks, 2);
1156        assert_eq!(state.directory_path, directory_path);
1157    }
1158
1159    #[tokio::test]
1160    async fn test_command_validation() {
1161        let executor = HookExecutor::with_default_config().unwrap();
1162
1163        // Commands are no longer validated against a whitelist
1164        // The approval mechanism is the security boundary
1165
1166        // Test that echo command works with any arguments
1167        let hook = Hook {
1168            order: 100,
1169            propagate: false,
1170            command: "echo".to_string(),
1171            args: vec!["test message".to_string()],
1172            dir: None,
1173            inputs: Vec::new(),
1174            source: Some(false),
1175        };
1176
1177        let result = executor.execute_single_hook(hook).await;
1178        assert!(result.is_ok(), "Echo command should succeed");
1179
1180        // Verify the output contains the expected message
1181        let hook_result = result.unwrap();
1182        assert!(hook_result.stdout.contains("test message"));
1183    }
1184
1185    #[tokio::test]
1186    async fn test_cancellation() {
1187        // Skip if cuenv binary is not available
1188        if setup_cuenv_executable().is_none() {
1189            eprintln!("Skipping test_cancellation: cuenv binary not found");
1190            return;
1191        }
1192
1193        let temp_dir = TempDir::new().unwrap();
1194        let config = HookExecutionConfig {
1195            default_timeout_seconds: 30,
1196            fail_fast: false,
1197            state_dir: Some(temp_dir.path().to_path_buf()),
1198        };
1199
1200        let executor = HookExecutor::new(config).unwrap();
1201        let directory_path = PathBuf::from("/test/cancel");
1202        let config_hash = "cancel_test".to_string();
1203
1204        // Create a long-running hook
1205        let hooks = vec![Hook {
1206            order: 100,
1207            propagate: false,
1208            command: "sleep".to_string(),
1209            args: vec!["10".to_string()],
1210            dir: None,
1211            inputs: Vec::new(),
1212            source: Some(false),
1213        }];
1214
1215        executor
1216            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1217            .await
1218            .unwrap();
1219
1220        // Wait for supervisor to actually start and create state
1221        // Poll until we see Running status or timeout
1222        let mut started = false;
1223        for _ in 0..20 {
1224            tokio::time::sleep(Duration::from_millis(100)).await;
1225            if let Ok(Some(state)) = executor
1226                .get_execution_status_for_instance(&directory_path, &config_hash)
1227                .await
1228                && state.status == ExecutionStatus::Running
1229            {
1230                started = true;
1231                break;
1232            }
1233        }
1234
1235        if !started {
1236            eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1237            return;
1238        }
1239
1240        // Cancel the execution
1241        let cancelled = executor
1242            .cancel_execution(
1243                &directory_path,
1244                &config_hash,
1245                Some("User cancelled".to_string()),
1246            )
1247            .await
1248            .unwrap();
1249        assert!(cancelled);
1250
1251        // Check that state reflects cancellation
1252        let state = executor
1253            .get_execution_status_for_instance(&directory_path, &config_hash)
1254            .await
1255            .unwrap()
1256            .unwrap();
1257        assert_eq!(state.status, ExecutionStatus::Cancelled);
1258    }
1259
1260    #[tokio::test]
1261    async fn test_large_output_handling() {
1262        let executor = HookExecutor::with_default_config().unwrap();
1263
1264        // Generate a large output using printf repeating a pattern
1265        // Create a large string in the environment variable instead
1266        let large_content = "x".repeat(1000); // 1KB per line
1267        let mut args = Vec::new();
1268        // Generate 100 lines of 1KB each = 100KB total
1269        for i in 0..100 {
1270            args.push(format!("Line {}: {}", i, large_content));
1271        }
1272
1273        // Use echo with multiple arguments
1274        let hook = Hook {
1275            order: 100,
1276            propagate: false,
1277            command: "echo".to_string(),
1278            args,
1279            dir: None,
1280            inputs: Vec::new(),
1281            source: Some(false),
1282        };
1283
1284        let result = executor.execute_single_hook(hook).await.unwrap();
1285        assert!(result.success);
1286        // Output should be captured without causing memory issues
1287        assert!(result.stdout.len() > 50_000); // At least 50KB of output
1288    }
1289
1290    #[tokio::test]
1291    async fn test_state_cleanup() {
1292        // Skip if cuenv binary is not available
1293        if setup_cuenv_executable().is_none() {
1294            eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1295            return;
1296        }
1297
1298        let temp_dir = TempDir::new().unwrap();
1299        let config = HookExecutionConfig {
1300            default_timeout_seconds: 30,
1301            fail_fast: false,
1302            state_dir: Some(temp_dir.path().to_path_buf()),
1303        };
1304
1305        let executor = HookExecutor::new(config).unwrap();
1306        let directory_path = PathBuf::from("/test/cleanup");
1307        let config_hash = "cleanup_test".to_string();
1308
1309        // Execute some hooks
1310        let hooks = vec![Hook {
1311            order: 100,
1312            propagate: false,
1313            command: "echo".to_string(),
1314            args: vec!["test".to_string()],
1315            dir: None,
1316            inputs: Vec::new(),
1317            source: Some(false),
1318        }];
1319
1320        executor
1321            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1322            .await
1323            .unwrap();
1324
1325        // Poll until state exists before waiting for completion
1326        let mut state_exists = false;
1327        for _ in 0..20 {
1328            tokio::time::sleep(Duration::from_millis(100)).await;
1329            if executor
1330                .get_execution_status_for_instance(&directory_path, &config_hash)
1331                .await
1332                .unwrap()
1333                .is_some()
1334            {
1335                state_exists = true;
1336                break;
1337            }
1338        }
1339
1340        if !state_exists {
1341            eprintln!("Warning: State never created, skipping cleanup test");
1342            return;
1343        }
1344
1345        // Wait for completion
1346        if let Err(e) = executor
1347            .wait_for_completion(&directory_path, &config_hash, Some(15))
1348            .await
1349        {
1350            eprintln!(
1351                "Warning: wait_for_completion timed out: {}, skipping test",
1352                e
1353            );
1354            return;
1355        }
1356
1357        // Clean up old states (should clean up the completed state)
1358        let cleaned = executor
1359            .cleanup_old_states(chrono::Duration::seconds(0))
1360            .await
1361            .unwrap();
1362        assert_eq!(cleaned, 1);
1363
1364        // State should be gone
1365        let state = executor
1366            .get_execution_status_for_instance(&directory_path, &config_hash)
1367            .await
1368            .unwrap();
1369        assert!(state.is_none());
1370    }
1371
1372    #[tokio::test]
1373    async fn test_execution_state_tracking() {
1374        let temp_dir = TempDir::new().unwrap();
1375        let config = HookExecutionConfig {
1376            default_timeout_seconds: 30,
1377            fail_fast: true,
1378            state_dir: Some(temp_dir.path().to_path_buf()),
1379        };
1380
1381        let executor = HookExecutor::new(config).unwrap();
1382        let directory_path = PathBuf::from("/test/directory");
1383        let config_hash = "hash".to_string();
1384
1385        // Initially no state
1386        let status = executor
1387            .get_execution_status_for_instance(&directory_path, &config_hash)
1388            .await
1389            .unwrap();
1390        assert!(status.is_none());
1391
1392        // Start execution
1393        let hooks = vec![Hook {
1394            order: 100,
1395            propagate: false,
1396            command: "echo".to_string(),
1397            args: vec!["test".to_string()],
1398            dir: None,
1399            inputs: Vec::new(),
1400            source: Some(false),
1401        }];
1402
1403        executor
1404            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1405            .await
1406            .unwrap();
1407
1408        // Should now have state
1409        let status = executor
1410            .get_execution_status_for_instance(&directory_path, &config_hash)
1411            .await
1412            .unwrap();
1413        assert!(status.is_some());
1414    }
1415
1416    #[tokio::test]
1417    async fn test_working_directory_handling() {
1418        let executor = HookExecutor::with_default_config().unwrap();
1419        let temp_dir = TempDir::new().unwrap();
1420
1421        // Test with valid working directory
1422        let hook_with_valid_dir = Hook {
1423            order: 100,
1424            propagate: false,
1425            command: "pwd".to_string(),
1426            args: vec![],
1427            dir: Some(temp_dir.path().to_string_lossy().to_string()),
1428            inputs: vec![],
1429            source: None,
1430        };
1431
1432        let result = executor
1433            .execute_single_hook(hook_with_valid_dir)
1434            .await
1435            .unwrap();
1436        assert!(result.success);
1437        assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1438
1439        // Test with non-existent working directory
1440        let hook_with_invalid_dir = Hook {
1441            order: 100,
1442            propagate: false,
1443            command: "pwd".to_string(),
1444            args: vec![],
1445            dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1446            inputs: vec![],
1447            source: None,
1448        };
1449
1450        let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1451        // This might succeed or fail depending on the implementation
1452        // The important part is it doesn't panic
1453        if let Ok(output) = result {
1454            // If it succeeds, the command might have handled the missing directory
1455            assert!(
1456                !output
1457                    .stdout
1458                    .contains("/nonexistent/directory/that/does/not/exist")
1459            );
1460        }
1461    }
1462
1463    #[tokio::test]
1464    async fn test_hook_execution_with_complex_output() {
1465        let executor = HookExecutor::with_default_config().unwrap();
1466
1467        // Test simple hooks without dangerous characters
1468        let hook = Hook {
1469            order: 100,
1470            propagate: false,
1471            command: "echo".to_string(),
1472            args: vec!["stdout output".to_string()],
1473            dir: None,
1474            inputs: vec![],
1475            source: None,
1476        };
1477
1478        let result = executor.execute_single_hook(hook).await.unwrap();
1479        assert!(result.success);
1480        assert!(result.stdout.contains("stdout output"));
1481
1482        // Test hook with non-zero exit code (using false command)
1483        let hook_with_exit_code = Hook {
1484            order: 100,
1485            propagate: false,
1486            command: "false".to_string(),
1487            args: vec![],
1488            dir: None,
1489            inputs: Vec::new(),
1490            source: Some(false),
1491        };
1492
1493        let result = executor
1494            .execute_single_hook(hook_with_exit_code)
1495            .await
1496            .unwrap();
1497        assert!(!result.success);
1498        // Exit code should be non-zero
1499        assert!(result.exit_status.is_some());
1500    }
1501
1502    #[tokio::test]
1503    async fn test_state_dir_getter() {
1504        use crate::state::StateManager;
1505
1506        let temp_dir = TempDir::new().unwrap();
1507        let state_dir = temp_dir.path().to_path_buf();
1508        let state_manager = StateManager::new(state_dir.clone());
1509
1510        assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1511    }
1512
1513    /// Test timeout behavior edge cases:
1514    /// - Verify that hooks are terminated after timeout
1515    /// - Verify error message includes timeout duration
1516    /// - Verify partial output is not captured on timeout
1517    #[tokio::test]
1518    async fn test_hook_timeout_behavior() {
1519        let temp_dir = TempDir::new().unwrap();
1520
1521        // Test with very short timeout (1 second)
1522        let config = HookExecutionConfig {
1523            default_timeout_seconds: 1,
1524            fail_fast: true,
1525            state_dir: Some(temp_dir.path().to_path_buf()),
1526        };
1527        let executor = HookExecutor::new(config).unwrap();
1528
1529        // Hook that sleeps longer than timeout
1530        let slow_hook = Hook {
1531            order: 100,
1532            propagate: false,
1533            command: "sleep".to_string(),
1534            args: vec!["30".to_string()],
1535            dir: None,
1536            inputs: Vec::new(),
1537            source: Some(false),
1538        };
1539
1540        let result = executor.execute_single_hook(slow_hook).await.unwrap();
1541
1542        // Verify timeout behavior
1543        assert!(!result.success, "Hook should fail due to timeout");
1544        assert!(
1545            result.error.is_some(),
1546            "Should have error message on timeout"
1547        );
1548        let error_msg = result.error.as_ref().unwrap();
1549        assert!(
1550            error_msg.contains("timed out"),
1551            "Error should mention timeout: {}",
1552            error_msg
1553        );
1554        assert!(
1555            error_msg.contains('1'),
1556            "Error should mention timeout duration: {}",
1557            error_msg
1558        );
1559
1560        // Verify exit_status is None for timeout (process was killed)
1561        assert!(
1562            result.exit_status.is_none(),
1563            "Exit status should be None for timed out process"
1564        );
1565
1566        // Test that timeout duration is roughly correct
1567        assert!(
1568            result.duration_ms >= 1000,
1569            "Duration should be at least 1 second"
1570        );
1571        assert!(
1572            result.duration_ms < 5000,
1573            "Duration should not be much longer than timeout"
1574        );
1575    }
1576
1577    /// Test timeout with a hook that produces output before timing out
1578    #[tokio::test]
1579    async fn test_hook_timeout_with_partial_output() {
1580        let temp_dir = TempDir::new().unwrap();
1581
1582        let config = HookExecutionConfig {
1583            default_timeout_seconds: 1,
1584            fail_fast: true,
1585            state_dir: Some(temp_dir.path().to_path_buf()),
1586        };
1587        let executor = HookExecutor::new(config).unwrap();
1588
1589        // Hook that outputs something then sleeps
1590        // Using bash -c to chain commands
1591        let hook = Hook {
1592            order: 100,
1593            propagate: false,
1594            command: "bash".to_string(),
1595            args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1596            dir: None,
1597            inputs: Vec::new(),
1598            source: Some(false),
1599        };
1600
1601        let result = executor.execute_single_hook(hook).await.unwrap();
1602
1603        assert!(!result.success, "Hook should timeout");
1604        assert!(
1605            result.error.as_ref().unwrap().contains("timed out"),
1606            "Should indicate timeout"
1607        );
1608    }
1609
1610    /// Test concurrent hook isolation: multiple hooks executing in parallel
1611    /// should not interfere with each other's state or environment
1612    #[tokio::test]
1613    async fn test_concurrent_hook_isolation() {
1614        use std::sync::Arc;
1615        use tokio::task::JoinSet;
1616
1617        let temp_dir = TempDir::new().unwrap();
1618        let config = HookExecutionConfig {
1619            default_timeout_seconds: 30,
1620            fail_fast: false,
1621            state_dir: Some(temp_dir.path().to_path_buf()),
1622        };
1623        let executor = Arc::new(HookExecutor::new(config).unwrap());
1624
1625        let mut join_set = JoinSet::new();
1626
1627        // Spawn multiple hooks concurrently with unique identifiers
1628        for i in 0..5 {
1629            let executor = executor.clone();
1630            let unique_id = format!("hook_{}", i);
1631
1632            join_set.spawn(async move {
1633                let hook = Hook {
1634                    order: 100,
1635                    propagate: false,
1636                    command: "bash".to_string(),
1637                    args: vec![
1638                        "-c".to_string(),
1639                        format!(
1640                            "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1641                            unique_id, unique_id
1642                        ),
1643                    ],
1644                    dir: None,
1645                    inputs: Vec::new(),
1646                    source: Some(false),
1647                };
1648
1649                let result = executor.execute_single_hook(hook).await.unwrap();
1650                (i, result)
1651            });
1652        }
1653
1654        // Collect all results
1655        let mut results = Vec::new();
1656        while let Some(result) = join_set.join_next().await {
1657            results.push(result.unwrap());
1658        }
1659
1660        // Verify each hook completed successfully and output is isolated
1661        assert_eq!(results.len(), 5, "All 5 hooks should complete");
1662
1663        for (i, result) in results {
1664            assert!(result.success, "Hook {} should succeed", i);
1665
1666            let expected_id = format!("hook_{}", i);
1667            assert!(
1668                result.stdout.contains(&format!("ID:{}", expected_id)),
1669                "Hook {} output should contain its ID. Got: {}",
1670                i,
1671                result.stdout
1672            );
1673            assert!(
1674                result.stdout.contains(&format!("DONE:{}", expected_id)),
1675                "Hook {} output should contain its DONE marker. Got: {}",
1676                i,
1677                result.stdout
1678            );
1679
1680            // Verify no cross-contamination: output should not contain other hook IDs
1681            for j in 0..5 {
1682                if j != i {
1683                    let other_id = format!("hook_{}", j);
1684                    assert!(
1685                        !result.stdout.contains(&format!("ID:{}", other_id)),
1686                        "Hook {} output should not contain hook {} ID",
1687                        i,
1688                        j
1689                    );
1690                }
1691            }
1692        }
1693    }
1694
1695    /// Test environment variable capture with special characters including:
1696    /// - Multiline values
1697    /// - Unicode characters
1698    /// - Special shell characters (quotes, backslashes, etc.)
1699    #[tokio::test]
1700    async fn test_environment_capture_special_chars() {
1701        // Test multiline environment variable values
1702        let multiline_script = r"
1703export MULTILINE_VAR='line1
1704line2
1705line3'
1706";
1707
1708        let result = evaluate_shell_environment(multiline_script, &HashMap::new()).await;
1709        assert!(result.is_ok(), "Should parse multiline env vars");
1710
1711        let (env_vars, _removed) = result.unwrap();
1712        if let Some(value) = env_vars.get("MULTILINE_VAR") {
1713            assert!(
1714                value.contains("line1"),
1715                "Should contain first line: {}",
1716                value
1717            );
1718            assert!(
1719                value.contains("line2"),
1720                "Should contain second line: {}",
1721                value
1722            );
1723        }
1724
1725        // Test Unicode characters
1726        let unicode_script = r"
1727export UNICODE_VAR='Hello 世界 🌍 émoji'
1728export CHINESE_VAR='中文测试'
1729export JAPANESE_VAR='日本語テスト'
1730";
1731
1732        let result = evaluate_shell_environment(unicode_script, &HashMap::new()).await;
1733        assert!(result.is_ok(), "Should parse unicode env vars");
1734
1735        let (env_vars, _removed) = result.unwrap();
1736        if let Some(value) = env_vars.get("UNICODE_VAR") {
1737            assert!(
1738                value.contains("世界"),
1739                "Should preserve Chinese characters: {}",
1740                value
1741            );
1742            assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1743        }
1744
1745        // Test special shell characters
1746        let special_chars_script = r#"
1747export QUOTED_VAR="value with 'single' and \"double\" quotes"
1748export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1749export EQUALS_VAR="key=value=another"
1750"#;
1751
1752        let result = evaluate_shell_environment(special_chars_script, &HashMap::new()).await;
1753        assert!(result.is_ok(), "Should parse special chars");
1754
1755        let (env_vars, _removed) = result.unwrap();
1756        if let Some(value) = env_vars.get("EQUALS_VAR") {
1757            assert!(
1758                value.contains("key=value=another"),
1759                "Should preserve equals signs: {}",
1760                value
1761            );
1762        }
1763    }
1764
1765    /// Test environment capture with empty and whitespace-only values
1766    #[tokio::test]
1767    async fn test_environment_capture_edge_cases() {
1768        // Test empty value
1769        let empty_script = r"
1770export EMPTY_VAR=''
1771export SPACE_VAR='   '
1772";
1773
1774        let result = evaluate_shell_environment(empty_script, &HashMap::new()).await;
1775        assert!(result.is_ok(), "Should handle empty/whitespace values");
1776        let (_env_vars, _removed) = result.unwrap();
1777
1778        // Test very long value
1779        let long_value = "x".repeat(10000);
1780        let long_script = format!("export LONG_VAR='{}'", long_value);
1781
1782        let result = evaluate_shell_environment(&long_script, &HashMap::new()).await;
1783        assert!(result.is_ok(), "Should handle very long values");
1784
1785        let (env_vars, _removed) = result.unwrap();
1786        if let Some(value) = env_vars.get("LONG_VAR") {
1787            assert_eq!(value.len(), 10000, "Should preserve full length");
1788        }
1789    }
1790
1791    /// Test that prior_env is passed through to child shells and that unset propagation works
1792    #[tokio::test]
1793    async fn test_environment_prior_env_chaining() {
1794        // Test 1: prior_env variables are visible and can be extended
1795        let mut prior_env = HashMap::new();
1796        prior_env.insert("CUENV_TEST_PRIOR".to_string(), "original_value".to_string());
1797
1798        let script = r#"export CUENV_TEST_PRIOR="extended_${CUENV_TEST_PRIOR}""#;
1799        let result = evaluate_shell_environment(script, &prior_env).await;
1800        assert!(
1801            result.is_ok(),
1802            "Should evaluate with prior_env: {:?}",
1803            result.as_ref().err()
1804        );
1805
1806        let (env_vars, _removed) = result.unwrap();
1807        if let Some(value) = env_vars.get("CUENV_TEST_PRIOR") {
1808            assert!(
1809                value.contains("extended_"),
1810                "Value should contain extended_ prefix: {}",
1811                value
1812            );
1813            assert!(
1814                value.contains("original_value"),
1815                "Value should contain original_value from prior_env: {}",
1816                value
1817            );
1818        } else {
1819            panic!("CUENV_TEST_PRIOR should be in env_vars delta since it was modified");
1820        }
1821
1822        // Test 2: unsetting a prior_env variable is reported in removed_keys
1823        let mut prior_env = HashMap::new();
1824        prior_env.insert("CUENV_TEST_REMOVE".to_string(), "bar".to_string());
1825
1826        let script = "unset CUENV_TEST_REMOVE";
1827        let result = evaluate_shell_environment(script, &prior_env).await;
1828        assert!(result.is_ok(), "Should evaluate unset script");
1829
1830        let (env_vars, removed) = result.unwrap();
1831        assert!(
1832            !env_vars.contains_key("CUENV_TEST_REMOVE"),
1833            "Unset variable should not appear in env_vars"
1834        );
1835        assert!(
1836            removed.contains(&"CUENV_TEST_REMOVE".to_string()),
1837            "Unset variable should appear in removed_keys: {:?}",
1838            removed
1839        );
1840    }
1841
1842    /// Test that hooks with different working directories are isolated
1843    #[tokio::test]
1844    async fn test_working_directory_isolation() {
1845        let executor = HookExecutor::with_default_config().unwrap();
1846
1847        // Create two temp directories
1848        let temp_dir1 = TempDir::new().unwrap();
1849        let temp_dir2 = TempDir::new().unwrap();
1850
1851        // Write unique files to each directory
1852        std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1853        std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1854
1855        // Hook that reads the marker file in its working directory
1856        let hook1 = Hook {
1857            order: 100,
1858            propagate: false,
1859            command: "cat".to_string(),
1860            args: vec!["marker.txt".to_string()],
1861            dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1862            inputs: vec![],
1863            source: None,
1864        };
1865
1866        let hook2 = Hook {
1867            order: 100,
1868            propagate: false,
1869            command: "cat".to_string(),
1870            args: vec!["marker.txt".to_string()],
1871            dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1872            inputs: vec![],
1873            source: None,
1874        };
1875
1876        let result1 = executor.execute_single_hook(hook1).await.unwrap();
1877        let result2 = executor.execute_single_hook(hook2).await.unwrap();
1878
1879        assert!(result1.success, "Hook 1 should succeed");
1880        assert!(result2.success, "Hook 2 should succeed");
1881
1882        assert!(
1883            result1.stdout.contains("dir1"),
1884            "Hook 1 should read from dir1: {}",
1885            result1.stdout
1886        );
1887        assert!(
1888            result2.stdout.contains("dir2"),
1889            "Hook 2 should read from dir2: {}",
1890            result2.stdout
1891        );
1892    }
1893
1894    /// Test hook execution with stderr output
1895    #[tokio::test]
1896    async fn test_stderr_capture() {
1897        let executor = HookExecutor::with_default_config().unwrap();
1898
1899        // Hook that writes to both stdout and stderr
1900        let hook = Hook {
1901            order: 100,
1902            propagate: false,
1903            command: "bash".to_string(),
1904            args: vec![
1905                "-c".to_string(),
1906                "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1907            ],
1908            dir: None,
1909            inputs: vec![],
1910            source: None,
1911        };
1912
1913        let result = executor.execute_single_hook(hook).await.unwrap();
1914
1915        assert!(result.success, "Hook should succeed");
1916        assert!(
1917            result.stdout.contains("to stdout"),
1918            "Should capture stdout: {}",
1919            result.stdout
1920        );
1921        assert!(
1922            result.stderr.contains("to stderr"),
1923            "Should capture stderr: {}",
1924            result.stderr
1925        );
1926    }
1927
1928    /// Test that hooks handle binary output gracefully
1929    #[tokio::test]
1930    async fn test_binary_output_handling() {
1931        let executor = HookExecutor::with_default_config().unwrap();
1932
1933        // Hook that outputs some binary-like data (null bytes will be lossy-converted)
1934        let hook = Hook {
1935            order: 100,
1936            propagate: false,
1937            command: "bash".to_string(),
1938            args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1939            dir: None,
1940            inputs: vec![],
1941            source: None,
1942        };
1943
1944        let result = executor.execute_single_hook(hook).await.unwrap();
1945
1946        // Should complete without panic even with binary output
1947        assert!(result.success, "Hook should succeed");
1948        // Output will contain replacement character for null byte
1949        assert!(
1950            result.stdout.contains("hello") && result.stdout.contains("world"),
1951            "Should contain text parts: {}",
1952            result.stdout
1953        );
1954    }
1955
1956    #[tokio::test]
1957    async fn test_capture_source_environment_returns_resulting_env() {
1958        let hook = Hook {
1959            order: 100,
1960            propagate: false,
1961            command: "bash".to_string(),
1962            args: vec![
1963                "-c".to_string(),
1964                "printf '%s\n' 'export CUENV_RUNTIME_TEST=from_runtime'".to_string(),
1965            ],
1966            dir: None,
1967            inputs: vec![],
1968            source: Some(true),
1969        };
1970
1971        let environment = capture_source_environment(hook, &HashMap::new(), 5)
1972            .await
1973            .unwrap();
1974
1975        assert_eq!(
1976            environment.get("CUENV_RUNTIME_TEST"),
1977            Some(&"from_runtime".to_string())
1978        );
1979    }
1980}