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