1use 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#[derive(Debug)]
16pub struct HookExecutor {
17 config: HookExecutionConfig,
18 state_manager: StateManager,
19}
20
21impl HookExecutor {
22 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 pub fn with_default_config() -> Result<Self> {
40 let mut config = HookExecutionConfig::default();
41
42 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 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 let previous_env =
66 if let Ok(Some(existing_state)) = self.state_manager.load_state(&instance_hash).await {
67 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 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 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 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 if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
104 && let Ok(pid) = pid_str.trim().parse::<usize>()
105 {
106 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 std::fs::remove_file(&pid_file).ok();
122 }
123
124 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 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 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 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 use std::process::{Command, Stdio};
158
159 let mut cmd = Command::new(¤t_exe);
160 cmd.arg("__hook-supervisor") .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 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 if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
192 cmd.env("CUENV_STATE_DIR", state_dir);
193 }
194
195 if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
197 cmd.env("CUENV_APPROVAL_FILE", approval_file);
198 }
199
200 if let Ok(rust_log) = std::env::var("RUST_LOG") {
202 cmd.env("RUST_LOG", rust_log);
203 }
204
205 #[cfg(unix)]
207 {
208 use std::os::unix::process::CommandExt;
209 unsafe {
211 cmd.pre_exec(|| {
212 if libc::setsid() == -1 {
214 return Err(std::io::Error::last_os_error());
215 }
216 Ok(())
217 });
218 }
219 }
220
221 #[cfg(windows)]
222 {
223 use std::os::windows::process::CommandExt;
224 const DETACHED_PROCESS: u32 = 0x00000008;
226 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
227 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
228 }
229
230 let _child = cmd
231 .spawn()
232 .map_err(|e| Error::configuration(format!("Failed to spawn supervisor: {}", e)))?;
233
234 info!("Spawned supervisor process for hook execution");
237
238 Ok(format!(
239 "Started execution of {} hooks in background",
240 total_hooks
241 ))
242 }
243
244 pub async fn get_execution_status(
246 &self,
247 directory_path: &Path,
248 ) -> Result<Option<HookExecutionState>> {
249 let states = self.state_manager.list_active_states().await?;
251 for state in states {
252 if state.directory_path == directory_path {
253 return Ok(Some(state));
254 }
255 }
256 Ok(None)
257 }
258
259 pub async fn get_execution_status_for_instance(
261 &self,
262 directory_path: &Path,
263 config_hash: &str,
264 ) -> Result<Option<HookExecutionState>> {
265 let instance_hash = compute_instance_hash(directory_path, config_hash);
266 self.state_manager.load_state(&instance_hash).await
267 }
268
269 pub async fn wait_for_completion(
271 &self,
272 directory_path: &Path,
273 config_hash: &str,
274 timeout_seconds: Option<u64>,
275 ) -> Result<HookExecutionState> {
276 let instance_hash = compute_instance_hash(directory_path, config_hash);
277 let poll_interval = Duration::from_millis(500);
278 let start_time = Instant::now();
279
280 loop {
281 if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
282 if state.is_complete() {
283 return Ok(state);
284 }
285 } else {
286 return Err(Error::configuration("No execution state found"));
287 }
288
289 if let Some(timeout) = timeout_seconds
291 && start_time.elapsed().as_secs() >= timeout
292 {
293 return Err(Error::Timeout { seconds: timeout });
294 }
295
296 tokio::time::sleep(poll_interval).await;
297 }
298 }
299
300 pub async fn cancel_execution(
302 &self,
303 directory_path: &Path,
304 config_hash: &str,
305 reason: Option<String>,
306 ) -> Result<bool> {
307 let instance_hash = compute_instance_hash(directory_path, config_hash);
308
309 let pid_file = self
311 .state_manager
312 .get_state_file_path(&instance_hash)
313 .with_extension("pid");
314
315 if pid_file.exists()
316 && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
317 && let Ok(pid) = pid_str.trim().parse::<usize>()
318 {
319 use sysinfo::{Pid, ProcessRefreshKind, Signal, System};
320
321 let mut system = System::new();
322 let process_pid = Pid::from(pid);
323
324 system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
326
327 if let Some(process) = system.process(process_pid) {
329 if process.kill_with(Signal::Term).is_some() {
330 info!("Sent SIGTERM to supervisor process PID {}", pid);
331 } else {
332 warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
333 }
334 } else {
335 info!(
336 "Supervisor process PID {} not found (may have already exited)",
337 pid
338 );
339 }
340
341 std::fs::remove_file(&pid_file).ok();
343 }
344
345 if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
347 && !state.is_complete()
348 {
349 state.mark_cancelled(reason);
350 self.state_manager.save_state(&state).await?;
351 info!(
352 "Cancelled execution for directory: {}",
353 directory_path.display()
354 );
355 return Ok(true);
356 }
357
358 Ok(false)
359 }
360
361 pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
363 let states = self.state_manager.list_active_states().await?;
364 let cutoff = chrono::Utc::now() - older_than;
365 let mut cleaned_count = 0;
366
367 for state in states {
368 if state.is_complete()
369 && let Some(finished_at) = state.finished_at
370 && finished_at < cutoff
371 {
372 self.state_manager
373 .remove_state(&state.instance_hash)
374 .await?;
375 cleaned_count += 1;
376 }
377 }
378
379 if cleaned_count > 0 {
380 info!("Cleaned up {} old execution states", cleaned_count);
381 }
382
383 Ok(cleaned_count)
384 }
385
386 pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
388 let timeout = self.config.default_timeout_seconds;
390
391 execute_hook_with_timeout(hook, &timeout).await
393 }
394}
395
396pub async fn execute_hooks(
398 hooks: Vec<Hook>,
399 _directory_path: &Path,
400 config: &HookExecutionConfig,
401 state_manager: &StateManager,
402 state: &mut HookExecutionState,
403) -> Result<()> {
404 let hook_count = hooks.len();
405 debug!("execute_hooks called with {} hooks", hook_count);
406 if hook_count == 0 {
407 debug!("No hooks to execute");
408 return Ok(());
409 }
410 debug!("Starting to iterate over {} hooks", hook_count);
411 for (index, hook) in hooks.into_iter().enumerate() {
412 debug!(
413 "Processing hook {}/{}: command={}",
414 index + 1,
415 state.total_hooks,
416 hook.command
417 );
418 debug!("Checking if execution was cancelled");
420 if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
421 debug!("Loaded state: status = {:?}", current_state.status);
422 if current_state.status == ExecutionStatus::Cancelled {
423 debug!("Execution was cancelled, stopping");
424 break;
425 }
426 }
427
428 let timeout_seconds = config.default_timeout_seconds;
431
432 state.mark_hook_running(index);
434
435 let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
437
438 match result {
440 Ok(hook_result) => {
441 if hook.source.unwrap_or(false) {
446 if !hook_result.stdout.is_empty() {
447 debug!(
448 "Evaluating source hook output for environment variables (success={})",
449 hook_result.success
450 );
451 match evaluate_shell_environment(&hook_result.stdout).await {
452 Ok(env_vars) => {
453 let count = env_vars.len();
454 debug!("Captured {} environment variables from source hook", count);
455 if count > 0 {
456 for (key, value) in env_vars {
458 state.environment_vars.insert(key, value);
459 }
460 }
461 }
462 Err(e) => {
463 warn!("Failed to evaluate source hook output: {}", e);
464 }
466 }
467 } else {
468 warn!(
469 "Source hook produced empty stdout. Stderr content:\n{}",
470 hook_result.stderr
471 );
472 }
473 }
474
475 state.record_hook_result(index, hook_result.clone());
476 if !hook_result.success && config.fail_fast {
477 warn!(
478 "Hook {} failed and fail_fast is enabled, stopping",
479 index + 1
480 );
481 break;
482 }
483 }
484 Err(e) => {
485 let error_msg = format!("Hook execution error: {}", e);
486 state.record_hook_result(
487 index,
488 HookResult::failure(
489 hook.clone(),
490 None,
491 String::new(),
492 error_msg.clone(),
493 0,
494 error_msg,
495 ),
496 );
497 if config.fail_fast {
498 warn!("Hook {} failed with error, stopping", index + 1);
499 break;
500 }
501 }
502 }
503
504 state_manager.save_state(state).await?;
506 }
507
508 if state.status == ExecutionStatus::Running {
510 state.status = ExecutionStatus::Completed;
511 state.finished_at = Some(chrono::Utc::now());
512 info!(
513 "All hooks completed successfully for directory: {}",
514 state.directory_path.display()
515 );
516 }
517
518 state_manager.save_state(state).await?;
520
521 Ok(())
522}
523
524async fn detect_shell() -> String {
526 if is_shell_capable("bash").await {
528 return "bash".to_string();
529 }
530
531 if is_shell_capable("zsh").await {
533 return "zsh".to_string();
534 }
535
536 "sh".to_string()
538}
539
540async fn is_shell_capable(shell: &str) -> bool {
542 let check_script = "case x in x) true ;& y) true ;; esac";
543 match Command::new(shell)
544 .arg("-c")
545 .arg(check_script)
546 .output()
547 .await
548 {
549 Ok(output) => output.status.success(),
550 Err(_) => false,
551 }
552}
553
554async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
556 debug!(
557 "Evaluating shell script to extract environment ({} bytes)",
558 shell_script.len()
559 );
560
561 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
562
563 let mut shell = detect_shell().await;
566
567 for line in shell_script.lines() {
568 if let Some(path) = line.strip_prefix("BASH='")
569 && let Some(end) = path.find('\'')
570 {
571 let bash_path = &path[..end];
572 let path = PathBuf::from(bash_path);
573 if path.exists() {
574 debug!("Detected Nix bash in script: {}", bash_path);
575 shell = bash_path.to_string();
576 break;
577 }
578 }
579 }
580
581 debug!("Using shell: {}", shell);
582
583 let mut cmd_before = Command::new(&shell);
585 cmd_before.arg("-c");
586 cmd_before.arg("env -0");
587 cmd_before.stdout(Stdio::piped());
588 cmd_before.stderr(Stdio::piped());
589
590 let output_before = cmd_before
591 .output()
592 .await
593 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
594
595 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
596 let mut env_before = HashMap::new();
597 for line in env_before_output.split('\0') {
598 if let Some((key, value)) = line.split_once('=') {
599 env_before.insert(key.to_string(), value.to_string());
600 }
601 }
602
603 let filtered_lines: Vec<&str> = shell_script
605 .lines()
606 .filter(|line| {
607 let trimmed = line.trim();
608 if trimmed.is_empty() {
609 return false;
610 }
611
612 if trimmed.starts_with("✓")
614 || trimmed.starts_with("sh:")
615 || trimmed.starts_with("bash:")
616 {
617 return false;
618 }
619
620 true
623 })
624 .collect();
625
626 let filtered_script = filtered_lines.join("\n");
627 tracing::trace!("Filtered shell script:\n{}", filtered_script);
628
629 let mut cmd = Command::new(shell);
631 cmd.arg("-c");
632 const DELIMITER: &str = "__CUENV_ENV_START__";
675 let script = format!(
676 "{}\necho -ne '\\0{}\\0'; env -0",
677 filtered_script, DELIMITER
678 );
679 cmd.arg(script);
680 cmd.stdout(Stdio::piped());
681 cmd.stderr(Stdio::piped());
682
683 let output = cmd.output().await.map_err(|e| {
684 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
685 })?;
686
687 if !output.status.success() {
690 let stderr = String::from_utf8_lossy(&output.stderr);
691 warn!(
692 "Shell script evaluation finished with error (exit code {:?}): {}",
693 output.status.code(),
694 stderr
695 );
696 }
698
699 let stdout_bytes = &output.stdout;
701 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
702
703 let env_start_index = stdout_bytes
705 .windows(delimiter_bytes.len())
706 .position(|window| window == delimiter_bytes);
707
708 let env_output_bytes = if let Some(idx) = env_start_index {
709 &stdout_bytes[idx + delimiter_bytes.len()..]
711 } else {
712 debug!("Environment delimiter not found in hook output");
713 let len = stdout_bytes.len();
715 let start = len.saturating_sub(1000);
716 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
717 warn!(
718 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
719 tail
720 );
721
722 &[]
732 };
733
734 let env_output = String::from_utf8_lossy(env_output_bytes);
735 let mut env_delta = HashMap::new();
736
737 for line in env_output.split('\0') {
738 if line.is_empty() {
739 continue;
740 }
741
742 if let Some((key, value)) = line.split_once('=') {
743 if key.starts_with("BASH_FUNC_")
745 || key == "PS1"
746 || key == "PS2"
747 || key == "_"
748 || key == "PWD"
749 || key == "OLDPWD"
750 || key == "SHLVL"
751 || key.starts_with("BASH")
752 {
753 continue;
754 }
755
756 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
759 env_delta.insert(key.to_string(), value.to_string());
760 }
761 }
762 }
763
764 if env_delta.is_empty() && !output.status.success() {
765 let stderr = String::from_utf8_lossy(&output.stderr);
767 return Err(Error::configuration(format!(
768 "Shell script evaluation failed and no environment captured. Error: {}",
769 stderr
770 )));
771 }
772
773 debug!(
774 "Evaluated shell script and extracted {} new/changed environment variables",
775 env_delta.len()
776 );
777 Ok(env_delta)
778}
779
780async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
782 let start_time = Instant::now();
783
784 debug!(
785 "Executing hook: {} {} (source: {})",
786 hook.command,
787 hook.args.join(" "),
788 hook.source.unwrap_or(false)
789 );
790
791 let mut cmd = Command::new(&hook.command);
793 cmd.args(&hook.args);
794 cmd.stdout(Stdio::piped());
795 cmd.stderr(Stdio::piped());
796
797 if let Some(dir) = &hook.dir {
799 cmd.current_dir(dir);
800 }
801
802 if hook.source.unwrap_or(false) {
805 cmd.env("SHELL", detect_shell().await);
806 }
807
808 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
810
811 let duration_ms = start_time.elapsed().as_millis() as u64;
812
813 match execution_result {
814 Ok(Ok(output)) => {
815 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
816 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
817
818 if output.status.success() {
819 debug!("Hook completed successfully in {}ms", duration_ms);
820 Ok(HookResult::success(
821 hook,
822 output.status,
823 stdout,
824 stderr,
825 duration_ms,
826 ))
827 } else {
828 warn!("Hook failed with exit code: {:?}", output.status.code());
829 Ok(HookResult::failure(
830 hook,
831 Some(output.status),
832 stdout,
833 stderr,
834 duration_ms,
835 format!("Command exited with status: {}", output.status),
836 ))
837 }
838 }
839 Ok(Err(io_error)) => {
840 error!("Failed to execute hook: {}", io_error);
841 Ok(HookResult::failure(
842 hook,
843 None,
844 String::new(),
845 String::new(),
846 duration_ms,
847 format!("Failed to execute command: {}", io_error),
848 ))
849 }
850 Err(_timeout_error) => {
851 warn!("Hook timed out after {} seconds", timeout_seconds);
852 Ok(HookResult::timeout(
853 hook,
854 String::new(),
855 String::new(),
856 *timeout_seconds,
857 ))
858 }
859 }
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865 use crate::hooks::types::Hook;
866 use tempfile::TempDir;
867
868 #[tokio::test]
869 async fn test_hook_executor_creation() {
870 let temp_dir = TempDir::new().unwrap();
871 let config = HookExecutionConfig {
872 default_timeout_seconds: 60,
873 fail_fast: true,
874 state_dir: Some(temp_dir.path().to_path_buf()),
875 };
876
877 let executor = HookExecutor::new(config).unwrap();
878 assert_eq!(executor.config.default_timeout_seconds, 60);
879 }
880
881 #[tokio::test]
882 async fn test_execute_single_hook_success() {
883 let executor = HookExecutor::with_default_config().unwrap();
884
885 let hook = Hook {
886 order: 100,
887 propagate: false,
888 command: "echo".to_string(),
889 args: vec!["hello".to_string()],
890 dir: None,
891 inputs: vec![],
892 source: None,
893 };
894
895 let result = executor.execute_single_hook(hook).await.unwrap();
896 assert!(result.success);
897 assert!(result.stdout.contains("hello"));
898 }
899
900 #[tokio::test]
901 async fn test_execute_single_hook_failure() {
902 let executor = HookExecutor::with_default_config().unwrap();
903
904 let hook = Hook {
905 order: 100,
906 propagate: false,
907 command: "false".to_string(), args: vec![],
909 dir: None,
910 inputs: Vec::new(),
911 source: Some(false),
912 };
913
914 let result = executor.execute_single_hook(hook).await.unwrap();
915 assert!(!result.success);
916 assert!(result.exit_status.is_some());
917 assert_ne!(result.exit_status.unwrap(), 0);
918 }
919
920 #[tokio::test]
921 async fn test_execute_single_hook_timeout() {
922 let temp_dir = TempDir::new().unwrap();
923 let config = HookExecutionConfig {
924 default_timeout_seconds: 1, fail_fast: true,
926 state_dir: Some(temp_dir.path().to_path_buf()),
927 };
928 let executor = HookExecutor::new(config).unwrap();
929
930 let hook = Hook {
931 order: 100,
932 propagate: false,
933 command: "sleep".to_string(),
934 args: vec!["10".to_string()], dir: None,
936 inputs: Vec::new(),
937 source: Some(false),
938 };
939
940 let result = executor.execute_single_hook(hook).await.unwrap();
941 assert!(!result.success);
942 assert!(result.error.as_ref().unwrap().contains("timed out"));
943 }
944
945 #[tokio::test]
946 async fn test_background_execution() {
947 let temp_dir = TempDir::new().unwrap();
948 let config = HookExecutionConfig {
949 default_timeout_seconds: 30,
950 fail_fast: true,
951 state_dir: Some(temp_dir.path().to_path_buf()),
952 };
953
954 let executor = HookExecutor::new(config).unwrap();
955 let directory_path = PathBuf::from("/test/directory");
956 let config_hash = "test_hash".to_string();
957
958 let hooks = vec![
959 Hook {
960 order: 100,
961 propagate: false,
962 command: "echo".to_string(),
963 args: vec!["hook1".to_string()],
964 dir: None,
965 inputs: Vec::new(),
966 source: Some(false),
967 },
968 Hook {
969 order: 100,
970 propagate: false,
971 command: "echo".to_string(),
972 args: vec!["hook2".to_string()],
973 dir: None,
974 inputs: Vec::new(),
975 source: Some(false),
976 },
977 ];
978
979 let result = executor
980 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
981 .await
982 .unwrap();
983
984 assert!(result.contains("Started execution of 2 hooks"));
985
986 tokio::time::sleep(Duration::from_millis(100)).await;
988
989 let status = executor
991 .get_execution_status_for_instance(&directory_path, &config_hash)
992 .await
993 .unwrap();
994 assert!(status.is_some());
995
996 let state = status.unwrap();
997 assert_eq!(state.total_hooks, 2);
998 assert_eq!(state.directory_path, directory_path);
999 }
1000
1001 #[tokio::test]
1002 async fn test_command_validation() {
1003 let executor = HookExecutor::with_default_config().unwrap();
1004
1005 let hook = Hook {
1010 order: 100,
1011 propagate: false,
1012 command: "echo".to_string(),
1013 args: vec!["test message".to_string()],
1014 dir: None,
1015 inputs: Vec::new(),
1016 source: Some(false),
1017 };
1018
1019 let result = executor.execute_single_hook(hook).await;
1020 assert!(result.is_ok(), "Echo command should succeed");
1021
1022 let hook_result = result.unwrap();
1024 assert!(hook_result.stdout.contains("test message"));
1025 }
1026
1027 #[tokio::test]
1028 #[ignore = "Needs investigation - async state management"]
1029 async fn test_cancellation() {
1030 let temp_dir = TempDir::new().unwrap();
1031 let config = HookExecutionConfig {
1032 default_timeout_seconds: 30,
1033 fail_fast: false,
1034 state_dir: Some(temp_dir.path().to_path_buf()),
1035 };
1036
1037 let executor = HookExecutor::new(config).unwrap();
1038 let directory_path = PathBuf::from("/test/cancel");
1039 let config_hash = "cancel_test".to_string();
1040
1041 let hooks = vec![Hook {
1043 order: 100,
1044 propagate: false,
1045 command: "sleep".to_string(),
1046 args: vec!["10".to_string()],
1047 dir: None,
1048 inputs: Vec::new(),
1049 source: Some(false),
1050 }];
1051
1052 executor
1053 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1054 .await
1055 .unwrap();
1056
1057 tokio::time::sleep(Duration::from_millis(100)).await;
1059
1060 let cancelled = executor
1062 .cancel_execution(
1063 &directory_path,
1064 &config_hash,
1065 Some("User cancelled".to_string()),
1066 )
1067 .await
1068 .unwrap();
1069 assert!(cancelled);
1070
1071 let state = executor
1073 .get_execution_status_for_instance(&directory_path, &config_hash)
1074 .await
1075 .unwrap()
1076 .unwrap();
1077 assert_eq!(state.status, ExecutionStatus::Cancelled);
1078 }
1079
1080 #[tokio::test]
1081 async fn test_large_output_handling() {
1082 let executor = HookExecutor::with_default_config().unwrap();
1083
1084 let large_content = "x".repeat(1000); let mut args = Vec::new();
1088 for i in 0..100 {
1090 args.push(format!("Line {}: {}", i, large_content));
1091 }
1092
1093 let hook = Hook {
1095 order: 100,
1096 propagate: false,
1097 command: "echo".to_string(),
1098 args,
1099 dir: None,
1100 inputs: Vec::new(),
1101 source: Some(false),
1102 };
1103
1104 let result = executor.execute_single_hook(hook).await.unwrap();
1105 assert!(result.success);
1106 assert!(result.stdout.len() > 50_000); }
1109
1110 #[tokio::test]
1111 #[ignore = "Needs investigation - async runtime issues"]
1112 async fn test_state_cleanup() {
1113 let temp_dir = TempDir::new().unwrap();
1114 let config = HookExecutionConfig {
1115 default_timeout_seconds: 30,
1116 fail_fast: false,
1117 state_dir: Some(temp_dir.path().to_path_buf()),
1118 };
1119
1120 let executor = HookExecutor::new(config).unwrap();
1121 let directory_path = PathBuf::from("/test/cleanup");
1122 let config_hash = "cleanup_test".to_string();
1123
1124 let hooks = vec![Hook {
1126 order: 100,
1127 propagate: false,
1128 command: "echo".to_string(),
1129 args: vec!["test".to_string()],
1130 dir: None,
1131 inputs: Vec::new(),
1132 source: Some(false),
1133 }];
1134
1135 executor
1136 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1137 .await
1138 .unwrap();
1139
1140 executor
1142 .wait_for_completion(&directory_path, &config_hash, Some(5))
1143 .await
1144 .unwrap();
1145
1146 let cleaned = executor
1148 .cleanup_old_states(chrono::Duration::seconds(0))
1149 .await
1150 .unwrap();
1151 assert_eq!(cleaned, 1);
1152
1153 let state = executor
1155 .get_execution_status_for_instance(&directory_path, &config_hash)
1156 .await
1157 .unwrap();
1158 assert!(state.is_none());
1159 }
1160
1161 #[tokio::test]
1162 async fn test_execution_state_tracking() {
1163 let temp_dir = TempDir::new().unwrap();
1164 let config = HookExecutionConfig {
1165 default_timeout_seconds: 30,
1166 fail_fast: true,
1167 state_dir: Some(temp_dir.path().to_path_buf()),
1168 };
1169
1170 let executor = HookExecutor::new(config).unwrap();
1171 let directory_path = PathBuf::from("/test/directory");
1172 let config_hash = "hash".to_string();
1173
1174 let status = executor
1176 .get_execution_status_for_instance(&directory_path, &config_hash)
1177 .await
1178 .unwrap();
1179 assert!(status.is_none());
1180
1181 let hooks = vec![Hook {
1183 order: 100,
1184 propagate: false,
1185 command: "echo".to_string(),
1186 args: vec!["test".to_string()],
1187 dir: None,
1188 inputs: Vec::new(),
1189 source: Some(false),
1190 }];
1191
1192 executor
1193 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1194 .await
1195 .unwrap();
1196
1197 let status = executor
1199 .get_execution_status_for_instance(&directory_path, &config_hash)
1200 .await
1201 .unwrap();
1202 assert!(status.is_some());
1203 }
1204
1205 #[tokio::test]
1275 #[ignore = "Needs investigation - timing issues"]
1276 async fn test_fail_fast_mode_edge_cases() {
1277 let temp_dir = TempDir::new().unwrap();
1278
1279 let config = HookExecutionConfig {
1281 default_timeout_seconds: 30,
1282 fail_fast: true,
1283 state_dir: Some(temp_dir.path().to_path_buf()),
1284 };
1285
1286 let executor = HookExecutor::new(config).unwrap();
1287 let directory_path = PathBuf::from("/test/fail-fast");
1288
1289 let hooks = vec![
1290 Hook {
1291 order: 100,
1292 propagate: false,
1293 command: "false".to_string(), args: vec![],
1295 dir: None,
1296 inputs: Vec::new(),
1297 source: Some(false),
1298 },
1299 Hook {
1300 order: 100,
1301 propagate: false,
1302 command: "echo".to_string(), args: vec!["should not run".to_string()],
1304 dir: None,
1305 inputs: Vec::new(),
1306 source: Some(false),
1307 },
1308 Hook {
1309 order: 100,
1310 propagate: false,
1311 command: "echo".to_string(), args: vec!["also should not run".to_string()],
1313 dir: None,
1314 inputs: Vec::new(),
1315 source: Some(false),
1316 },
1317 ];
1318
1319 let config_hash = "fail_fast_test".to_string();
1320 executor
1321 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1322 .await
1323 .unwrap();
1324
1325 executor
1327 .wait_for_completion(&directory_path, &config_hash, Some(10))
1328 .await
1329 .unwrap();
1330
1331 let state = executor
1332 .get_execution_status_for_instance(&directory_path, &config_hash)
1333 .await
1334 .unwrap()
1335 .unwrap();
1336
1337 assert_eq!(state.status, ExecutionStatus::Failed);
1338 assert_eq!(state.completed_hooks, 1);
1341
1342 let directory_path2 = PathBuf::from("/test/fail-fast-continue");
1344
1345 let hooks2 = vec![
1346 Hook {
1347 order: 100,
1348 propagate: false,
1349 command: "false".to_string(),
1350 args: vec![],
1351 dir: None,
1352 inputs: Vec::new(),
1353 source: Some(false),
1354 },
1355 Hook {
1356 order: 100,
1357 propagate: false,
1358 command: "echo".to_string(),
1359 args: vec!["this should run".to_string()],
1360 dir: None,
1361 inputs: Vec::new(),
1362 source: Some(false),
1363 },
1364 Hook {
1365 order: 100,
1366 propagate: false,
1367 command: "false".to_string(), args: vec![],
1369 dir: None,
1370 inputs: Vec::new(),
1371 source: Some(false),
1372 },
1373 Hook {
1374 order: 100,
1375 propagate: false,
1376 command: "echo".to_string(),
1377 args: vec!["this should not run".to_string()],
1378 dir: None,
1379 inputs: Vec::new(),
1380 source: Some(false),
1381 },
1382 ];
1383
1384 let config_hash2 = "fail_fast_continue_test".to_string();
1385 executor
1386 .execute_hooks_background(directory_path2.clone(), config_hash2.clone(), hooks2)
1387 .await
1388 .unwrap();
1389
1390 executor
1391 .wait_for_completion(&directory_path2, &config_hash2, Some(10))
1392 .await
1393 .unwrap();
1394
1395 let state2 = executor
1396 .get_execution_status_for_instance(&directory_path2, &config_hash2)
1397 .await
1398 .unwrap()
1399 .unwrap();
1400
1401 assert_eq!(state2.status, ExecutionStatus::Failed);
1402 assert_eq!(state2.completed_hooks, 3);
1405 }
1406
1407 #[tokio::test]
1408 async fn test_security_validation_comprehensive() {
1409 let executor = HookExecutor::with_default_config().unwrap();
1410
1411 let test_args = vec![
1416 vec!["simple test".to_string()],
1417 vec!["test with spaces".to_string()],
1418 ["test", "multiple", "args"]
1419 .iter()
1420 .map(|s| s.to_string())
1421 .collect(),
1422 ];
1423
1424 for args in test_args {
1425 let hook = Hook {
1426 order: 100,
1427 propagate: false,
1428 command: "echo".to_string(),
1429 args: args.clone(),
1430 dir: None,
1431 inputs: Vec::new(),
1432 source: Some(false),
1433 };
1434
1435 let result = executor.execute_single_hook(hook).await;
1436 assert!(
1437 result.is_ok(),
1438 "Echo command should work with args: {:?}",
1439 args
1440 );
1441 }
1442 }
1443
1444 #[tokio::test]
1445 async fn test_working_directory_handling() {
1446 let executor = HookExecutor::with_default_config().unwrap();
1447 let temp_dir = TempDir::new().unwrap();
1448
1449 let hook_with_valid_dir = Hook {
1451 order: 100,
1452 propagate: false,
1453 command: "pwd".to_string(),
1454 args: vec![],
1455 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1456 inputs: vec![],
1457 source: None,
1458 };
1459
1460 let result = executor
1461 .execute_single_hook(hook_with_valid_dir)
1462 .await
1463 .unwrap();
1464 assert!(result.success);
1465 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1466
1467 let hook_with_invalid_dir = Hook {
1469 order: 100,
1470 propagate: false,
1471 command: "pwd".to_string(),
1472 args: vec![],
1473 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1474 inputs: vec![],
1475 source: None,
1476 };
1477
1478 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1479 if result.is_ok() {
1482 assert!(
1484 !result
1485 .unwrap()
1486 .stdout
1487 .contains("/nonexistent/directory/that/does/not/exist")
1488 );
1489 }
1490
1491 let hook_with_relative_dir = Hook {
1493 order: 100,
1494 propagate: false,
1495 command: "pwd".to_string(),
1496 args: vec![],
1497 dir: Some("./relative/path".to_string()),
1498 inputs: vec![],
1499 source: None,
1500 };
1501
1502 let _ = executor.execute_single_hook(hook_with_relative_dir).await;
1504 }
1505
1506 #[tokio::test]
1507 async fn test_hook_execution_with_complex_output() {
1508 let executor = HookExecutor::with_default_config().unwrap();
1509
1510 let hook = Hook {
1512 order: 100,
1513 propagate: false,
1514 command: "echo".to_string(),
1515 args: vec!["stdout output".to_string()],
1516 dir: None,
1517 inputs: vec![],
1518 source: None,
1519 };
1520
1521 let result = executor.execute_single_hook(hook).await.unwrap();
1522 assert!(result.success);
1523 assert!(result.stdout.contains("stdout output"));
1524
1525 let hook_with_exit_code = Hook {
1527 order: 100,
1528 propagate: false,
1529 command: "false".to_string(),
1530 args: vec![],
1531 dir: None,
1532 inputs: Vec::new(),
1533 source: Some(false),
1534 };
1535
1536 let result = executor
1537 .execute_single_hook(hook_with_exit_code)
1538 .await
1539 .unwrap();
1540 assert!(!result.success);
1541 assert!(result.exit_status.is_some());
1543 }
1544
1545 #[tokio::test]
1546 #[ignore = "Needs investigation - state management"]
1547 async fn test_multiple_directory_executions() {
1548 let temp_dir = TempDir::new().unwrap();
1549 let config = HookExecutionConfig {
1550 default_timeout_seconds: 30,
1551 fail_fast: false,
1552 state_dir: Some(temp_dir.path().to_path_buf()),
1553 };
1554
1555 let executor = HookExecutor::new(config).unwrap();
1556
1557 let directories = [
1559 PathBuf::from("/test/dir1"),
1560 PathBuf::from("/test/dir2"),
1561 PathBuf::from("/test/dir3"),
1562 ];
1563
1564 let mut config_hashes = Vec::new();
1565 for (i, dir) in directories.iter().enumerate() {
1566 let hooks = vec![Hook {
1567 order: 100,
1568 propagate: false,
1569 command: "echo".to_string(),
1570 args: vec![format!("directory {}", i)],
1571 dir: None,
1572 inputs: Vec::new(),
1573 source: Some(false),
1574 }];
1575
1576 let config_hash = format!("hash_{}", i);
1577 config_hashes.push(config_hash.clone());
1578 executor
1579 .execute_hooks_background(dir.clone(), config_hash.clone(), hooks)
1580 .await
1581 .unwrap();
1582 }
1583
1584 for (dir, config_hash) in directories.iter().zip(config_hashes.iter()) {
1586 executor
1587 .wait_for_completion(dir, config_hash, Some(10))
1588 .await
1589 .unwrap();
1590
1591 let state = executor
1592 .get_execution_status_for_instance(dir, config_hash)
1593 .await
1594 .unwrap()
1595 .unwrap();
1596
1597 assert_eq!(state.status, ExecutionStatus::Completed);
1598 assert_eq!(state.completed_hooks, 1);
1599 assert_eq!(state.total_hooks, 1);
1600 }
1601 }
1602
1603 #[tokio::test]
1604 #[ignore = "Needs investigation - retry logic"]
1605 async fn test_error_recovery_and_retry() {
1606 let temp_dir = TempDir::new().unwrap();
1607 let config = HookExecutionConfig {
1608 default_timeout_seconds: 30,
1609 fail_fast: false,
1610 state_dir: Some(temp_dir.path().to_path_buf()),
1611 };
1612
1613 let executor = HookExecutor::new(config).unwrap();
1614 let directory_path = PathBuf::from("/test/recovery");
1615
1616 let hooks = vec![
1618 Hook {
1619 order: 100,
1620 propagate: false,
1621 command: "echo".to_string(),
1622 args: vec!["success 1".to_string()],
1623 dir: None,
1624 inputs: Vec::new(),
1625 source: Some(false),
1626 },
1627 Hook {
1628 order: 100,
1629 propagate: false,
1630 command: "false".to_string(),
1631 args: vec![],
1632 dir: None,
1633 inputs: Vec::new(),
1634 source: Some(false),
1635 },
1636 Hook {
1637 order: 100,
1638 propagate: false,
1639 command: "echo".to_string(),
1640 args: vec!["success 2".to_string()],
1641 dir: None,
1642 inputs: Vec::new(),
1643 source: Some(false),
1644 },
1645 ];
1646
1647 let config_hash = "recovery_test".to_string();
1648 executor
1649 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1650 .await
1651 .unwrap();
1652
1653 executor
1654 .wait_for_completion(&directory_path, &config_hash, Some(10))
1655 .await
1656 .unwrap();
1657
1658 let state = executor
1659 .get_execution_status_for_instance(&directory_path, &config_hash)
1660 .await
1661 .unwrap()
1662 .unwrap();
1663
1664 assert_eq!(state.status, ExecutionStatus::Completed);
1666 assert_eq!(state.completed_hooks, 3);
1667 assert_eq!(state.total_hooks, 3);
1668 }
1669
1670 #[tokio::test]
1671 #[ignore = "Requires supervisor binary - integration test"]
1672 async fn test_instance_hash_separation() {
1673 let temp_dir = TempDir::new().unwrap();
1675 let config = HookExecutionConfig {
1676 default_timeout_seconds: 30,
1677 fail_fast: false,
1678 state_dir: Some(temp_dir.path().to_path_buf()),
1679 };
1680
1681 let executor = HookExecutor::new(config).unwrap();
1682 let directory_path = PathBuf::from("/test/multi-config");
1683
1684 let hooks1 = vec![Hook {
1686 order: 100,
1687 propagate: false,
1688 command: "echo".to_string(),
1689 args: vec!["config1".to_string()],
1690 dir: None,
1691 inputs: Vec::new(),
1692 source: Some(false),
1693 }];
1694
1695 let hooks2 = vec![Hook {
1697 order: 100,
1698 propagate: false,
1699 command: "echo".to_string(),
1700 args: vec!["config2".to_string()],
1701 dir: None,
1702 inputs: Vec::new(),
1703 source: Some(false),
1704 }];
1705
1706 let config_hash1 = "config_hash_1".to_string();
1707 let config_hash2 = "config_hash_2".to_string();
1708
1709 executor
1711 .execute_hooks_background(directory_path.clone(), config_hash1.clone(), hooks1)
1712 .await
1713 .unwrap();
1714
1715 executor
1716 .execute_hooks_background(directory_path.clone(), config_hash2.clone(), hooks2)
1717 .await
1718 .unwrap();
1719
1720 executor
1722 .wait_for_completion(&directory_path, &config_hash1, Some(5))
1723 .await
1724 .unwrap();
1725
1726 executor
1727 .wait_for_completion(&directory_path, &config_hash2, Some(5))
1728 .await
1729 .unwrap();
1730
1731 let state1 = executor
1733 .get_execution_status_for_instance(&directory_path, &config_hash1)
1734 .await
1735 .unwrap()
1736 .unwrap();
1737
1738 let state2 = executor
1739 .get_execution_status_for_instance(&directory_path, &config_hash2)
1740 .await
1741 .unwrap()
1742 .unwrap();
1743
1744 assert_eq!(state1.status, ExecutionStatus::Completed);
1745 assert_eq!(state2.status, ExecutionStatus::Completed);
1746
1747 assert_ne!(state1.instance_hash, state2.instance_hash);
1749 }
1750
1751 #[tokio::test]
1752 #[ignore = "Requires supervisor binary - integration test"]
1753 async fn test_file_based_argument_passing() {
1754 let temp_dir = TempDir::new().unwrap();
1756 let config = HookExecutionConfig {
1757 default_timeout_seconds: 30,
1758 fail_fast: false,
1759 state_dir: Some(temp_dir.path().to_path_buf()),
1760 };
1761
1762 let executor = HookExecutor::new(config).unwrap();
1763 let directory_path = PathBuf::from("/test/file-args");
1764 let config_hash = "file_test".to_string();
1765
1766 let mut large_hooks = Vec::new();
1768 for i in 0..100 {
1769 large_hooks.push(Hook { order: 100, propagate: false,
1770 command: "echo".to_string(),
1771 args: vec![format!("This is a very long argument string number {} with lots of text to ensure we test the file-based argument passing mechanism properly", i)],
1772 dir: None,
1773 inputs: Vec::new(),
1774 source: Some(false),
1775 });
1776 }
1777
1778 let result = executor
1780 .execute_hooks_background(directory_path.clone(), config_hash.clone(), large_hooks)
1781 .await;
1782
1783 assert!(result.is_ok(), "Should handle large hook configurations");
1784
1785 tokio::time::sleep(Duration::from_millis(500)).await;
1787
1788 executor
1790 .cancel_execution(
1791 &directory_path,
1792 &config_hash,
1793 Some("Test cleanup".to_string()),
1794 )
1795 .await
1796 .ok();
1797 }
1798
1799 #[tokio::test]
1800 async fn test_state_dir_getter() {
1801 use crate::hooks::state::StateManager;
1802
1803 let temp_dir = TempDir::new().unwrap();
1804 let state_dir = temp_dir.path().to_path_buf();
1805 let state_manager = StateManager::new(state_dir.clone());
1806
1807 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1808 }
1809}