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