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/// Detect which shell to use for environment evaluation
621async fn detect_shell() -> String {
622    // Try bash first
623    if is_shell_capable("bash").await {
624        return "bash".to_string();
625    }
626
627    // Try zsh (common on macOS where bash is old)
628    if is_shell_capable("zsh").await {
629        return "zsh".to_string();
630    }
631
632    // Fall back to sh (likely to fail for advanced scripts but better than nothing)
633    "sh".to_string()
634}
635
636/// Check if a shell supports modern features like case fallthrough (;&)
637async fn is_shell_capable(shell: &str) -> bool {
638    let check_script = "case x in x) true ;& y) true ;; esac";
639    match Command::new(shell)
640        .arg("-c")
641        .arg(check_script)
642        .output()
643        .await
644    {
645        Ok(output) => output.status.success(),
646        Err(_) => false,
647    }
648}
649
650/// Evaluate shell script and extract resulting environment variables
651async fn evaluate_shell_environment(
652    shell_script: &str,
653    prior_env: &HashMap<String, String>,
654) -> Result<(HashMap<String, String>, Vec<String>)> {
655    const DELIMITER: &str = "__CUENV_ENV_START__";
656
657    debug!(
658        "Evaluating shell script to extract environment ({} bytes)",
659        shell_script.len()
660    );
661
662    tracing::trace!("Raw shell script from hook:\n{}", shell_script);
663
664    // Try to find the specific bash binary that produced this script (common in Nix/devenv)
665    // This avoids compatibility issues with system bash (e.g. macOS bash 3.2 vs Nix bash 5.x)
666    let mut shell = detect_shell().await;
667
668    for line in shell_script.lines() {
669        if let Some(path) = line.strip_prefix("BASH='")
670            && let Some(end) = path.find('\'')
671        {
672            let bash_path = &path[..end];
673            let path = PathBuf::from(bash_path);
674            if path.exists() {
675                debug!("Detected Nix bash in script: {}", bash_path);
676                shell = bash_path.to_string();
677                break;
678            }
679        }
680    }
681
682    debug!("Using shell: {}", shell);
683
684    // First, get the environment before running the script
685    let mut cmd_before = Command::new(&shell);
686    cmd_before.arg("-c");
687    cmd_before.arg("/usr/bin/env -0");
688    cmd_before.stdout(Stdio::piped());
689    cmd_before.stderr(Stdio::piped());
690    // Inject prior hooks' environment so the baseline reflects accumulated state
691    for (key, value) in prior_env {
692        cmd_before.env(key, value);
693    }
694
695    let output_before = cmd_before
696        .output()
697        .await
698        .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
699
700    let env_before_output = String::from_utf8_lossy(&output_before.stdout);
701    let mut env_before = HashMap::new();
702    for line in env_before_output.split('\0') {
703        if let Some((key, value)) = line.split_once('=') {
704            env_before.insert(key.to_string(), value.to_string());
705        }
706    }
707
708    // Filter out lines that are likely status messages or not shell assignments
709    let filtered_lines: Vec<&str> = shell_script
710        .lines()
711        .filter(|line| {
712            let trimmed = line.trim();
713            if trimmed.is_empty() {
714                return false;
715            }
716
717            // Filter out known status/error prefixes that might pollute stdout
718            if trimmed.starts_with("✓")
719                || trimmed.starts_with("sh:")
720                || trimmed.starts_with("bash:")
721            {
722                return false;
723            }
724
725            // Otherwise keep it. We trust the tool to output valid shell code
726            // (including multiline strings, comments, unsets, aliases, etc.)
727            true
728        })
729        .collect();
730
731    let filtered_script = filtered_lines.join("\n");
732    tracing::trace!("Filtered shell script:\n{}", filtered_script);
733
734    // Now execute the filtered script and capture the environment after
735    let mut cmd = Command::new(shell);
736    cmd.arg("-c");
737
738    let script = format!(
739        "{}\necho -ne '\\0{}\\0'; /usr/bin/env -0",
740        filtered_script, DELIMITER
741    );
742    cmd.arg(script);
743    cmd.stdout(Stdio::piped());
744    cmd.stderr(Stdio::piped());
745    // Inject prior hooks' environment so $PATH etc. expand correctly
746    for (key, value) in prior_env {
747        cmd.env(key, value);
748    }
749
750    let output = cmd.output().await.map_err(|e| {
751        Error::configuration(format!("Failed to evaluate shell environment: {}", e))
752    })?;
753
754    // If the command failed, we still try to parse the output, in case env -0 ran.
755    // But we should log the error.
756    if !output.status.success() {
757        let stderr = String::from_utf8_lossy(&output.stderr);
758        warn!(
759            "Shell script evaluation finished with error (exit code {:?}): {}",
760            output.status.code(),
761            stderr
762        );
763        // We continue to try to parse stdout.
764    }
765
766    // Parse the output. We expect: <script_output>\0<DELIMITER>\0<env_vars>\0...
767    let stdout_bytes = &output.stdout;
768    let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
769
770    // Find the delimiter in the output
771    let env_start_index = stdout_bytes
772        .windows(delimiter_bytes.len())
773        .position(|window| window == delimiter_bytes);
774
775    let env_output_bytes = if let Some(idx) = env_start_index {
776        // We found the delimiter, everything after it is the environment
777        &stdout_bytes[idx + delimiter_bytes.len()..]
778    } else {
779        debug!("Environment delimiter not found in hook output");
780        // Log the tail of stdout to diagnose why delimiter is missing
781        let len = stdout_bytes.len();
782        let start = len.saturating_sub(1000);
783        let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
784        warn!(
785            "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
786            tail
787        );
788
789        // Fallback: return empty if delimiter missing
790        &[]
791    };
792
793    let env_output = String::from_utf8_lossy(env_output_bytes);
794    let mut env_delta = HashMap::new();
795    let mut post_env_keys = std::collections::HashSet::new();
796
797    let is_skip_key = |key: &str| -> bool {
798        key.starts_with("BASH_FUNC_")
799            || key == "PS1"
800            || key == "PS2"
801            || key == "_"
802            || key == "PWD"
803            || key == "OLDPWD"
804            || key == "SHLVL"
805            || key.starts_with("BASH")
806    };
807
808    for line in env_output.split('\0') {
809        if line.is_empty() {
810            continue;
811        }
812
813        if let Some((key, value)) = line.split_once('=') {
814            if is_skip_key(key) {
815                continue;
816            }
817
818            if !key.is_empty() {
819                post_env_keys.insert(key.to_string());
820            }
821
822            // Only include variables that are new or changed
823            // We also skip empty keys which can happen with malformed output
824            if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
825                env_delta.insert(key.to_string(), value.to_string());
826            }
827        }
828    }
829
830    // Detect variables that were present in prior_env but removed by this hook
831    let removed_keys: Vec<String> = prior_env
832        .keys()
833        .filter(|key| !is_skip_key(key) && !post_env_keys.contains(key.as_str()))
834        .cloned()
835        .collect();
836
837    if env_delta.is_empty() && removed_keys.is_empty() && !output.status.success() {
838        // If we failed AND got no variables, that's a real problem.
839        let stderr = String::from_utf8_lossy(&output.stderr);
840        return Err(Error::configuration(format!(
841            "Shell script evaluation failed and no environment captured. Error: {}",
842            stderr
843        )));
844    }
845
846    debug!(
847        "Evaluated shell script and extracted {} new/changed environment variables ({} removed)",
848        env_delta.len(),
849        removed_keys.len()
850    );
851    Ok((env_delta, removed_keys))
852}
853
854/// Execute a single hook with timeout
855async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
856    let start_time = Instant::now();
857
858    debug!(
859        "Executing hook: {} {} (source: {})",
860        hook.command,
861        hook.args.join(" "),
862        hook.source.unwrap_or(false)
863    );
864
865    // Prepare the command
866    let mut cmd = Command::new(&hook.command);
867    cmd.args(&hook.args);
868    cmd.stdout(Stdio::piped());
869    cmd.stderr(Stdio::piped());
870
871    // Set working directory
872    if let Some(dir) = &hook.dir {
873        cmd.current_dir(dir);
874    }
875
876    // Force SHELL to match the evaluator shell for source hooks
877    // This ensures tools like devenv output compatible syntax (e.g. avoid fish syntax)
878    if hook.source.unwrap_or(false) {
879        cmd.env("SHELL", detect_shell().await);
880    }
881
882    // Execute with timeout
883    let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
884
885    // Truncation is fine here - a u64 can hold ~584M years in milliseconds
886    #[expect(
887        clippy::cast_possible_truncation,
888        reason = "u128 to u64 truncation is acceptable for duration"
889    )]
890    let duration_ms = start_time.elapsed().as_millis() as u64;
891
892    match execution_result {
893        Ok(Ok(output)) => {
894            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
895            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
896
897            if output.status.success() {
898                debug!("Hook completed successfully in {}ms", duration_ms);
899                Ok(HookResult::success(
900                    hook,
901                    output.status,
902                    stdout,
903                    stderr,
904                    duration_ms,
905                ))
906            } else {
907                warn!("Hook failed with exit code: {:?}", output.status.code());
908                Ok(HookResult::failure(
909                    hook,
910                    Some(output.status),
911                    stdout,
912                    stderr,
913                    duration_ms,
914                    format!("Command exited with status: {}", output.status),
915                ))
916            }
917        }
918        Ok(Err(io_error)) => {
919            error!("Failed to execute hook: {}", io_error);
920            Ok(HookResult::failure(
921                hook,
922                None,
923                String::new(),
924                String::new(),
925                duration_ms,
926                format!("Failed to execute command: {}", io_error),
927            ))
928        }
929        Err(_timeout_error) => {
930            warn!("Hook timed out after {} seconds", timeout_seconds);
931            Ok(HookResult::timeout(
932                hook,
933                String::new(),
934                String::new(),
935                *timeout_seconds,
936            ))
937        }
938    }
939}
940
941#[cfg(test)]
942#[expect(
943    clippy::print_stderr,
944    reason = "Tests may use eprintln! to report skip conditions"
945)]
946mod tests {
947    use super::*;
948    use crate::types::Hook;
949    use tempfile::TempDir;
950
951    /// Helper to set up CUENV_EXECUTABLE for tests that spawn the supervisor.
952    /// The cuenv binary must already be built (via `cargo build --bin cuenv`).
953    fn setup_cuenv_executable() -> Option<PathBuf> {
954        // Check if already set
955        if std::env::var("CUENV_EXECUTABLE").is_ok() {
956            return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
957        }
958
959        // Try to find the cuenv binary in target/debug
960        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
961        let workspace_root = manifest_dir.parent()?.parent()?;
962        let cuenv_binary = workspace_root.join("target/debug/cuenv");
963
964        if cuenv_binary.exists() {
965            // SAFETY: This is only called in tests where we control the environment.
966            // No other threads should be accessing this environment variable.
967            #[expect(
968                unsafe_code,
969                reason = "Test helper setting env var in controlled test environment"
970            )]
971            unsafe {
972                std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
973            }
974            Some(cuenv_binary)
975        } else {
976            None
977        }
978    }
979
980    #[tokio::test]
981    async fn test_hook_executor_creation() {
982        let temp_dir = TempDir::new().unwrap();
983        let config = HookExecutionConfig {
984            default_timeout_seconds: 60,
985            fail_fast: true,
986            state_dir: Some(temp_dir.path().to_path_buf()),
987        };
988
989        let executor = HookExecutor::new(config).unwrap();
990        assert_eq!(executor.config.default_timeout_seconds, 60);
991    }
992
993    #[tokio::test]
994    async fn test_execute_single_hook_success() {
995        let executor = HookExecutor::with_default_config().unwrap();
996
997        let hook = Hook {
998            order: 100,
999            propagate: false,
1000            command: "echo".to_string(),
1001            args: vec!["hello".to_string()],
1002            dir: None,
1003            inputs: vec![],
1004            source: None,
1005        };
1006
1007        let result = executor.execute_single_hook(hook).await.unwrap();
1008        assert!(result.success);
1009        assert!(result.stdout.contains("hello"));
1010    }
1011
1012    #[tokio::test]
1013    async fn test_execute_single_hook_failure() {
1014        let executor = HookExecutor::with_default_config().unwrap();
1015
1016        let hook = Hook {
1017            order: 100,
1018            propagate: false,
1019            command: "false".to_string(), // Command that always fails
1020            args: vec![],
1021            dir: None,
1022            inputs: Vec::new(),
1023            source: Some(false),
1024        };
1025
1026        let result = executor.execute_single_hook(hook).await.unwrap();
1027        assert!(!result.success);
1028        assert!(result.exit_status.is_some());
1029        assert_ne!(result.exit_status.unwrap(), 0);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_execute_single_hook_timeout() {
1034        let temp_dir = TempDir::new().unwrap();
1035        let config = HookExecutionConfig {
1036            default_timeout_seconds: 1, // Set timeout to 1 second
1037            fail_fast: true,
1038            state_dir: Some(temp_dir.path().to_path_buf()),
1039        };
1040        let executor = HookExecutor::new(config).unwrap();
1041
1042        let hook = Hook {
1043            order: 100,
1044            propagate: false,
1045            command: "sleep".to_string(),
1046            args: vec!["10".to_string()], // Sleep for 10 seconds
1047            dir: None,
1048            inputs: Vec::new(),
1049            source: Some(false),
1050        };
1051
1052        let result = executor.execute_single_hook(hook).await.unwrap();
1053        assert!(!result.success);
1054        assert!(result.error.as_ref().unwrap().contains("timed out"));
1055    }
1056
1057    #[tokio::test]
1058    async fn test_background_execution() {
1059        let temp_dir = TempDir::new().unwrap();
1060        let config = HookExecutionConfig {
1061            default_timeout_seconds: 30,
1062            fail_fast: true,
1063            state_dir: Some(temp_dir.path().to_path_buf()),
1064        };
1065
1066        let executor = HookExecutor::new(config).unwrap();
1067        let directory_path = PathBuf::from("/test/directory");
1068        let config_hash = "test_hash".to_string();
1069
1070        let hooks = vec![
1071            Hook {
1072                order: 100,
1073                propagate: false,
1074                command: "echo".to_string(),
1075                args: vec!["hook1".to_string()],
1076                dir: None,
1077                inputs: Vec::new(),
1078                source: Some(false),
1079            },
1080            Hook {
1081                order: 100,
1082                propagate: false,
1083                command: "echo".to_string(),
1084                args: vec!["hook2".to_string()],
1085                dir: None,
1086                inputs: Vec::new(),
1087                source: Some(false),
1088            },
1089        ];
1090
1091        let result = executor
1092            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1093            .await
1094            .unwrap();
1095
1096        assert!(result.contains("Started execution of 2 hooks"));
1097
1098        // Wait a bit for background execution to start
1099        tokio::time::sleep(Duration::from_millis(100)).await;
1100
1101        // Check execution status
1102        let status = executor
1103            .get_execution_status_for_instance(&directory_path, &config_hash)
1104            .await
1105            .unwrap();
1106        assert!(status.is_some());
1107
1108        let state = status.unwrap();
1109        assert_eq!(state.total_hooks, 2);
1110        assert_eq!(state.directory_path, directory_path);
1111    }
1112
1113    #[tokio::test]
1114    async fn test_command_validation() {
1115        let executor = HookExecutor::with_default_config().unwrap();
1116
1117        // Commands are no longer validated against a whitelist
1118        // The approval mechanism is the security boundary
1119
1120        // Test that echo command works with any arguments
1121        let hook = Hook {
1122            order: 100,
1123            propagate: false,
1124            command: "echo".to_string(),
1125            args: vec!["test message".to_string()],
1126            dir: None,
1127            inputs: Vec::new(),
1128            source: Some(false),
1129        };
1130
1131        let result = executor.execute_single_hook(hook).await;
1132        assert!(result.is_ok(), "Echo command should succeed");
1133
1134        // Verify the output contains the expected message
1135        let hook_result = result.unwrap();
1136        assert!(hook_result.stdout.contains("test message"));
1137    }
1138
1139    #[tokio::test]
1140    async fn test_cancellation() {
1141        // Skip if cuenv binary is not available
1142        if setup_cuenv_executable().is_none() {
1143            eprintln!("Skipping test_cancellation: cuenv binary not found");
1144            return;
1145        }
1146
1147        let temp_dir = TempDir::new().unwrap();
1148        let config = HookExecutionConfig {
1149            default_timeout_seconds: 30,
1150            fail_fast: false,
1151            state_dir: Some(temp_dir.path().to_path_buf()),
1152        };
1153
1154        let executor = HookExecutor::new(config).unwrap();
1155        let directory_path = PathBuf::from("/test/cancel");
1156        let config_hash = "cancel_test".to_string();
1157
1158        // Create a long-running hook
1159        let hooks = vec![Hook {
1160            order: 100,
1161            propagate: false,
1162            command: "sleep".to_string(),
1163            args: vec!["10".to_string()],
1164            dir: None,
1165            inputs: Vec::new(),
1166            source: Some(false),
1167        }];
1168
1169        executor
1170            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1171            .await
1172            .unwrap();
1173
1174        // Wait for supervisor to actually start and create state
1175        // Poll until we see Running status or timeout
1176        let mut started = false;
1177        for _ in 0..20 {
1178            tokio::time::sleep(Duration::from_millis(100)).await;
1179            if let Ok(Some(state)) = executor
1180                .get_execution_status_for_instance(&directory_path, &config_hash)
1181                .await
1182                && state.status == ExecutionStatus::Running
1183            {
1184                started = true;
1185                break;
1186            }
1187        }
1188
1189        if !started {
1190            eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1191            return;
1192        }
1193
1194        // Cancel the execution
1195        let cancelled = executor
1196            .cancel_execution(
1197                &directory_path,
1198                &config_hash,
1199                Some("User cancelled".to_string()),
1200            )
1201            .await
1202            .unwrap();
1203        assert!(cancelled);
1204
1205        // Check that state reflects cancellation
1206        let state = executor
1207            .get_execution_status_for_instance(&directory_path, &config_hash)
1208            .await
1209            .unwrap()
1210            .unwrap();
1211        assert_eq!(state.status, ExecutionStatus::Cancelled);
1212    }
1213
1214    #[tokio::test]
1215    async fn test_large_output_handling() {
1216        let executor = HookExecutor::with_default_config().unwrap();
1217
1218        // Generate a large output using printf repeating a pattern
1219        // Create a large string in the environment variable instead
1220        let large_content = "x".repeat(1000); // 1KB per line
1221        let mut args = Vec::new();
1222        // Generate 100 lines of 1KB each = 100KB total
1223        for i in 0..100 {
1224            args.push(format!("Line {}: {}", i, large_content));
1225        }
1226
1227        // Use echo with multiple arguments
1228        let hook = Hook {
1229            order: 100,
1230            propagate: false,
1231            command: "echo".to_string(),
1232            args,
1233            dir: None,
1234            inputs: Vec::new(),
1235            source: Some(false),
1236        };
1237
1238        let result = executor.execute_single_hook(hook).await.unwrap();
1239        assert!(result.success);
1240        // Output should be captured without causing memory issues
1241        assert!(result.stdout.len() > 50_000); // At least 50KB of output
1242    }
1243
1244    #[tokio::test]
1245    async fn test_state_cleanup() {
1246        // Skip if cuenv binary is not available
1247        if setup_cuenv_executable().is_none() {
1248            eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1249            return;
1250        }
1251
1252        let temp_dir = TempDir::new().unwrap();
1253        let config = HookExecutionConfig {
1254            default_timeout_seconds: 30,
1255            fail_fast: false,
1256            state_dir: Some(temp_dir.path().to_path_buf()),
1257        };
1258
1259        let executor = HookExecutor::new(config).unwrap();
1260        let directory_path = PathBuf::from("/test/cleanup");
1261        let config_hash = "cleanup_test".to_string();
1262
1263        // Execute some hooks
1264        let hooks = vec![Hook {
1265            order: 100,
1266            propagate: false,
1267            command: "echo".to_string(),
1268            args: vec!["test".to_string()],
1269            dir: None,
1270            inputs: Vec::new(),
1271            source: Some(false),
1272        }];
1273
1274        executor
1275            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1276            .await
1277            .unwrap();
1278
1279        // Poll until state exists before waiting for completion
1280        let mut state_exists = false;
1281        for _ in 0..20 {
1282            tokio::time::sleep(Duration::from_millis(100)).await;
1283            if executor
1284                .get_execution_status_for_instance(&directory_path, &config_hash)
1285                .await
1286                .unwrap()
1287                .is_some()
1288            {
1289                state_exists = true;
1290                break;
1291            }
1292        }
1293
1294        if !state_exists {
1295            eprintln!("Warning: State never created, skipping cleanup test");
1296            return;
1297        }
1298
1299        // Wait for completion
1300        if let Err(e) = executor
1301            .wait_for_completion(&directory_path, &config_hash, Some(15))
1302            .await
1303        {
1304            eprintln!(
1305                "Warning: wait_for_completion timed out: {}, skipping test",
1306                e
1307            );
1308            return;
1309        }
1310
1311        // Clean up old states (should clean up the completed state)
1312        let cleaned = executor
1313            .cleanup_old_states(chrono::Duration::seconds(0))
1314            .await
1315            .unwrap();
1316        assert_eq!(cleaned, 1);
1317
1318        // State should be gone
1319        let state = executor
1320            .get_execution_status_for_instance(&directory_path, &config_hash)
1321            .await
1322            .unwrap();
1323        assert!(state.is_none());
1324    }
1325
1326    #[tokio::test]
1327    async fn test_execution_state_tracking() {
1328        let temp_dir = TempDir::new().unwrap();
1329        let config = HookExecutionConfig {
1330            default_timeout_seconds: 30,
1331            fail_fast: true,
1332            state_dir: Some(temp_dir.path().to_path_buf()),
1333        };
1334
1335        let executor = HookExecutor::new(config).unwrap();
1336        let directory_path = PathBuf::from("/test/directory");
1337        let config_hash = "hash".to_string();
1338
1339        // Initially no state
1340        let status = executor
1341            .get_execution_status_for_instance(&directory_path, &config_hash)
1342            .await
1343            .unwrap();
1344        assert!(status.is_none());
1345
1346        // Start execution
1347        let hooks = vec![Hook {
1348            order: 100,
1349            propagate: false,
1350            command: "echo".to_string(),
1351            args: vec!["test".to_string()],
1352            dir: None,
1353            inputs: Vec::new(),
1354            source: Some(false),
1355        }];
1356
1357        executor
1358            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1359            .await
1360            .unwrap();
1361
1362        // Should now have state
1363        let status = executor
1364            .get_execution_status_for_instance(&directory_path, &config_hash)
1365            .await
1366            .unwrap();
1367        assert!(status.is_some());
1368    }
1369
1370    #[tokio::test]
1371    async fn test_working_directory_handling() {
1372        let executor = HookExecutor::with_default_config().unwrap();
1373        let temp_dir = TempDir::new().unwrap();
1374
1375        // Test with valid working directory
1376        let hook_with_valid_dir = Hook {
1377            order: 100,
1378            propagate: false,
1379            command: "pwd".to_string(),
1380            args: vec![],
1381            dir: Some(temp_dir.path().to_string_lossy().to_string()),
1382            inputs: vec![],
1383            source: None,
1384        };
1385
1386        let result = executor
1387            .execute_single_hook(hook_with_valid_dir)
1388            .await
1389            .unwrap();
1390        assert!(result.success);
1391        assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1392
1393        // Test with non-existent working directory
1394        let hook_with_invalid_dir = Hook {
1395            order: 100,
1396            propagate: false,
1397            command: "pwd".to_string(),
1398            args: vec![],
1399            dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1400            inputs: vec![],
1401            source: None,
1402        };
1403
1404        let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1405        // This might succeed or fail depending on the implementation
1406        // The important part is it doesn't panic
1407        if let Ok(output) = result {
1408            // If it succeeds, the command might have handled the missing directory
1409            assert!(
1410                !output
1411                    .stdout
1412                    .contains("/nonexistent/directory/that/does/not/exist")
1413            );
1414        }
1415    }
1416
1417    #[tokio::test]
1418    async fn test_hook_execution_with_complex_output() {
1419        let executor = HookExecutor::with_default_config().unwrap();
1420
1421        // Test simple hooks without dangerous characters
1422        let hook = Hook {
1423            order: 100,
1424            propagate: false,
1425            command: "echo".to_string(),
1426            args: vec!["stdout output".to_string()],
1427            dir: None,
1428            inputs: vec![],
1429            source: None,
1430        };
1431
1432        let result = executor.execute_single_hook(hook).await.unwrap();
1433        assert!(result.success);
1434        assert!(result.stdout.contains("stdout output"));
1435
1436        // Test hook with non-zero exit code (using false command)
1437        let hook_with_exit_code = Hook {
1438            order: 100,
1439            propagate: false,
1440            command: "false".to_string(),
1441            args: vec![],
1442            dir: None,
1443            inputs: Vec::new(),
1444            source: Some(false),
1445        };
1446
1447        let result = executor
1448            .execute_single_hook(hook_with_exit_code)
1449            .await
1450            .unwrap();
1451        assert!(!result.success);
1452        // Exit code should be non-zero
1453        assert!(result.exit_status.is_some());
1454    }
1455
1456    #[tokio::test]
1457    async fn test_state_dir_getter() {
1458        use crate::state::StateManager;
1459
1460        let temp_dir = TempDir::new().unwrap();
1461        let state_dir = temp_dir.path().to_path_buf();
1462        let state_manager = StateManager::new(state_dir.clone());
1463
1464        assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1465    }
1466
1467    /// Test timeout behavior edge cases:
1468    /// - Verify that hooks are terminated after timeout
1469    /// - Verify error message includes timeout duration
1470    /// - Verify partial output is not captured on timeout
1471    #[tokio::test]
1472    async fn test_hook_timeout_behavior() {
1473        let temp_dir = TempDir::new().unwrap();
1474
1475        // Test with very short timeout (1 second)
1476        let config = HookExecutionConfig {
1477            default_timeout_seconds: 1,
1478            fail_fast: true,
1479            state_dir: Some(temp_dir.path().to_path_buf()),
1480        };
1481        let executor = HookExecutor::new(config).unwrap();
1482
1483        // Hook that sleeps longer than timeout
1484        let slow_hook = Hook {
1485            order: 100,
1486            propagate: false,
1487            command: "sleep".to_string(),
1488            args: vec!["30".to_string()],
1489            dir: None,
1490            inputs: Vec::new(),
1491            source: Some(false),
1492        };
1493
1494        let result = executor.execute_single_hook(slow_hook).await.unwrap();
1495
1496        // Verify timeout behavior
1497        assert!(!result.success, "Hook should fail due to timeout");
1498        assert!(
1499            result.error.is_some(),
1500            "Should have error message on timeout"
1501        );
1502        let error_msg = result.error.as_ref().unwrap();
1503        assert!(
1504            error_msg.contains("timed out"),
1505            "Error should mention timeout: {}",
1506            error_msg
1507        );
1508        assert!(
1509            error_msg.contains('1'),
1510            "Error should mention timeout duration: {}",
1511            error_msg
1512        );
1513
1514        // Verify exit_status is None for timeout (process was killed)
1515        assert!(
1516            result.exit_status.is_none(),
1517            "Exit status should be None for timed out process"
1518        );
1519
1520        // Test that timeout duration is roughly correct
1521        assert!(
1522            result.duration_ms >= 1000,
1523            "Duration should be at least 1 second"
1524        );
1525        assert!(
1526            result.duration_ms < 5000,
1527            "Duration should not be much longer than timeout"
1528        );
1529    }
1530
1531    /// Test timeout with a hook that produces output before timing out
1532    #[tokio::test]
1533    async fn test_hook_timeout_with_partial_output() {
1534        let temp_dir = TempDir::new().unwrap();
1535
1536        let config = HookExecutionConfig {
1537            default_timeout_seconds: 1,
1538            fail_fast: true,
1539            state_dir: Some(temp_dir.path().to_path_buf()),
1540        };
1541        let executor = HookExecutor::new(config).unwrap();
1542
1543        // Hook that outputs something then sleeps
1544        // Using bash -c to chain commands
1545        let hook = Hook {
1546            order: 100,
1547            propagate: false,
1548            command: "bash".to_string(),
1549            args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1550            dir: None,
1551            inputs: Vec::new(),
1552            source: Some(false),
1553        };
1554
1555        let result = executor.execute_single_hook(hook).await.unwrap();
1556
1557        assert!(!result.success, "Hook should timeout");
1558        assert!(
1559            result.error.as_ref().unwrap().contains("timed out"),
1560            "Should indicate timeout"
1561        );
1562    }
1563
1564    /// Test concurrent hook isolation: multiple hooks executing in parallel
1565    /// should not interfere with each other's state or environment
1566    #[tokio::test]
1567    async fn test_concurrent_hook_isolation() {
1568        use std::sync::Arc;
1569        use tokio::task::JoinSet;
1570
1571        let temp_dir = TempDir::new().unwrap();
1572        let config = HookExecutionConfig {
1573            default_timeout_seconds: 30,
1574            fail_fast: false,
1575            state_dir: Some(temp_dir.path().to_path_buf()),
1576        };
1577        let executor = Arc::new(HookExecutor::new(config).unwrap());
1578
1579        let mut join_set = JoinSet::new();
1580
1581        // Spawn multiple hooks concurrently with unique identifiers
1582        for i in 0..5 {
1583            let executor = executor.clone();
1584            let unique_id = format!("hook_{}", i);
1585
1586            join_set.spawn(async move {
1587                let hook = Hook {
1588                    order: 100,
1589                    propagate: false,
1590                    command: "bash".to_string(),
1591                    args: vec![
1592                        "-c".to_string(),
1593                        format!(
1594                            "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1595                            unique_id, unique_id
1596                        ),
1597                    ],
1598                    dir: None,
1599                    inputs: Vec::new(),
1600                    source: Some(false),
1601                };
1602
1603                let result = executor.execute_single_hook(hook).await.unwrap();
1604                (i, result)
1605            });
1606        }
1607
1608        // Collect all results
1609        let mut results = Vec::new();
1610        while let Some(result) = join_set.join_next().await {
1611            results.push(result.unwrap());
1612        }
1613
1614        // Verify each hook completed successfully and output is isolated
1615        assert_eq!(results.len(), 5, "All 5 hooks should complete");
1616
1617        for (i, result) in results {
1618            assert!(result.success, "Hook {} should succeed", i);
1619
1620            let expected_id = format!("hook_{}", i);
1621            assert!(
1622                result.stdout.contains(&format!("ID:{}", expected_id)),
1623                "Hook {} output should contain its ID. Got: {}",
1624                i,
1625                result.stdout
1626            );
1627            assert!(
1628                result.stdout.contains(&format!("DONE:{}", expected_id)),
1629                "Hook {} output should contain its DONE marker. Got: {}",
1630                i,
1631                result.stdout
1632            );
1633
1634            // Verify no cross-contamination: output should not contain other hook IDs
1635            for j in 0..5 {
1636                if j != i {
1637                    let other_id = format!("hook_{}", j);
1638                    assert!(
1639                        !result.stdout.contains(&format!("ID:{}", other_id)),
1640                        "Hook {} output should not contain hook {} ID",
1641                        i,
1642                        j
1643                    );
1644                }
1645            }
1646        }
1647    }
1648
1649    /// Test environment variable capture with special characters including:
1650    /// - Multiline values
1651    /// - Unicode characters
1652    /// - Special shell characters (quotes, backslashes, etc.)
1653    #[tokio::test]
1654    async fn test_environment_capture_special_chars() {
1655        // Test multiline environment variable values
1656        let multiline_script = r"
1657export MULTILINE_VAR='line1
1658line2
1659line3'
1660";
1661
1662        let result = evaluate_shell_environment(multiline_script, &HashMap::new()).await;
1663        assert!(result.is_ok(), "Should parse multiline env vars");
1664
1665        let (env_vars, _removed) = result.unwrap();
1666        if let Some(value) = env_vars.get("MULTILINE_VAR") {
1667            assert!(
1668                value.contains("line1"),
1669                "Should contain first line: {}",
1670                value
1671            );
1672            assert!(
1673                value.contains("line2"),
1674                "Should contain second line: {}",
1675                value
1676            );
1677        }
1678
1679        // Test Unicode characters
1680        let unicode_script = r"
1681export UNICODE_VAR='Hello 世界 🌍 émoji'
1682export CHINESE_VAR='中文测试'
1683export JAPANESE_VAR='日本語テスト'
1684";
1685
1686        let result = evaluate_shell_environment(unicode_script, &HashMap::new()).await;
1687        assert!(result.is_ok(), "Should parse unicode env vars");
1688
1689        let (env_vars, _removed) = result.unwrap();
1690        if let Some(value) = env_vars.get("UNICODE_VAR") {
1691            assert!(
1692                value.contains("世界"),
1693                "Should preserve Chinese characters: {}",
1694                value
1695            );
1696            assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1697        }
1698
1699        // Test special shell characters
1700        let special_chars_script = r#"
1701export QUOTED_VAR="value with 'single' and \"double\" quotes"
1702export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1703export EQUALS_VAR="key=value=another"
1704"#;
1705
1706        let result = evaluate_shell_environment(special_chars_script, &HashMap::new()).await;
1707        assert!(result.is_ok(), "Should parse special chars");
1708
1709        let (env_vars, _removed) = result.unwrap();
1710        if let Some(value) = env_vars.get("EQUALS_VAR") {
1711            assert!(
1712                value.contains("key=value=another"),
1713                "Should preserve equals signs: {}",
1714                value
1715            );
1716        }
1717    }
1718
1719    /// Test environment capture with empty and whitespace-only values
1720    #[tokio::test]
1721    async fn test_environment_capture_edge_cases() {
1722        // Test empty value
1723        let empty_script = r"
1724export EMPTY_VAR=''
1725export SPACE_VAR='   '
1726";
1727
1728        let result = evaluate_shell_environment(empty_script, &HashMap::new()).await;
1729        assert!(result.is_ok(), "Should handle empty/whitespace values");
1730        let (_env_vars, _removed) = result.unwrap();
1731
1732        // Test very long value
1733        let long_value = "x".repeat(10000);
1734        let long_script = format!("export LONG_VAR='{}'", long_value);
1735
1736        let result = evaluate_shell_environment(&long_script, &HashMap::new()).await;
1737        assert!(result.is_ok(), "Should handle very long values");
1738
1739        let (env_vars, _removed) = result.unwrap();
1740        if let Some(value) = env_vars.get("LONG_VAR") {
1741            assert_eq!(value.len(), 10000, "Should preserve full length");
1742        }
1743    }
1744
1745    /// Test that prior_env is passed through to child shells and that unset propagation works
1746    #[tokio::test]
1747    async fn test_environment_prior_env_chaining() {
1748        // Test 1: prior_env variables are visible and can be extended
1749        let mut prior_env = HashMap::new();
1750        prior_env.insert("CUENV_TEST_PRIOR".to_string(), "original_value".to_string());
1751
1752        let script = r#"export CUENV_TEST_PRIOR="extended_${CUENV_TEST_PRIOR}""#;
1753        let result = evaluate_shell_environment(script, &prior_env).await;
1754        assert!(
1755            result.is_ok(),
1756            "Should evaluate with prior_env: {:?}",
1757            result.as_ref().err()
1758        );
1759
1760        let (env_vars, _removed) = result.unwrap();
1761        if let Some(value) = env_vars.get("CUENV_TEST_PRIOR") {
1762            assert!(
1763                value.contains("extended_"),
1764                "Value should contain extended_ prefix: {}",
1765                value
1766            );
1767            assert!(
1768                value.contains("original_value"),
1769                "Value should contain original_value from prior_env: {}",
1770                value
1771            );
1772        } else {
1773            panic!("CUENV_TEST_PRIOR should be in env_vars delta since it was modified");
1774        }
1775
1776        // Test 2: unsetting a prior_env variable is reported in removed_keys
1777        let mut prior_env = HashMap::new();
1778        prior_env.insert("CUENV_TEST_REMOVE".to_string(), "bar".to_string());
1779
1780        let script = "unset CUENV_TEST_REMOVE";
1781        let result = evaluate_shell_environment(script, &prior_env).await;
1782        assert!(result.is_ok(), "Should evaluate unset script");
1783
1784        let (env_vars, removed) = result.unwrap();
1785        assert!(
1786            !env_vars.contains_key("CUENV_TEST_REMOVE"),
1787            "Unset variable should not appear in env_vars"
1788        );
1789        assert!(
1790            removed.contains(&"CUENV_TEST_REMOVE".to_string()),
1791            "Unset variable should appear in removed_keys: {:?}",
1792            removed
1793        );
1794    }
1795
1796    /// Test that hooks with different working directories are isolated
1797    #[tokio::test]
1798    async fn test_working_directory_isolation() {
1799        let executor = HookExecutor::with_default_config().unwrap();
1800
1801        // Create two temp directories
1802        let temp_dir1 = TempDir::new().unwrap();
1803        let temp_dir2 = TempDir::new().unwrap();
1804
1805        // Write unique files to each directory
1806        std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1807        std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1808
1809        // Hook that reads the marker file in its working directory
1810        let hook1 = Hook {
1811            order: 100,
1812            propagate: false,
1813            command: "cat".to_string(),
1814            args: vec!["marker.txt".to_string()],
1815            dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1816            inputs: vec![],
1817            source: None,
1818        };
1819
1820        let hook2 = Hook {
1821            order: 100,
1822            propagate: false,
1823            command: "cat".to_string(),
1824            args: vec!["marker.txt".to_string()],
1825            dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1826            inputs: vec![],
1827            source: None,
1828        };
1829
1830        let result1 = executor.execute_single_hook(hook1).await.unwrap();
1831        let result2 = executor.execute_single_hook(hook2).await.unwrap();
1832
1833        assert!(result1.success, "Hook 1 should succeed");
1834        assert!(result2.success, "Hook 2 should succeed");
1835
1836        assert!(
1837            result1.stdout.contains("dir1"),
1838            "Hook 1 should read from dir1: {}",
1839            result1.stdout
1840        );
1841        assert!(
1842            result2.stdout.contains("dir2"),
1843            "Hook 2 should read from dir2: {}",
1844            result2.stdout
1845        );
1846    }
1847
1848    /// Test hook execution with stderr output
1849    #[tokio::test]
1850    async fn test_stderr_capture() {
1851        let executor = HookExecutor::with_default_config().unwrap();
1852
1853        // Hook that writes to both stdout and stderr
1854        let hook = Hook {
1855            order: 100,
1856            propagate: false,
1857            command: "bash".to_string(),
1858            args: vec![
1859                "-c".to_string(),
1860                "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1861            ],
1862            dir: None,
1863            inputs: vec![],
1864            source: None,
1865        };
1866
1867        let result = executor.execute_single_hook(hook).await.unwrap();
1868
1869        assert!(result.success, "Hook should succeed");
1870        assert!(
1871            result.stdout.contains("to stdout"),
1872            "Should capture stdout: {}",
1873            result.stdout
1874        );
1875        assert!(
1876            result.stderr.contains("to stderr"),
1877            "Should capture stderr: {}",
1878            result.stderr
1879        );
1880    }
1881
1882    /// Test that hooks handle binary output gracefully
1883    #[tokio::test]
1884    async fn test_binary_output_handling() {
1885        let executor = HookExecutor::with_default_config().unwrap();
1886
1887        // Hook that outputs some binary-like data (null bytes will be lossy-converted)
1888        let hook = Hook {
1889            order: 100,
1890            propagate: false,
1891            command: "bash".to_string(),
1892            args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1893            dir: None,
1894            inputs: vec![],
1895            source: None,
1896        };
1897
1898        let result = executor.execute_single_hook(hook).await.unwrap();
1899
1900        // Should complete without panic even with binary output
1901        assert!(result.success, "Hook should succeed");
1902        // Output will contain replacement character for null byte
1903        assert!(
1904            result.stdout.contains("hello") && result.stdout.contains("world"),
1905            "Should contain text parts: {}",
1906            result.stdout
1907        );
1908    }
1909}