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