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