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