cuenv_core/hooks/
executor.rs

1//! Hook execution engine with background processing and state management
2
3use crate::hooks::state::{HookExecutionState, StateManager, compute_instance_hash};
4use crate::hooks::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        if hooks.is_empty() {
58            return Ok("No hooks to execute".to_string());
59        }
60
61        let instance_hash = compute_instance_hash(&directory_path, &config_hash);
62        let total_hooks = hooks.len();
63
64        // Check for existing state to preserve previous environment
65        let previous_env =
66            if let Ok(Some(existing_state)) = self.state_manager.load_state(&instance_hash).await {
67                // If we have a completed state, save its environment as previous
68                if existing_state.status == ExecutionStatus::Completed {
69                    Some(existing_state.environment_vars.clone())
70                } else {
71                    existing_state.previous_env
72                }
73            } else {
74                None
75            };
76
77        // Create initial execution state with previous environment
78        let mut state = HookExecutionState::new(
79            directory_path.clone(),
80            instance_hash.clone(),
81            config_hash.clone(),
82            hooks.clone(),
83        );
84        state.previous_env = previous_env;
85
86        // Save initial state
87        self.state_manager.save_state(&state).await?;
88
89        info!(
90            "Starting background execution of {} hooks for directory: {}",
91            total_hooks,
92            directory_path.display()
93        );
94
95        // Check if a supervisor is already running for this instance
96        let pid_file = self
97            .state_manager
98            .get_state_file_path(&instance_hash)
99            .with_extension("pid");
100
101        if pid_file.exists() {
102            // Read the PID and check if process is still running
103            if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
104                && let Ok(pid) = pid_str.trim().parse::<usize>()
105            {
106                // Check if process is still alive using sysinfo
107                use sysinfo::{Pid, ProcessRefreshKind, System};
108                let mut system = System::new();
109                let process_pid = Pid::from(pid);
110                system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
111
112                if system.process(process_pid).is_some() {
113                    info!("Supervisor already running for directory with PID {}", pid);
114                    return Ok(format!(
115                        "Supervisor already running for {} hooks (PID: {})",
116                        total_hooks, pid
117                    ));
118                }
119            }
120            // If we get here, the PID file exists but process is dead
121            std::fs::remove_file(&pid_file).ok();
122        }
123
124        // Write hooks and config to temp files to avoid argument size limits
125        let state_dir = self.state_manager.get_state_dir();
126        let hooks_file = state_dir.join(format!("{}_hooks.json", instance_hash));
127        let config_file = state_dir.join(format!("{}_config.json", instance_hash));
128
129        // Serialize and write hooks
130        let hooks_json = serde_json::to_string(&hooks)
131            .map_err(|e| Error::configuration(format!("Failed to serialize hooks: {}", e)))?;
132        std::fs::write(&hooks_file, &hooks_json).map_err(|e| Error::Io {
133            source: e,
134            path: Some(hooks_file.clone().into_boxed_path()),
135            operation: "write".to_string(),
136        })?;
137
138        // Serialize and write config
139        let config_json = serde_json::to_string(&self.config)
140            .map_err(|e| Error::configuration(format!("Failed to serialize config: {}", e)))?;
141        std::fs::write(&config_file, &config_json).map_err(|e| Error::Io {
142            source: e,
143            path: Some(config_file.clone().into_boxed_path()),
144            operation: "write".to_string(),
145        })?;
146
147        // Get the executable path to spawn as supervisor
148        // Allow override via CUENV_EXECUTABLE for testing
149        let current_exe = if let Ok(exe_path) = std::env::var("CUENV_EXECUTABLE") {
150            PathBuf::from(exe_path)
151        } else {
152            std::env::current_exe()
153                .map_err(|e| Error::configuration(format!("Failed to get current exe: {}", e)))?
154        };
155
156        // Spawn a detached supervisor process
157        use std::process::{Command, Stdio};
158
159        let mut cmd = Command::new(&current_exe);
160        cmd.arg("__hook-supervisor") // Special hidden command
161            .arg("--directory")
162            .arg(directory_path.to_string_lossy().to_string())
163            .arg("--instance-hash")
164            .arg(&instance_hash)
165            .arg("--config-hash")
166            .arg(&config_hash)
167            .arg("--hooks-file")
168            .arg(hooks_file.to_string_lossy().to_string())
169            .arg("--config-file")
170            .arg(config_file.to_string_lossy().to_string())
171            .stdin(Stdio::null());
172
173        // Redirect output to log files for debugging
174        let temp_dir = std::env::temp_dir();
175        let log_file = std::fs::File::create(temp_dir.join("cuenv_supervisor.log")).ok();
176        let err_file = std::fs::File::create(temp_dir.join("cuenv_supervisor_err.log")).ok();
177
178        if let Some(log) = log_file {
179            cmd.stdout(Stdio::from(log));
180        } else {
181            cmd.stdout(Stdio::null());
182        }
183
184        if let Some(err) = err_file {
185            cmd.stderr(Stdio::from(err));
186        } else {
187            cmd.stderr(Stdio::null());
188        }
189
190        // Pass through CUENV_STATE_DIR if set
191        if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
192            cmd.env("CUENV_STATE_DIR", state_dir);
193        }
194
195        // Pass through CUENV_APPROVAL_FILE if set
196        if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
197            cmd.env("CUENV_APPROVAL_FILE", approval_file);
198        }
199
200        // Pass through RUST_LOG for debugging
201        if let Ok(rust_log) = std::env::var("RUST_LOG") {
202            cmd.env("RUST_LOG", rust_log);
203        }
204
205        // Platform-specific detachment configuration
206        #[cfg(unix)]
207        {
208            use std::os::unix::process::CommandExt;
209            // Detach from parent process group using setsid
210            unsafe {
211                cmd.pre_exec(|| {
212                    // Create a new session, detaching from controlling terminal
213                    if libc::setsid() == -1 {
214                        return Err(std::io::Error::last_os_error());
215                    }
216                    Ok(())
217                });
218            }
219        }
220
221        #[cfg(windows)]
222        {
223            use std::os::windows::process::CommandExt;
224            // Windows-specific flags for detached process
225            const DETACHED_PROCESS: u32 = 0x00000008;
226            const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
227            cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
228        }
229
230        let _child = cmd
231            .spawn()
232            .map_err(|e| Error::configuration(format!("Failed to spawn supervisor: {}", e)))?;
233
234        // The child is now properly detached
235
236        info!("Spawned supervisor process for hook execution");
237
238        Ok(format!(
239            "Started execution of {} hooks in background",
240            total_hooks
241        ))
242    }
243
244    /// Get the current execution status for a directory
245    pub async fn get_execution_status(
246        &self,
247        directory_path: &Path,
248    ) -> Result<Option<HookExecutionState>> {
249        // List all active states and find one matching this directory
250        let states = self.state_manager.list_active_states().await?;
251        for state in states {
252            if state.directory_path == directory_path {
253                return Ok(Some(state));
254            }
255        }
256        Ok(None)
257    }
258
259    /// Get execution status for a specific instance (directory + config)
260    pub async fn get_execution_status_for_instance(
261        &self,
262        directory_path: &Path,
263        config_hash: &str,
264    ) -> Result<Option<HookExecutionState>> {
265        let instance_hash = compute_instance_hash(directory_path, config_hash);
266        self.state_manager.load_state(&instance_hash).await
267    }
268
269    /// Wait for hook execution to complete, with optional timeout in seconds
270    pub async fn wait_for_completion(
271        &self,
272        directory_path: &Path,
273        config_hash: &str,
274        timeout_seconds: Option<u64>,
275    ) -> Result<HookExecutionState> {
276        let instance_hash = compute_instance_hash(directory_path, config_hash);
277        let poll_interval = Duration::from_millis(500);
278        let start_time = Instant::now();
279
280        loop {
281            if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
282                if state.is_complete() {
283                    return Ok(state);
284                }
285            } else {
286                return Err(Error::configuration("No execution state found"));
287            }
288
289            // Check timeout
290            if let Some(timeout) = timeout_seconds
291                && start_time.elapsed().as_secs() >= timeout
292            {
293                return Err(Error::Timeout { seconds: timeout });
294            }
295
296            tokio::time::sleep(poll_interval).await;
297        }
298    }
299
300    /// Cancel execution for a directory
301    pub async fn cancel_execution(
302        &self,
303        directory_path: &Path,
304        config_hash: &str,
305        reason: Option<String>,
306    ) -> Result<bool> {
307        let instance_hash = compute_instance_hash(directory_path, config_hash);
308
309        // Try to kill the supervisor process if it exists
310        let pid_file = self
311            .state_manager
312            .get_state_file_path(&instance_hash)
313            .with_extension("pid");
314
315        if pid_file.exists()
316            && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
317            && let Ok(pid) = pid_str.trim().parse::<usize>()
318        {
319            use sysinfo::{Pid, ProcessRefreshKind, Signal, System};
320
321            let mut system = System::new();
322            let process_pid = Pid::from(pid);
323
324            // Refresh the specific process
325            system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
326
327            // Check if process exists and kill it
328            if let Some(process) = system.process(process_pid) {
329                if process.kill_with(Signal::Term).is_some() {
330                    info!("Sent SIGTERM to supervisor process PID {}", pid);
331                } else {
332                    warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
333                }
334            } else {
335                info!(
336                    "Supervisor process PID {} not found (may have already exited)",
337                    pid
338                );
339            }
340
341            // Clean up PID file regardless
342            std::fs::remove_file(&pid_file).ok();
343        }
344
345        // Then update the state
346        if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
347            && !state.is_complete()
348        {
349            state.mark_cancelled(reason);
350            self.state_manager.save_state(&state).await?;
351            info!(
352                "Cancelled execution for directory: {}",
353                directory_path.display()
354            );
355            return Ok(true);
356        }
357
358        Ok(false)
359    }
360
361    /// Clean up completed execution states older than the specified duration
362    pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
363        let states = self.state_manager.list_active_states().await?;
364        let cutoff = chrono::Utc::now() - older_than;
365        let mut cleaned_count = 0;
366
367        for state in states {
368            if state.is_complete()
369                && let Some(finished_at) = state.finished_at
370                && finished_at < cutoff
371            {
372                self.state_manager
373                    .remove_state(&state.instance_hash)
374                    .await?;
375                cleaned_count += 1;
376            }
377        }
378
379        if cleaned_count > 0 {
380            info!("Cleaned up {} old execution states", cleaned_count);
381        }
382
383        Ok(cleaned_count)
384    }
385
386    /// Execute a single hook and return the result
387    pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
388        // Use the default timeout from config
389        let timeout = self.config.default_timeout_seconds;
390
391        // No validation - users approved this config with cuenv allow
392        execute_hook_with_timeout(hook, &timeout).await
393    }
394}
395
396/// Execute hooks sequentially
397pub async fn execute_hooks(
398    hooks: Vec<Hook>,
399    _directory_path: &Path,
400    config: &HookExecutionConfig,
401    state_manager: &StateManager,
402    state: &mut HookExecutionState,
403) -> Result<()> {
404    let hook_count = hooks.len();
405    debug!("execute_hooks called with {} hooks", hook_count);
406    if hook_count == 0 {
407        debug!("No hooks to execute");
408        return Ok(());
409    }
410    debug!("Starting to iterate over {} hooks", hook_count);
411    for (index, hook) in hooks.into_iter().enumerate() {
412        debug!(
413            "Processing hook {}/{}: command={}",
414            index + 1,
415            state.total_hooks,
416            hook.command
417        );
418        // Check if execution was cancelled
419        debug!("Checking if execution was cancelled");
420        if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
421            debug!("Loaded state: status = {:?}", current_state.status);
422            if current_state.status == ExecutionStatus::Cancelled {
423                debug!("Execution was cancelled, stopping");
424                break;
425            }
426        }
427
428        // No validation - users approved this config with cuenv allow
429
430        let timeout_seconds = config.default_timeout_seconds;
431
432        // Mark hook as running
433        state.mark_hook_running(index);
434
435        // Execute the hook and wait for it to complete
436        let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
437
438        // Record the result
439        match result {
440            Ok(hook_result) => {
441                // If this is a source hook, evaluate its output to capture environment variables.
442                // We do this even if the hook failed (exit code != 0), because tools like devenv
443                // might output valid environment exports before crashing or exiting with error.
444                // We rely on our robust delimiter-based parsing to extract what we can.
445                if hook.source.unwrap_or(false) {
446                    if !hook_result.stdout.is_empty() {
447                        debug!(
448                            "Evaluating source hook output for environment variables (success={})",
449                            hook_result.success
450                        );
451                        match evaluate_shell_environment(&hook_result.stdout).await {
452                            Ok(env_vars) => {
453                                let count = env_vars.len();
454                                debug!("Captured {} environment variables from source hook", count);
455                                if count > 0 {
456                                    // Merge captured environment variables into state
457                                    for (key, value) in env_vars {
458                                        state.environment_vars.insert(key, value);
459                                    }
460                                }
461                            }
462                            Err(e) => {
463                                warn!("Failed to evaluate source hook output: {}", e);
464                                // Don't fail the hook execution further, just log the error
465                            }
466                        }
467                    } else {
468                        warn!(
469                            "Source hook produced empty stdout. Stderr content:\n{}",
470                            hook_result.stderr
471                        );
472                    }
473                }
474
475                state.record_hook_result(index, hook_result.clone());
476                if !hook_result.success && config.fail_fast {
477                    warn!(
478                        "Hook {} failed and fail_fast is enabled, stopping",
479                        index + 1
480                    );
481                    break;
482                }
483            }
484            Err(e) => {
485                let error_msg = format!("Hook execution error: {}", e);
486                state.record_hook_result(
487                    index,
488                    HookResult::failure(
489                        hook.clone(),
490                        None,
491                        String::new(),
492                        error_msg.clone(),
493                        0,
494                        error_msg,
495                    ),
496                );
497                if config.fail_fast {
498                    warn!("Hook {} failed with error, stopping", index + 1);
499                    break;
500                }
501            }
502        }
503
504        // Save state after each hook completes
505        state_manager.save_state(state).await?;
506    }
507
508    // Mark execution as completed if we got here without errors
509    if state.status == ExecutionStatus::Running {
510        state.status = ExecutionStatus::Completed;
511        state.finished_at = Some(chrono::Utc::now());
512        info!(
513            "All hooks completed successfully for directory: {}",
514            state.directory_path.display()
515        );
516    }
517
518    // Save final state
519    state_manager.save_state(state).await?;
520
521    Ok(())
522}
523
524/// Detect which shell to use for environment evaluation
525async fn detect_shell() -> String {
526    // Try bash first
527    if is_shell_capable("bash").await {
528        return "bash".to_string();
529    }
530
531    // Try zsh (common on macOS where bash is old)
532    if is_shell_capable("zsh").await {
533        return "zsh".to_string();
534    }
535
536    // Fall back to sh (likely to fail for advanced scripts but better than nothing)
537    "sh".to_string()
538}
539
540/// Check if a shell supports modern features like case fallthrough (;&)
541async fn is_shell_capable(shell: &str) -> bool {
542    let check_script = "case x in x) true ;& y) true ;; esac";
543    match Command::new(shell)
544        .arg("-c")
545        .arg(check_script)
546        .output()
547        .await
548    {
549        Ok(output) => output.status.success(),
550        Err(_) => false,
551    }
552}
553
554/// Evaluate shell script and extract resulting environment variables
555async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
556    debug!(
557        "Evaluating shell script to extract environment ({} bytes)",
558        shell_script.len()
559    );
560
561    tracing::trace!("Raw shell script from hook:\n{}", shell_script);
562
563    // Try to find the specific bash binary that produced this script (common in Nix/devenv)
564    // This avoids compatibility issues with system bash (e.g. macOS bash 3.2 vs Nix bash 5.x)
565    let mut shell = detect_shell().await;
566
567    for line in shell_script.lines() {
568        if let Some(path) = line.strip_prefix("BASH='")
569            && let Some(end) = path.find('\'')
570        {
571            let bash_path = &path[..end];
572            let path = PathBuf::from(bash_path);
573            if path.exists() {
574                debug!("Detected Nix bash in script: {}", bash_path);
575                shell = bash_path.to_string();
576                break;
577            }
578        }
579    }
580
581    debug!("Using shell: {}", shell);
582
583    // First, get the environment before running the script
584    let mut cmd_before = Command::new(&shell);
585    cmd_before.arg("-c");
586    cmd_before.arg("env -0");
587    cmd_before.stdout(Stdio::piped());
588    cmd_before.stderr(Stdio::piped());
589
590    let output_before = cmd_before
591        .output()
592        .await
593        .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
594
595    let env_before_output = String::from_utf8_lossy(&output_before.stdout);
596    let mut env_before = HashMap::new();
597    for line in env_before_output.split('\0') {
598        if let Some((key, value)) = line.split_once('=') {
599            env_before.insert(key.to_string(), value.to_string());
600        }
601    }
602
603    // Filter out lines that are likely status messages or not shell assignments
604    let filtered_lines: Vec<&str> = shell_script
605        .lines()
606        .filter(|line| {
607            let trimmed = line.trim();
608            if trimmed.is_empty() {
609                return false;
610            }
611
612            // Filter out known status/error prefixes that might pollute stdout
613            if trimmed.starts_with("✓")
614                || trimmed.starts_with("sh:")
615                || trimmed.starts_with("bash:")
616            {
617                return false;
618            }
619
620            // Otherwise keep it. We trust the tool to output valid shell code
621            // (including multiline strings, comments, unsets, aliases, etc.)
622            true
623        })
624        .collect();
625
626    let filtered_script = filtered_lines.join("\n");
627    tracing::trace!("Filtered shell script:\n{}", filtered_script);
628
629    // Now execute the filtered script and capture the environment after
630    let mut cmd = Command::new(shell);
631    cmd.arg("-c");
632    // Create a script that sources the exports and then prints the environment
633    // We wrap the sourced script in a subshell or block that ignores errors?
634    // No, we want environment variables to persist.
635    // But if a command fails, we don't want the whole evaluation to fail.
636    // We append " || true" to each line? No, multiline strings.
637
638    // Better: Execute the script, but ensure we always reach "env -0".
639    // In sh, "cmd; env" runs env even if cmd fails, UNLESS set -e is active.
640    // By default set -e is OFF.
641
642    // However, we check output.status.success().
643    // If the last command is "env -0", and it succeeds, the exit code is 0.
644    // Even if previous commands failed (and printed to stderr).
645
646    // So "nonexistent_command; env -0" -> exit code 0.
647    // Why did my thought experiment suggest failure?
648    // Maybe because I was confusing it with pipefail or set -e.
649
650    // The reproduction test PASSED even with "nonexistent_command_garbage".
651    // This means `evaluate_shell_environment` IS robust against simple command failures!
652
653    // So why is the user's case failing?
654
655    // Maybe `devenv` output contains something that makes `env -0` NOT run or NOT output what we expect?
656    // Or maybe it exits the shell? `exit 1`?
657
658    // If `devenv` prints `exit 1`, then `env -0` is never reached.
659    // `devenv` is a script. If it calls `exit`, it exits the sourcing shell?
660    // If we `source` a script that `exit`s, it exits the parent shell (our sh -c).
661
662    // Does `devenv print-dev-env` exit?
663    // It shouldn't.
664
665    // But if `devenv` fails internally, does it exit?
666    // "devenv: command not found" -> 127, continues.
667
668    // What if the output is syntactically invalid shell?
669    // `export FOO="unclosed`
670    // Then `sh` parses it, finds error, prints error, and... stops?
671    // Usually yes, syntax error aborts execution of the script.
672
673    // Let's try to reproduce SYNTAX ERROR.
674    const DELIMITER: &str = "__CUENV_ENV_START__";
675    let script = format!(
676        "{}\necho -ne '\\0{}\\0'; env -0",
677        filtered_script, DELIMITER
678    );
679    cmd.arg(script);
680    cmd.stdout(Stdio::piped());
681    cmd.stderr(Stdio::piped());
682
683    let output = cmd.output().await.map_err(|e| {
684        Error::configuration(format!("Failed to evaluate shell environment: {}", e))
685    })?;
686
687    // If the command failed, we still try to parse the output, in case env -0 ran.
688    // But we should log the error.
689    if !output.status.success() {
690        let stderr = String::from_utf8_lossy(&output.stderr);
691        warn!(
692            "Shell script evaluation finished with error (exit code {:?}): {}",
693            output.status.code(),
694            stderr
695        );
696        // We continue to try to parse stdout.
697    }
698
699    // Parse the output. We expect: <script_output>\0<DELIMITER>\0<env_vars>\0...
700    let stdout_bytes = &output.stdout;
701    let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
702
703    // Find the delimiter in the output
704    let env_start_index = stdout_bytes
705        .windows(delimiter_bytes.len())
706        .position(|window| window == delimiter_bytes);
707
708    let env_output_bytes = if let Some(idx) = env_start_index {
709        // We found the delimiter, everything after it is the environment
710        &stdout_bytes[idx + delimiter_bytes.len()..]
711    } else {
712        debug!("Environment delimiter not found in hook output");
713        // Log the tail of stdout to diagnose why delimiter is missing
714        let len = stdout_bytes.len();
715        let start = len.saturating_sub(1000);
716        let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
717        warn!(
718            "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
719            tail
720        );
721
722        // Fallback: try to use the whole output if delimiter missing,
723        // but this is risky if stdout has garbage.
724        // However, if env -0 ran, it's usually at the end.
725        // But without delimiter, we can't separate garbage from vars safely.
726        // We'll try to parse anyway, effectively reverting to previous behavior,
727        // but with the risk of corruption if garbage exists.
728        // Given we added delimiter specifically to avoid this, maybe we should return empty?
729        // But if script crashed before printing delimiter, we capture nothing.
730        // If we return empty, at least we don't return corrupted vars.
731        &[]
732    };
733
734    let env_output = String::from_utf8_lossy(env_output_bytes);
735    let mut env_delta = HashMap::new();
736
737    for line in env_output.split('\0') {
738        if line.is_empty() {
739            continue;
740        }
741
742        if let Some((key, value)) = line.split_once('=') {
743            // Skip some problematic variables that can interfere
744            if key.starts_with("BASH_FUNC_")
745                || key == "PS1"
746                || key == "PS2"
747                || key == "_"
748                || key == "PWD"
749                || key == "OLDPWD"
750                || key == "SHLVL"
751                || key.starts_with("BASH")
752            {
753                continue;
754            }
755
756            // Only include variables that are new or changed
757            // We also skip empty keys which can happen with malformed output
758            if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
759                env_delta.insert(key.to_string(), value.to_string());
760            }
761        }
762    }
763
764    if env_delta.is_empty() && !output.status.success() {
765        // If we failed AND got no variables, that's a real problem.
766        let stderr = String::from_utf8_lossy(&output.stderr);
767        return Err(Error::configuration(format!(
768            "Shell script evaluation failed and no environment captured. Error: {}",
769            stderr
770        )));
771    }
772
773    debug!(
774        "Evaluated shell script and extracted {} new/changed environment variables",
775        env_delta.len()
776    );
777    Ok(env_delta)
778}
779
780/// Execute a single hook with timeout
781async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
782    let start_time = Instant::now();
783
784    debug!(
785        "Executing hook: {} {} (source: {})",
786        hook.command,
787        hook.args.join(" "),
788        hook.source.unwrap_or(false)
789    );
790
791    // Prepare the command
792    let mut cmd = Command::new(&hook.command);
793    cmd.args(&hook.args);
794    cmd.stdout(Stdio::piped());
795    cmd.stderr(Stdio::piped());
796
797    // Set working directory
798    if let Some(dir) = &hook.dir {
799        cmd.current_dir(dir);
800    }
801
802    // Force SHELL to match the evaluator shell for source hooks
803    // This ensures tools like devenv output compatible syntax (e.g. avoid fish syntax)
804    if hook.source.unwrap_or(false) {
805        cmd.env("SHELL", detect_shell().await);
806    }
807
808    // Execute with timeout
809    let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
810
811    let duration_ms = start_time.elapsed().as_millis() as u64;
812
813    match execution_result {
814        Ok(Ok(output)) => {
815            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
816            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
817
818            if output.status.success() {
819                debug!("Hook completed successfully in {}ms", duration_ms);
820                Ok(HookResult::success(
821                    hook,
822                    output.status,
823                    stdout,
824                    stderr,
825                    duration_ms,
826                ))
827            } else {
828                warn!("Hook failed with exit code: {:?}", output.status.code());
829                Ok(HookResult::failure(
830                    hook,
831                    Some(output.status),
832                    stdout,
833                    stderr,
834                    duration_ms,
835                    format!("Command exited with status: {}", output.status),
836                ))
837            }
838        }
839        Ok(Err(io_error)) => {
840            error!("Failed to execute hook: {}", io_error);
841            Ok(HookResult::failure(
842                hook,
843                None,
844                String::new(),
845                String::new(),
846                duration_ms,
847                format!("Failed to execute command: {}", io_error),
848            ))
849        }
850        Err(_timeout_error) => {
851            warn!("Hook timed out after {} seconds", timeout_seconds);
852            Ok(HookResult::timeout(
853                hook,
854                String::new(),
855                String::new(),
856                *timeout_seconds,
857            ))
858        }
859    }
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use crate::hooks::types::Hook;
866    use tempfile::TempDir;
867
868    #[tokio::test]
869    async fn test_hook_executor_creation() {
870        let temp_dir = TempDir::new().unwrap();
871        let config = HookExecutionConfig {
872            default_timeout_seconds: 60,
873            fail_fast: true,
874            state_dir: Some(temp_dir.path().to_path_buf()),
875        };
876
877        let executor = HookExecutor::new(config).unwrap();
878        assert_eq!(executor.config.default_timeout_seconds, 60);
879    }
880
881    #[tokio::test]
882    async fn test_execute_single_hook_success() {
883        let executor = HookExecutor::with_default_config().unwrap();
884
885        let hook = Hook {
886            order: 100,
887            propagate: false,
888            command: "echo".to_string(),
889            args: vec!["hello".to_string()],
890            dir: None,
891            inputs: vec![],
892            source: None,
893        };
894
895        let result = executor.execute_single_hook(hook).await.unwrap();
896        assert!(result.success);
897        assert!(result.stdout.contains("hello"));
898    }
899
900    #[tokio::test]
901    async fn test_execute_single_hook_failure() {
902        let executor = HookExecutor::with_default_config().unwrap();
903
904        let hook = Hook {
905            order: 100,
906            propagate: false,
907            command: "false".to_string(), // Command that always fails
908            args: vec![],
909            dir: None,
910            inputs: Vec::new(),
911            source: Some(false),
912        };
913
914        let result = executor.execute_single_hook(hook).await.unwrap();
915        assert!(!result.success);
916        assert!(result.exit_status.is_some());
917        assert_ne!(result.exit_status.unwrap(), 0);
918    }
919
920    #[tokio::test]
921    async fn test_execute_single_hook_timeout() {
922        let temp_dir = TempDir::new().unwrap();
923        let config = HookExecutionConfig {
924            default_timeout_seconds: 1, // Set timeout to 1 second
925            fail_fast: true,
926            state_dir: Some(temp_dir.path().to_path_buf()),
927        };
928        let executor = HookExecutor::new(config).unwrap();
929
930        let hook = Hook {
931            order: 100,
932            propagate: false,
933            command: "sleep".to_string(),
934            args: vec!["10".to_string()], // Sleep for 10 seconds
935            dir: None,
936            inputs: Vec::new(),
937            source: Some(false),
938        };
939
940        let result = executor.execute_single_hook(hook).await.unwrap();
941        assert!(!result.success);
942        assert!(result.error.as_ref().unwrap().contains("timed out"));
943    }
944
945    #[tokio::test]
946    async fn test_background_execution() {
947        let temp_dir = TempDir::new().unwrap();
948        let config = HookExecutionConfig {
949            default_timeout_seconds: 30,
950            fail_fast: true,
951            state_dir: Some(temp_dir.path().to_path_buf()),
952        };
953
954        let executor = HookExecutor::new(config).unwrap();
955        let directory_path = PathBuf::from("/test/directory");
956        let config_hash = "test_hash".to_string();
957
958        let hooks = vec![
959            Hook {
960                order: 100,
961                propagate: false,
962                command: "echo".to_string(),
963                args: vec!["hook1".to_string()],
964                dir: None,
965                inputs: Vec::new(),
966                source: Some(false),
967            },
968            Hook {
969                order: 100,
970                propagate: false,
971                command: "echo".to_string(),
972                args: vec!["hook2".to_string()],
973                dir: None,
974                inputs: Vec::new(),
975                source: Some(false),
976            },
977        ];
978
979        let result = executor
980            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
981            .await
982            .unwrap();
983
984        assert!(result.contains("Started execution of 2 hooks"));
985
986        // Wait a bit for background execution to start
987        tokio::time::sleep(Duration::from_millis(100)).await;
988
989        // Check execution status
990        let status = executor
991            .get_execution_status_for_instance(&directory_path, &config_hash)
992            .await
993            .unwrap();
994        assert!(status.is_some());
995
996        let state = status.unwrap();
997        assert_eq!(state.total_hooks, 2);
998        assert_eq!(state.directory_path, directory_path);
999    }
1000
1001    #[tokio::test]
1002    async fn test_command_validation() {
1003        let executor = HookExecutor::with_default_config().unwrap();
1004
1005        // Commands are no longer validated against a whitelist
1006        // The approval mechanism is the security boundary
1007
1008        // Test that echo command works with any arguments
1009        let hook = Hook {
1010            order: 100,
1011            propagate: false,
1012            command: "echo".to_string(),
1013            args: vec!["test message".to_string()],
1014            dir: None,
1015            inputs: Vec::new(),
1016            source: Some(false),
1017        };
1018
1019        let result = executor.execute_single_hook(hook).await;
1020        assert!(result.is_ok(), "Echo command should succeed");
1021
1022        // Verify the output contains the expected message
1023        let hook_result = result.unwrap();
1024        assert!(hook_result.stdout.contains("test message"));
1025    }
1026
1027    #[tokio::test]
1028    #[ignore = "Needs investigation - async state management"]
1029    async fn test_cancellation() {
1030        let temp_dir = TempDir::new().unwrap();
1031        let config = HookExecutionConfig {
1032            default_timeout_seconds: 30,
1033            fail_fast: false,
1034            state_dir: Some(temp_dir.path().to_path_buf()),
1035        };
1036
1037        let executor = HookExecutor::new(config).unwrap();
1038        let directory_path = PathBuf::from("/test/cancel");
1039        let config_hash = "cancel_test".to_string();
1040
1041        // Create a long-running hook
1042        let hooks = vec![Hook {
1043            order: 100,
1044            propagate: false,
1045            command: "sleep".to_string(),
1046            args: vec!["10".to_string()],
1047            dir: None,
1048            inputs: Vec::new(),
1049            source: Some(false),
1050        }];
1051
1052        executor
1053            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1054            .await
1055            .unwrap();
1056
1057        // Wait a bit for execution to start
1058        tokio::time::sleep(Duration::from_millis(100)).await;
1059
1060        // Cancel the execution
1061        let cancelled = executor
1062            .cancel_execution(
1063                &directory_path,
1064                &config_hash,
1065                Some("User cancelled".to_string()),
1066            )
1067            .await
1068            .unwrap();
1069        assert!(cancelled);
1070
1071        // Check that state reflects cancellation
1072        let state = executor
1073            .get_execution_status_for_instance(&directory_path, &config_hash)
1074            .await
1075            .unwrap()
1076            .unwrap();
1077        assert_eq!(state.status, ExecutionStatus::Cancelled);
1078    }
1079
1080    #[tokio::test]
1081    async fn test_large_output_handling() {
1082        let executor = HookExecutor::with_default_config().unwrap();
1083
1084        // Generate a large output using printf repeating a pattern
1085        // Create a large string in the environment variable instead
1086        let large_content = "x".repeat(1000); // 1KB per line
1087        let mut args = Vec::new();
1088        // Generate 100 lines of 1KB each = 100KB total
1089        for i in 0..100 {
1090            args.push(format!("Line {}: {}", i, large_content));
1091        }
1092
1093        // Use echo with multiple arguments
1094        let hook = Hook {
1095            order: 100,
1096            propagate: false,
1097            command: "echo".to_string(),
1098            args,
1099            dir: None,
1100            inputs: Vec::new(),
1101            source: Some(false),
1102        };
1103
1104        let result = executor.execute_single_hook(hook).await.unwrap();
1105        assert!(result.success);
1106        // Output should be captured without causing memory issues
1107        assert!(result.stdout.len() > 50_000); // At least 50KB of output
1108    }
1109
1110    #[tokio::test]
1111    #[ignore = "Needs investigation - async runtime issues"]
1112    async fn test_state_cleanup() {
1113        let temp_dir = TempDir::new().unwrap();
1114        let config = HookExecutionConfig {
1115            default_timeout_seconds: 30,
1116            fail_fast: false,
1117            state_dir: Some(temp_dir.path().to_path_buf()),
1118        };
1119
1120        let executor = HookExecutor::new(config).unwrap();
1121        let directory_path = PathBuf::from("/test/cleanup");
1122        let config_hash = "cleanup_test".to_string();
1123
1124        // Execute some hooks
1125        let hooks = vec![Hook {
1126            order: 100,
1127            propagate: false,
1128            command: "echo".to_string(),
1129            args: vec!["test".to_string()],
1130            dir: None,
1131            inputs: Vec::new(),
1132            source: Some(false),
1133        }];
1134
1135        executor
1136            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1137            .await
1138            .unwrap();
1139
1140        // Wait for completion
1141        executor
1142            .wait_for_completion(&directory_path, &config_hash, Some(5))
1143            .await
1144            .unwrap();
1145
1146        // Clean up old states (should clean up the completed state)
1147        let cleaned = executor
1148            .cleanup_old_states(chrono::Duration::seconds(0))
1149            .await
1150            .unwrap();
1151        assert_eq!(cleaned, 1);
1152
1153        // State should be gone
1154        let state = executor
1155            .get_execution_status_for_instance(&directory_path, &config_hash)
1156            .await
1157            .unwrap();
1158        assert!(state.is_none());
1159    }
1160
1161    #[tokio::test]
1162    async fn test_execution_state_tracking() {
1163        let temp_dir = TempDir::new().unwrap();
1164        let config = HookExecutionConfig {
1165            default_timeout_seconds: 30,
1166            fail_fast: true,
1167            state_dir: Some(temp_dir.path().to_path_buf()),
1168        };
1169
1170        let executor = HookExecutor::new(config).unwrap();
1171        let directory_path = PathBuf::from("/test/directory");
1172        let config_hash = "hash".to_string();
1173
1174        // Initially no state
1175        let status = executor
1176            .get_execution_status_for_instance(&directory_path, &config_hash)
1177            .await
1178            .unwrap();
1179        assert!(status.is_none());
1180
1181        // Start execution
1182        let hooks = vec![Hook {
1183            order: 100,
1184            propagate: false,
1185            command: "echo".to_string(),
1186            args: vec!["test".to_string()],
1187            dir: None,
1188            inputs: Vec::new(),
1189            source: Some(false),
1190        }];
1191
1192        executor
1193            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1194            .await
1195            .unwrap();
1196
1197        // Should now have state
1198        let status = executor
1199            .get_execution_status_for_instance(&directory_path, &config_hash)
1200            .await
1201            .unwrap();
1202        assert!(status.is_some());
1203    }
1204
1205    // Commented out: allow_command and disallow_command methods don't exist
1206    // #[tokio::test]
1207    // async fn test_command_whitelist_management() {
1208    //         let executor = HookExecutor::with_default_config().unwrap();
1209    //
1210    //         // Test adding a new command to whitelist
1211    //         let custom_command = "my-custom-tool".to_string();
1212    //         executor.allow_command(custom_command.clone()).await;
1213    //
1214    //         // Test that the newly allowed command works
1215    //         let hook = Hook { order: 100, propagate: false,
1216    //             command: custom_command.clone(),
1217    //             args: vec!["--version".to_string()],
1218    //             dir: None,
1219    //             inputs: Vec::new(),
1220    //             source: Some(false),
1221    //         };
1222    //
1223    //         // Should not error due to whitelist check (may fail if command doesn't exist)
1224    //         let result = executor.execute_single_hook(hook).await;
1225    //         // If it errors, it should be because the command doesn't exist, not because it's not allowed
1226    //         if result.is_err() {
1227    //             let err_msg = result.unwrap_err().to_string();
1228    //             assert!(
1229    //                 !err_msg.contains("not allowed"),
1230    //                 "Command should be allowed after adding to whitelist"
1231    //             );
1232    //         }
1233    //
1234    //         // Test removing a command from whitelist
1235    //         executor.disallow_command("echo").await;
1236    //
1237    //         let hook = Hook { order: 100, propagate: false,
1238    //             command: "echo".to_string(),
1239    //             args: vec!["test".to_string()],
1240    //             dir: None,
1241    //             inputs: vec![],
1242    //             source: None,
1243    //         };
1244    //
1245    //         let result = executor.execute_single_hook(hook).await;
1246    //         assert!(result.is_err(), "Echo command should be disallowed");
1247    //         let err_msg = result.unwrap_err().to_string();
1248    //         assert!(
1249    //             err_msg.contains("not allowed")
1250    //                 || err_msg.contains("not in whitelist")
1251    //                 || err_msg.contains("Configuration"),
1252    //             "Error message should indicate command not allowed: {}",
1253    //             err_msg
1254    //         );
1255    //
1256    //         // Test re-allowing a command
1257    //         executor.allow_command("echo".to_string()).await;
1258    //
1259    //         let hook = Hook { order: 100, propagate: false,
1260    //             command: "echo".to_string(),
1261    //             args: vec!["test".to_string()],
1262    //             dir: None,
1263    //             inputs: vec![],
1264    //             source: None,
1265    //         };
1266    //
1267    //         let result = executor.execute_single_hook(hook).await;
1268    //         assert!(
1269    //             result.is_ok(),
1270    //             "Echo command should be allowed after re-adding"
1271    //         );
1272    //     }
1273
1274    #[tokio::test]
1275    #[ignore = "Needs investigation - timing issues"]
1276    async fn test_fail_fast_mode_edge_cases() {
1277        let temp_dir = TempDir::new().unwrap();
1278
1279        // Test fail_fast with multiple failing hooks
1280        let config = HookExecutionConfig {
1281            default_timeout_seconds: 30,
1282            fail_fast: true,
1283            state_dir: Some(temp_dir.path().to_path_buf()),
1284        };
1285
1286        let executor = HookExecutor::new(config).unwrap();
1287        let directory_path = PathBuf::from("/test/fail-fast");
1288
1289        let hooks = vec![
1290            Hook {
1291                order: 100,
1292                propagate: false,
1293                command: "false".to_string(), // Will fail
1294                args: vec![],
1295                dir: None,
1296                inputs: Vec::new(),
1297                source: Some(false),
1298            },
1299            Hook {
1300                order: 100,
1301                propagate: false,
1302                command: "echo".to_string(), // Should not execute due to fail_fast
1303                args: vec!["should not run".to_string()],
1304                dir: None,
1305                inputs: Vec::new(),
1306                source: Some(false),
1307            },
1308            Hook {
1309                order: 100,
1310                propagate: false,
1311                command: "echo".to_string(), // Should not execute due to fail_fast
1312                args: vec!["also should not run".to_string()],
1313                dir: None,
1314                inputs: Vec::new(),
1315                source: Some(false),
1316            },
1317        ];
1318
1319        let config_hash = "fail_fast_test".to_string();
1320        executor
1321            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1322            .await
1323            .unwrap();
1324
1325        // Wait for completion
1326        executor
1327            .wait_for_completion(&directory_path, &config_hash, Some(10))
1328            .await
1329            .unwrap();
1330
1331        let state = executor
1332            .get_execution_status_for_instance(&directory_path, &config_hash)
1333            .await
1334            .unwrap()
1335            .unwrap();
1336
1337        assert_eq!(state.status, ExecutionStatus::Failed);
1338        // Only the first hook should have been executed
1339        // Only the first hook should have been executed
1340        assert_eq!(state.completed_hooks, 1);
1341
1342        // Test fail_fast with continue_on_error interaction
1343        let directory_path2 = PathBuf::from("/test/fail-fast-continue");
1344
1345        let hooks2 = vec![
1346            Hook {
1347                order: 100,
1348                propagate: false,
1349                command: "false".to_string(),
1350                args: vec![],
1351                dir: None,
1352                inputs: Vec::new(),
1353                source: Some(false),
1354            },
1355            Hook {
1356                order: 100,
1357                propagate: false,
1358                command: "echo".to_string(),
1359                args: vec!["this should run".to_string()],
1360                dir: None,
1361                inputs: Vec::new(),
1362                source: Some(false),
1363            },
1364            Hook {
1365                order: 100,
1366                propagate: false,
1367                command: "false".to_string(), // Will fail and stop execution
1368                args: vec![],
1369                dir: None,
1370                inputs: Vec::new(),
1371                source: Some(false),
1372            },
1373            Hook {
1374                order: 100,
1375                propagate: false,
1376                command: "echo".to_string(),
1377                args: vec!["this should not run".to_string()],
1378                dir: None,
1379                inputs: Vec::new(),
1380                source: Some(false),
1381            },
1382        ];
1383
1384        let config_hash2 = "fail_fast_continue_test".to_string();
1385        executor
1386            .execute_hooks_background(directory_path2.clone(), config_hash2.clone(), hooks2)
1387            .await
1388            .unwrap();
1389
1390        executor
1391            .wait_for_completion(&directory_path2, &config_hash2, Some(10))
1392            .await
1393            .unwrap();
1394
1395        let state2 = executor
1396            .get_execution_status_for_instance(&directory_path2, &config_hash2)
1397            .await
1398            .unwrap()
1399            .unwrap();
1400
1401        assert_eq!(state2.status, ExecutionStatus::Failed);
1402        // First three hooks should have been executed
1403        // First three hooks should have been executed
1404        assert_eq!(state2.completed_hooks, 3);
1405    }
1406
1407    #[tokio::test]
1408    async fn test_security_validation_comprehensive() {
1409        let executor = HookExecutor::with_default_config().unwrap();
1410
1411        // Security validation has been removed - the approval mechanism is the security boundary
1412        // Commands with any arguments are now allowed after approval
1413
1414        // Test that echo command works with various arguments
1415        let test_args = vec![
1416            vec!["simple test".to_string()],
1417            vec!["test with spaces".to_string()],
1418            ["test", "multiple", "args"]
1419                .iter()
1420                .map(|s| s.to_string())
1421                .collect(),
1422        ];
1423
1424        for args in test_args {
1425            let hook = Hook {
1426                order: 100,
1427                propagate: false,
1428                command: "echo".to_string(),
1429                args: args.clone(),
1430                dir: None,
1431                inputs: Vec::new(),
1432                source: Some(false),
1433            };
1434
1435            let result = executor.execute_single_hook(hook).await;
1436            assert!(
1437                result.is_ok(),
1438                "Echo command should work with args: {:?}",
1439                args
1440            );
1441        }
1442    }
1443
1444    #[tokio::test]
1445    async fn test_working_directory_handling() {
1446        let executor = HookExecutor::with_default_config().unwrap();
1447        let temp_dir = TempDir::new().unwrap();
1448
1449        // Test with valid working directory
1450        let hook_with_valid_dir = Hook {
1451            order: 100,
1452            propagate: false,
1453            command: "pwd".to_string(),
1454            args: vec![],
1455            dir: Some(temp_dir.path().to_string_lossy().to_string()),
1456            inputs: vec![],
1457            source: None,
1458        };
1459
1460        let result = executor
1461            .execute_single_hook(hook_with_valid_dir)
1462            .await
1463            .unwrap();
1464        assert!(result.success);
1465        assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1466
1467        // Test with non-existent working directory
1468        let hook_with_invalid_dir = Hook {
1469            order: 100,
1470            propagate: false,
1471            command: "pwd".to_string(),
1472            args: vec![],
1473            dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1474            inputs: vec![],
1475            source: None,
1476        };
1477
1478        let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1479        // This might succeed or fail depending on the implementation
1480        // The important part is it doesn't panic
1481        if result.is_ok() {
1482            // If it succeeds, the command might have handled the missing directory
1483            assert!(
1484                !result
1485                    .unwrap()
1486                    .stdout
1487                    .contains("/nonexistent/directory/that/does/not/exist")
1488            );
1489        }
1490
1491        // Test with relative path working directory (should be validated)
1492        let hook_with_relative_dir = Hook {
1493            order: 100,
1494            propagate: false,
1495            command: "pwd".to_string(),
1496            args: vec![],
1497            dir: Some("./relative/path".to_string()),
1498            inputs: vec![],
1499            source: None,
1500        };
1501
1502        // This might work or fail depending on the implementation
1503        let _ = executor.execute_single_hook(hook_with_relative_dir).await;
1504    }
1505
1506    #[tokio::test]
1507    async fn test_hook_execution_with_complex_output() {
1508        let executor = HookExecutor::with_default_config().unwrap();
1509
1510        // Test simple hooks without dangerous characters
1511        let hook = Hook {
1512            order: 100,
1513            propagate: false,
1514            command: "echo".to_string(),
1515            args: vec!["stdout output".to_string()],
1516            dir: None,
1517            inputs: vec![],
1518            source: None,
1519        };
1520
1521        let result = executor.execute_single_hook(hook).await.unwrap();
1522        assert!(result.success);
1523        assert!(result.stdout.contains("stdout output"));
1524
1525        // Test hook with non-zero exit code (using false command)
1526        let hook_with_exit_code = Hook {
1527            order: 100,
1528            propagate: false,
1529            command: "false".to_string(),
1530            args: vec![],
1531            dir: None,
1532            inputs: Vec::new(),
1533            source: Some(false),
1534        };
1535
1536        let result = executor
1537            .execute_single_hook(hook_with_exit_code)
1538            .await
1539            .unwrap();
1540        assert!(!result.success);
1541        // Exit code should be non-zero
1542        assert!(result.exit_status.is_some());
1543    }
1544
1545    #[tokio::test]
1546    #[ignore = "Needs investigation - state management"]
1547    async fn test_multiple_directory_executions() {
1548        let temp_dir = TempDir::new().unwrap();
1549        let config = HookExecutionConfig {
1550            default_timeout_seconds: 30,
1551            fail_fast: false,
1552            state_dir: Some(temp_dir.path().to_path_buf()),
1553        };
1554
1555        let executor = HookExecutor::new(config).unwrap();
1556
1557        // Start executions for multiple directories
1558        let directories = [
1559            PathBuf::from("/test/dir1"),
1560            PathBuf::from("/test/dir2"),
1561            PathBuf::from("/test/dir3"),
1562        ];
1563
1564        let mut config_hashes = Vec::new();
1565        for (i, dir) in directories.iter().enumerate() {
1566            let hooks = vec![Hook {
1567                order: 100,
1568                propagate: false,
1569                command: "echo".to_string(),
1570                args: vec![format!("directory {}", i)],
1571                dir: None,
1572                inputs: Vec::new(),
1573                source: Some(false),
1574            }];
1575
1576            let config_hash = format!("hash_{}", i);
1577            config_hashes.push(config_hash.clone());
1578            executor
1579                .execute_hooks_background(dir.clone(), config_hash.clone(), hooks)
1580                .await
1581                .unwrap();
1582        }
1583
1584        // Wait for all to complete
1585        for (dir, config_hash) in directories.iter().zip(config_hashes.iter()) {
1586            executor
1587                .wait_for_completion(dir, config_hash, Some(10))
1588                .await
1589                .unwrap();
1590
1591            let state = executor
1592                .get_execution_status_for_instance(dir, config_hash)
1593                .await
1594                .unwrap()
1595                .unwrap();
1596
1597            assert_eq!(state.status, ExecutionStatus::Completed);
1598            assert_eq!(state.completed_hooks, 1);
1599            assert_eq!(state.total_hooks, 1);
1600        }
1601    }
1602
1603    #[tokio::test]
1604    #[ignore = "Needs investigation - retry logic"]
1605    async fn test_error_recovery_and_retry() {
1606        let temp_dir = TempDir::new().unwrap();
1607        let config = HookExecutionConfig {
1608            default_timeout_seconds: 30,
1609            fail_fast: false,
1610            state_dir: Some(temp_dir.path().to_path_buf()),
1611        };
1612
1613        let executor = HookExecutor::new(config).unwrap();
1614        let directory_path = PathBuf::from("/test/recovery");
1615
1616        // Execute hooks with some failures
1617        let hooks = vec![
1618            Hook {
1619                order: 100,
1620                propagate: false,
1621                command: "echo".to_string(),
1622                args: vec!["success 1".to_string()],
1623                dir: None,
1624                inputs: Vec::new(),
1625                source: Some(false),
1626            },
1627            Hook {
1628                order: 100,
1629                propagate: false,
1630                command: "false".to_string(),
1631                args: vec![],
1632                dir: None,
1633                inputs: Vec::new(),
1634                source: Some(false),
1635            },
1636            Hook {
1637                order: 100,
1638                propagate: false,
1639                command: "echo".to_string(),
1640                args: vec!["success 2".to_string()],
1641                dir: None,
1642                inputs: Vec::new(),
1643                source: Some(false),
1644            },
1645        ];
1646
1647        let config_hash = "recovery_test".to_string();
1648        executor
1649            .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1650            .await
1651            .unwrap();
1652
1653        executor
1654            .wait_for_completion(&directory_path, &config_hash, Some(10))
1655            .await
1656            .unwrap();
1657
1658        let state = executor
1659            .get_execution_status_for_instance(&directory_path, &config_hash)
1660            .await
1661            .unwrap()
1662            .unwrap();
1663
1664        // Should complete with partial failure
1665        assert_eq!(state.status, ExecutionStatus::Completed);
1666        assert_eq!(state.completed_hooks, 3);
1667        assert_eq!(state.total_hooks, 3);
1668    }
1669
1670    #[tokio::test]
1671    #[ignore = "Requires supervisor binary - integration test"]
1672    async fn test_instance_hash_separation() {
1673        // Test that different config hashes for the same directory are tracked separately
1674        let temp_dir = TempDir::new().unwrap();
1675        let config = HookExecutionConfig {
1676            default_timeout_seconds: 30,
1677            fail_fast: false,
1678            state_dir: Some(temp_dir.path().to_path_buf()),
1679        };
1680
1681        let executor = HookExecutor::new(config).unwrap();
1682        let directory_path = PathBuf::from("/test/multi-config");
1683
1684        // Create hooks for first configuration
1685        let hooks1 = vec![Hook {
1686            order: 100,
1687            propagate: false,
1688            command: "echo".to_string(),
1689            args: vec!["config1".to_string()],
1690            dir: None,
1691            inputs: Vec::new(),
1692            source: Some(false),
1693        }];
1694
1695        // Create hooks for second configuration
1696        let hooks2 = vec![Hook {
1697            order: 100,
1698            propagate: false,
1699            command: "echo".to_string(),
1700            args: vec!["config2".to_string()],
1701            dir: None,
1702            inputs: Vec::new(),
1703            source: Some(false),
1704        }];
1705
1706        let config_hash1 = "config_hash_1".to_string();
1707        let config_hash2 = "config_hash_2".to_string();
1708
1709        // Execute both configurations
1710        executor
1711            .execute_hooks_background(directory_path.clone(), config_hash1.clone(), hooks1)
1712            .await
1713            .unwrap();
1714
1715        executor
1716            .execute_hooks_background(directory_path.clone(), config_hash2.clone(), hooks2)
1717            .await
1718            .unwrap();
1719
1720        // Wait for both to complete
1721        executor
1722            .wait_for_completion(&directory_path, &config_hash1, Some(5))
1723            .await
1724            .unwrap();
1725
1726        executor
1727            .wait_for_completion(&directory_path, &config_hash2, Some(5))
1728            .await
1729            .unwrap();
1730
1731        // Check that both have separate states
1732        let state1 = executor
1733            .get_execution_status_for_instance(&directory_path, &config_hash1)
1734            .await
1735            .unwrap()
1736            .unwrap();
1737
1738        let state2 = executor
1739            .get_execution_status_for_instance(&directory_path, &config_hash2)
1740            .await
1741            .unwrap()
1742            .unwrap();
1743
1744        assert_eq!(state1.status, ExecutionStatus::Completed);
1745        assert_eq!(state2.status, ExecutionStatus::Completed);
1746
1747        // Ensure they have different instance hashes
1748        assert_ne!(state1.instance_hash, state2.instance_hash);
1749    }
1750
1751    #[tokio::test]
1752    #[ignore = "Requires supervisor binary - integration test"]
1753    async fn test_file_based_argument_passing() {
1754        // Test that hooks and config are written to files and cleaned up
1755        let temp_dir = TempDir::new().unwrap();
1756        let config = HookExecutionConfig {
1757            default_timeout_seconds: 30,
1758            fail_fast: false,
1759            state_dir: Some(temp_dir.path().to_path_buf()),
1760        };
1761
1762        let executor = HookExecutor::new(config).unwrap();
1763        let directory_path = PathBuf::from("/test/file-args");
1764        let config_hash = "file_test".to_string();
1765
1766        // Create a large hook configuration that would exceed typical arg limits
1767        let mut large_hooks = Vec::new();
1768        for i in 0..100 {
1769            large_hooks.push(Hook { order: 100, propagate: false,
1770                command: "echo".to_string(),
1771                args: vec![format!("This is a very long argument string number {} with lots of text to ensure we test the file-based argument passing mechanism properly", i)],
1772                dir: None,
1773                inputs: Vec::new(),
1774                source: Some(false),
1775            });
1776        }
1777
1778        // This should write to files instead of passing as arguments
1779        let result = executor
1780            .execute_hooks_background(directory_path.clone(), config_hash.clone(), large_hooks)
1781            .await;
1782
1783        assert!(result.is_ok(), "Should handle large hook configurations");
1784
1785        // Wait a bit for supervisor to start and read files
1786        tokio::time::sleep(Duration::from_millis(500)).await;
1787
1788        // Cancel to clean up
1789        executor
1790            .cancel_execution(
1791                &directory_path,
1792                &config_hash,
1793                Some("Test cleanup".to_string()),
1794            )
1795            .await
1796            .ok();
1797    }
1798
1799    #[tokio::test]
1800    async fn test_state_dir_getter() {
1801        use crate::hooks::state::StateManager;
1802
1803        let temp_dir = TempDir::new().unwrap();
1804        let state_dir = temp_dir.path().to_path_buf();
1805        let state_manager = StateManager::new(state_dir.clone());
1806
1807        assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1808    }
1809}