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 self.state_manager
91 .create_directory_marker(&directory_path, &instance_hash)
92 .await?;
93
94 info!(
95 "Starting background execution of {} hooks for directory: {}",
96 total_hooks,
97 directory_path.display()
98 );
99
100 let pid_file = self
102 .state_manager
103 .get_state_file_path(&instance_hash)
104 .with_extension("pid");
105
106 if pid_file.exists() {
107 if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
109 && let Ok(pid) = pid_str.trim().parse::<usize>()
110 {
111 use sysinfo::{Pid, ProcessRefreshKind, System};
113 let mut system = System::new();
114 let process_pid = Pid::from(pid);
115 system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
116
117 if system.process(process_pid).is_some() {
118 info!("Supervisor already running for directory with PID {}", pid);
119 return Ok(format!(
120 "Supervisor already running for {} hooks (PID: {})",
121 total_hooks, pid
122 ));
123 }
124 }
125 std::fs::remove_file(&pid_file).ok();
127 }
128
129 let state_dir = self.state_manager.get_state_dir();
131 let hooks_file = state_dir.join(format!("{}_hooks.json", instance_hash));
132 let config_file = state_dir.join(format!("{}_config.json", instance_hash));
133
134 let hooks_json = serde_json::to_string(&hooks)
136 .map_err(|e| Error::configuration(format!("Failed to serialize hooks: {}", e)))?;
137 std::fs::write(&hooks_file, &hooks_json).map_err(|e| Error::Io {
138 source: e,
139 path: Some(hooks_file.clone().into_boxed_path()),
140 operation: "write".to_string(),
141 })?;
142
143 let config_json = serde_json::to_string(&self.config)
145 .map_err(|e| Error::configuration(format!("Failed to serialize config: {}", e)))?;
146 std::fs::write(&config_file, &config_json).map_err(|e| Error::Io {
147 source: e,
148 path: Some(config_file.clone().into_boxed_path()),
149 operation: "write".to_string(),
150 })?;
151
152 let current_exe = if let Ok(exe_path) = std::env::var("CUENV_EXECUTABLE") {
155 PathBuf::from(exe_path)
156 } else {
157 std::env::current_exe()
158 .map_err(|e| Error::configuration(format!("Failed to get current exe: {}", e)))?
159 };
160
161 use std::process::{Command, Stdio};
163
164 let mut cmd = Command::new(¤t_exe);
165 cmd.arg("__hook-supervisor") .arg("--directory")
167 .arg(directory_path.to_string_lossy().to_string())
168 .arg("--instance-hash")
169 .arg(&instance_hash)
170 .arg("--config-hash")
171 .arg(&config_hash)
172 .arg("--hooks-file")
173 .arg(hooks_file.to_string_lossy().to_string())
174 .arg("--config-file")
175 .arg(config_file.to_string_lossy().to_string())
176 .stdin(Stdio::null());
177
178 let temp_dir = std::env::temp_dir();
180 let log_file = std::fs::File::create(temp_dir.join("cuenv_supervisor.log")).ok();
181 let err_file = std::fs::File::create(temp_dir.join("cuenv_supervisor_err.log")).ok();
182
183 if let Some(log) = log_file {
184 cmd.stdout(Stdio::from(log));
185 } else {
186 cmd.stdout(Stdio::null());
187 }
188
189 if let Some(err) = err_file {
190 cmd.stderr(Stdio::from(err));
191 } else {
192 cmd.stderr(Stdio::null());
193 }
194
195 if let Ok(state_dir) = std::env::var("CUENV_STATE_DIR") {
197 cmd.env("CUENV_STATE_DIR", state_dir);
198 }
199
200 if let Ok(approval_file) = std::env::var("CUENV_APPROVAL_FILE") {
202 cmd.env("CUENV_APPROVAL_FILE", approval_file);
203 }
204
205 if let Ok(rust_log) = std::env::var("RUST_LOG") {
207 cmd.env("RUST_LOG", rust_log);
208 }
209
210 #[cfg(unix)]
212 {
213 use std::os::unix::process::CommandExt;
214 unsafe {
216 cmd.pre_exec(|| {
217 if libc::setsid() == -1 {
219 return Err(std::io::Error::last_os_error());
220 }
221 Ok(())
222 });
223 }
224 }
225
226 #[cfg(windows)]
227 {
228 use std::os::windows::process::CommandExt;
229 const DETACHED_PROCESS: u32 = 0x00000008;
231 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
232 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
233 }
234
235 let _child = cmd
236 .spawn()
237 .map_err(|e| Error::configuration(format!("Failed to spawn supervisor: {}", e)))?;
238
239 info!("Spawned supervisor process for hook execution");
242
243 Ok(format!(
244 "Started execution of {} hooks in background",
245 total_hooks
246 ))
247 }
248
249 pub async fn get_execution_status(
251 &self,
252 directory_path: &Path,
253 ) -> Result<Option<HookExecutionState>> {
254 let states = self.state_manager.list_active_states().await?;
256 for state in states {
257 if state.directory_path == directory_path {
258 return Ok(Some(state));
259 }
260 }
261 Ok(None)
262 }
263
264 pub async fn get_execution_status_for_instance(
266 &self,
267 directory_path: &Path,
268 config_hash: &str,
269 ) -> Result<Option<HookExecutionState>> {
270 let instance_hash = compute_instance_hash(directory_path, config_hash);
271 self.state_manager.load_state(&instance_hash).await
272 }
273
274 pub async fn get_fast_status(
278 &self,
279 directory_path: &Path,
280 ) -> Result<Option<HookExecutionState>> {
281 if !self.state_manager.has_active_marker(directory_path) {
283 return Ok(None);
284 }
285
286 if let Some(instance_hash) = self
288 .state_manager
289 .get_marker_instance_hash(directory_path)
290 .await
291 {
292 let state = self.state_manager.load_state(&instance_hash).await?;
293
294 match &state {
295 Some(s) if s.is_complete() && !s.should_display_completed() => {
296 self.state_manager
298 .remove_directory_marker(directory_path)
299 .await
300 .ok();
301 return Ok(None);
302 }
303 None => {
304 self.state_manager
306 .remove_directory_marker(directory_path)
307 .await
308 .ok();
309 return Ok(None);
310 }
311 Some(_) => return Ok(state),
312 }
313 }
314
315 Ok(None)
316 }
317
318 pub fn state_manager(&self) -> &StateManager {
320 &self.state_manager
321 }
322
323 pub fn get_fast_status_sync(
327 &self,
328 directory_path: &Path,
329 ) -> Result<Option<HookExecutionState>> {
330 if !self.state_manager.has_active_marker(directory_path) {
332 return Ok(None);
333 }
334
335 if let Some(instance_hash) = self
337 .state_manager
338 .get_marker_instance_hash_sync(directory_path)
339 {
340 let state = self.state_manager.load_state_sync(&instance_hash)?;
341
342 match &state {
343 Some(s) if s.is_complete() && !s.should_display_completed() => {
344 return Ok(None);
347 }
348 None => {
349 return Ok(None);
352 }
353 Some(_) => return Ok(state),
354 }
355 }
356
357 Ok(None)
358 }
359
360 pub async fn wait_for_completion(
362 &self,
363 directory_path: &Path,
364 config_hash: &str,
365 timeout_seconds: Option<u64>,
366 ) -> Result<HookExecutionState> {
367 let instance_hash = compute_instance_hash(directory_path, config_hash);
368 let poll_interval = Duration::from_millis(500);
369 let start_time = Instant::now();
370
371 loop {
372 if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
373 if state.is_complete() {
374 return Ok(state);
375 }
376 } else {
377 return Err(Error::configuration("No execution state found"));
378 }
379
380 if let Some(timeout) = timeout_seconds
382 && start_time.elapsed().as_secs() >= timeout
383 {
384 return Err(Error::Timeout { seconds: timeout });
385 }
386
387 tokio::time::sleep(poll_interval).await;
388 }
389 }
390
391 pub async fn cancel_execution(
393 &self,
394 directory_path: &Path,
395 config_hash: &str,
396 reason: Option<String>,
397 ) -> Result<bool> {
398 let instance_hash = compute_instance_hash(directory_path, config_hash);
399
400 let pid_file = self
402 .state_manager
403 .get_state_file_path(&instance_hash)
404 .with_extension("pid");
405
406 if pid_file.exists()
407 && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
408 && let Ok(pid) = pid_str.trim().parse::<usize>()
409 {
410 use sysinfo::{Pid, ProcessRefreshKind, Signal, System};
411
412 let mut system = System::new();
413 let process_pid = Pid::from(pid);
414
415 system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
417
418 if let Some(process) = system.process(process_pid) {
420 if process.kill_with(Signal::Term).is_some() {
421 info!("Sent SIGTERM to supervisor process PID {}", pid);
422 } else {
423 warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
424 }
425 } else {
426 info!(
427 "Supervisor process PID {} not found (may have already exited)",
428 pid
429 );
430 }
431
432 std::fs::remove_file(&pid_file).ok();
434 }
435
436 if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
438 && !state.is_complete()
439 {
440 state.mark_cancelled(reason);
441 self.state_manager.save_state(&state).await?;
442 info!(
443 "Cancelled execution for directory: {}",
444 directory_path.display()
445 );
446 return Ok(true);
447 }
448
449 Ok(false)
450 }
451
452 pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
454 let states = self.state_manager.list_active_states().await?;
455 let cutoff = chrono::Utc::now() - older_than;
456 let mut cleaned_count = 0;
457
458 for state in states {
459 if state.is_complete()
460 && let Some(finished_at) = state.finished_at
461 && finished_at < cutoff
462 {
463 self.state_manager
464 .remove_state(&state.instance_hash)
465 .await?;
466 cleaned_count += 1;
467 }
468 }
469
470 if cleaned_count > 0 {
471 info!("Cleaned up {} old execution states", cleaned_count);
472 }
473
474 Ok(cleaned_count)
475 }
476
477 pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
479 let timeout = self.config.default_timeout_seconds;
481
482 execute_hook_with_timeout(hook, &timeout).await
484 }
485}
486
487pub async fn execute_hooks(
489 hooks: Vec<Hook>,
490 _directory_path: &Path,
491 config: &HookExecutionConfig,
492 state_manager: &StateManager,
493 state: &mut HookExecutionState,
494) -> Result<()> {
495 let hook_count = hooks.len();
496 debug!("execute_hooks called with {} hooks", hook_count);
497 if hook_count == 0 {
498 debug!("No hooks to execute");
499 return Ok(());
500 }
501 debug!("Starting to iterate over {} hooks", hook_count);
502 for (index, hook) in hooks.into_iter().enumerate() {
503 debug!(
504 "Processing hook {}/{}: command={}",
505 index + 1,
506 state.total_hooks,
507 hook.command
508 );
509 debug!("Checking if execution was cancelled");
511 if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
512 debug!("Loaded state: status = {:?}", current_state.status);
513 if current_state.status == ExecutionStatus::Cancelled {
514 debug!("Execution was cancelled, stopping");
515 break;
516 }
517 }
518
519 let timeout_seconds = config.default_timeout_seconds;
522
523 state.mark_hook_running(index);
525
526 let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
528
529 match result {
531 Ok(hook_result) => {
532 if hook.source.unwrap_or(false) {
537 if !hook_result.stdout.is_empty() {
538 debug!(
539 "Evaluating source hook output for environment variables (success={})",
540 hook_result.success
541 );
542 match evaluate_shell_environment(&hook_result.stdout).await {
543 Ok(env_vars) => {
544 let count = env_vars.len();
545 debug!("Captured {} environment variables from source hook", count);
546 if count > 0 {
547 for (key, value) in env_vars {
549 state.environment_vars.insert(key, value);
550 }
551 }
552 }
553 Err(e) => {
554 warn!("Failed to evaluate source hook output: {}", e);
555 }
557 }
558 } else {
559 warn!(
560 "Source hook produced empty stdout. Stderr content:\n{}",
561 hook_result.stderr
562 );
563 }
564 }
565
566 state.record_hook_result(index, hook_result.clone());
567 if !hook_result.success && config.fail_fast {
568 warn!(
569 "Hook {} failed and fail_fast is enabled, stopping",
570 index + 1
571 );
572 break;
573 }
574 }
575 Err(e) => {
576 let error_msg = format!("Hook execution error: {}", e);
577 state.record_hook_result(
578 index,
579 HookResult::failure(
580 hook.clone(),
581 None,
582 String::new(),
583 error_msg.clone(),
584 0,
585 error_msg,
586 ),
587 );
588 if config.fail_fast {
589 warn!("Hook {} failed with error, stopping", index + 1);
590 break;
591 }
592 }
593 }
594
595 state_manager.save_state(state).await?;
597 }
598
599 if state.status == ExecutionStatus::Running {
601 state.status = ExecutionStatus::Completed;
602 state.finished_at = Some(chrono::Utc::now());
603 info!(
604 "All hooks completed successfully for directory: {}",
605 state.directory_path.display()
606 );
607 }
608
609 state_manager.save_state(state).await?;
611
612 Ok(())
613}
614
615async fn detect_shell() -> String {
617 if is_shell_capable("bash").await {
619 return "bash".to_string();
620 }
621
622 if is_shell_capable("zsh").await {
624 return "zsh".to_string();
625 }
626
627 "sh".to_string()
629}
630
631async fn is_shell_capable(shell: &str) -> bool {
633 let check_script = "case x in x) true ;& y) true ;; esac";
634 match Command::new(shell)
635 .arg("-c")
636 .arg(check_script)
637 .output()
638 .await
639 {
640 Ok(output) => output.status.success(),
641 Err(_) => false,
642 }
643}
644
645async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
647 debug!(
648 "Evaluating shell script to extract environment ({} bytes)",
649 shell_script.len()
650 );
651
652 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
653
654 let mut shell = detect_shell().await;
657
658 for line in shell_script.lines() {
659 if let Some(path) = line.strip_prefix("BASH='")
660 && let Some(end) = path.find('\'')
661 {
662 let bash_path = &path[..end];
663 let path = PathBuf::from(bash_path);
664 if path.exists() {
665 debug!("Detected Nix bash in script: {}", bash_path);
666 shell = bash_path.to_string();
667 break;
668 }
669 }
670 }
671
672 debug!("Using shell: {}", shell);
673
674 let mut cmd_before = Command::new(&shell);
676 cmd_before.arg("-c");
677 cmd_before.arg("env -0");
678 cmd_before.stdout(Stdio::piped());
679 cmd_before.stderr(Stdio::piped());
680
681 let output_before = cmd_before
682 .output()
683 .await
684 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
685
686 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
687 let mut env_before = HashMap::new();
688 for line in env_before_output.split('\0') {
689 if let Some((key, value)) = line.split_once('=') {
690 env_before.insert(key.to_string(), value.to_string());
691 }
692 }
693
694 let filtered_lines: Vec<&str> = shell_script
696 .lines()
697 .filter(|line| {
698 let trimmed = line.trim();
699 if trimmed.is_empty() {
700 return false;
701 }
702
703 if trimmed.starts_with("✓")
705 || trimmed.starts_with("sh:")
706 || trimmed.starts_with("bash:")
707 {
708 return false;
709 }
710
711 true
714 })
715 .collect();
716
717 let filtered_script = filtered_lines.join("\n");
718 tracing::trace!("Filtered shell script:\n{}", filtered_script);
719
720 let mut cmd = Command::new(shell);
722 cmd.arg("-c");
723 const DELIMITER: &str = "__CUENV_ENV_START__";
766 let script = format!(
767 "{}\necho -ne '\\0{}\\0'; env -0",
768 filtered_script, DELIMITER
769 );
770 cmd.arg(script);
771 cmd.stdout(Stdio::piped());
772 cmd.stderr(Stdio::piped());
773
774 let output = cmd.output().await.map_err(|e| {
775 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
776 })?;
777
778 if !output.status.success() {
781 let stderr = String::from_utf8_lossy(&output.stderr);
782 warn!(
783 "Shell script evaluation finished with error (exit code {:?}): {}",
784 output.status.code(),
785 stderr
786 );
787 }
789
790 let stdout_bytes = &output.stdout;
792 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
793
794 let env_start_index = stdout_bytes
796 .windows(delimiter_bytes.len())
797 .position(|window| window == delimiter_bytes);
798
799 let env_output_bytes = if let Some(idx) = env_start_index {
800 &stdout_bytes[idx + delimiter_bytes.len()..]
802 } else {
803 debug!("Environment delimiter not found in hook output");
804 let len = stdout_bytes.len();
806 let start = len.saturating_sub(1000);
807 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
808 warn!(
809 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
810 tail
811 );
812
813 &[]
823 };
824
825 let env_output = String::from_utf8_lossy(env_output_bytes);
826 let mut env_delta = HashMap::new();
827
828 for line in env_output.split('\0') {
829 if line.is_empty() {
830 continue;
831 }
832
833 if let Some((key, value)) = line.split_once('=') {
834 if key.starts_with("BASH_FUNC_")
836 || key == "PS1"
837 || key == "PS2"
838 || key == "_"
839 || key == "PWD"
840 || key == "OLDPWD"
841 || key == "SHLVL"
842 || key.starts_with("BASH")
843 {
844 continue;
845 }
846
847 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
850 env_delta.insert(key.to_string(), value.to_string());
851 }
852 }
853 }
854
855 if env_delta.is_empty() && !output.status.success() {
856 let stderr = String::from_utf8_lossy(&output.stderr);
858 return Err(Error::configuration(format!(
859 "Shell script evaluation failed and no environment captured. Error: {}",
860 stderr
861 )));
862 }
863
864 debug!(
865 "Evaluated shell script and extracted {} new/changed environment variables",
866 env_delta.len()
867 );
868 Ok(env_delta)
869}
870
871async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
873 let start_time = Instant::now();
874
875 debug!(
876 "Executing hook: {} {} (source: {})",
877 hook.command,
878 hook.args.join(" "),
879 hook.source.unwrap_or(false)
880 );
881
882 let mut cmd = Command::new(&hook.command);
884 cmd.args(&hook.args);
885 cmd.stdout(Stdio::piped());
886 cmd.stderr(Stdio::piped());
887
888 if let Some(dir) = &hook.dir {
890 cmd.current_dir(dir);
891 }
892
893 if hook.source.unwrap_or(false) {
896 cmd.env("SHELL", detect_shell().await);
897 }
898
899 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
901
902 let duration_ms = start_time.elapsed().as_millis() as u64;
903
904 match execution_result {
905 Ok(Ok(output)) => {
906 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
907 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
908
909 if output.status.success() {
910 debug!("Hook completed successfully in {}ms", duration_ms);
911 Ok(HookResult::success(
912 hook,
913 output.status,
914 stdout,
915 stderr,
916 duration_ms,
917 ))
918 } else {
919 warn!("Hook failed with exit code: {:?}", output.status.code());
920 Ok(HookResult::failure(
921 hook,
922 Some(output.status),
923 stdout,
924 stderr,
925 duration_ms,
926 format!("Command exited with status: {}", output.status),
927 ))
928 }
929 }
930 Ok(Err(io_error)) => {
931 error!("Failed to execute hook: {}", io_error);
932 Ok(HookResult::failure(
933 hook,
934 None,
935 String::new(),
936 String::new(),
937 duration_ms,
938 format!("Failed to execute command: {}", io_error),
939 ))
940 }
941 Err(_timeout_error) => {
942 warn!("Hook timed out after {} seconds", timeout_seconds);
943 Ok(HookResult::timeout(
944 hook,
945 String::new(),
946 String::new(),
947 *timeout_seconds,
948 ))
949 }
950 }
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956 use crate::hooks::types::Hook;
957 use tempfile::TempDir;
958
959 #[tokio::test]
960 async fn test_hook_executor_creation() {
961 let temp_dir = TempDir::new().unwrap();
962 let config = HookExecutionConfig {
963 default_timeout_seconds: 60,
964 fail_fast: true,
965 state_dir: Some(temp_dir.path().to_path_buf()),
966 };
967
968 let executor = HookExecutor::new(config).unwrap();
969 assert_eq!(executor.config.default_timeout_seconds, 60);
970 }
971
972 #[tokio::test]
973 async fn test_execute_single_hook_success() {
974 let executor = HookExecutor::with_default_config().unwrap();
975
976 let hook = Hook {
977 order: 100,
978 propagate: false,
979 command: "echo".to_string(),
980 args: vec!["hello".to_string()],
981 dir: None,
982 inputs: vec![],
983 source: None,
984 };
985
986 let result = executor.execute_single_hook(hook).await.unwrap();
987 assert!(result.success);
988 assert!(result.stdout.contains("hello"));
989 }
990
991 #[tokio::test]
992 async fn test_execute_single_hook_failure() {
993 let executor = HookExecutor::with_default_config().unwrap();
994
995 let hook = Hook {
996 order: 100,
997 propagate: false,
998 command: "false".to_string(), args: vec![],
1000 dir: None,
1001 inputs: Vec::new(),
1002 source: Some(false),
1003 };
1004
1005 let result = executor.execute_single_hook(hook).await.unwrap();
1006 assert!(!result.success);
1007 assert!(result.exit_status.is_some());
1008 assert_ne!(result.exit_status.unwrap(), 0);
1009 }
1010
1011 #[tokio::test]
1012 async fn test_execute_single_hook_timeout() {
1013 let temp_dir = TempDir::new().unwrap();
1014 let config = HookExecutionConfig {
1015 default_timeout_seconds: 1, fail_fast: true,
1017 state_dir: Some(temp_dir.path().to_path_buf()),
1018 };
1019 let executor = HookExecutor::new(config).unwrap();
1020
1021 let hook = Hook {
1022 order: 100,
1023 propagate: false,
1024 command: "sleep".to_string(),
1025 args: vec!["10".to_string()], dir: None,
1027 inputs: Vec::new(),
1028 source: Some(false),
1029 };
1030
1031 let result = executor.execute_single_hook(hook).await.unwrap();
1032 assert!(!result.success);
1033 assert!(result.error.as_ref().unwrap().contains("timed out"));
1034 }
1035
1036 #[tokio::test]
1037 async fn test_background_execution() {
1038 let temp_dir = TempDir::new().unwrap();
1039 let config = HookExecutionConfig {
1040 default_timeout_seconds: 30,
1041 fail_fast: true,
1042 state_dir: Some(temp_dir.path().to_path_buf()),
1043 };
1044
1045 let executor = HookExecutor::new(config).unwrap();
1046 let directory_path = PathBuf::from("/test/directory");
1047 let config_hash = "test_hash".to_string();
1048
1049 let hooks = vec![
1050 Hook {
1051 order: 100,
1052 propagate: false,
1053 command: "echo".to_string(),
1054 args: vec!["hook1".to_string()],
1055 dir: None,
1056 inputs: Vec::new(),
1057 source: Some(false),
1058 },
1059 Hook {
1060 order: 100,
1061 propagate: false,
1062 command: "echo".to_string(),
1063 args: vec!["hook2".to_string()],
1064 dir: None,
1065 inputs: Vec::new(),
1066 source: Some(false),
1067 },
1068 ];
1069
1070 let result = executor
1071 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1072 .await
1073 .unwrap();
1074
1075 assert!(result.contains("Started execution of 2 hooks"));
1076
1077 tokio::time::sleep(Duration::from_millis(100)).await;
1079
1080 let status = executor
1082 .get_execution_status_for_instance(&directory_path, &config_hash)
1083 .await
1084 .unwrap();
1085 assert!(status.is_some());
1086
1087 let state = status.unwrap();
1088 assert_eq!(state.total_hooks, 2);
1089 assert_eq!(state.directory_path, directory_path);
1090 }
1091
1092 #[tokio::test]
1093 async fn test_command_validation() {
1094 let executor = HookExecutor::with_default_config().unwrap();
1095
1096 let hook = Hook {
1101 order: 100,
1102 propagate: false,
1103 command: "echo".to_string(),
1104 args: vec!["test message".to_string()],
1105 dir: None,
1106 inputs: Vec::new(),
1107 source: Some(false),
1108 };
1109
1110 let result = executor.execute_single_hook(hook).await;
1111 assert!(result.is_ok(), "Echo command should succeed");
1112
1113 let hook_result = result.unwrap();
1115 assert!(hook_result.stdout.contains("test message"));
1116 }
1117
1118 #[tokio::test]
1119 #[ignore = "Needs investigation - async state management"]
1120 async fn test_cancellation() {
1121 let temp_dir = TempDir::new().unwrap();
1122 let config = HookExecutionConfig {
1123 default_timeout_seconds: 30,
1124 fail_fast: false,
1125 state_dir: Some(temp_dir.path().to_path_buf()),
1126 };
1127
1128 let executor = HookExecutor::new(config).unwrap();
1129 let directory_path = PathBuf::from("/test/cancel");
1130 let config_hash = "cancel_test".to_string();
1131
1132 let hooks = vec![Hook {
1134 order: 100,
1135 propagate: false,
1136 command: "sleep".to_string(),
1137 args: vec!["10".to_string()],
1138 dir: None,
1139 inputs: Vec::new(),
1140 source: Some(false),
1141 }];
1142
1143 executor
1144 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1145 .await
1146 .unwrap();
1147
1148 tokio::time::sleep(Duration::from_millis(100)).await;
1150
1151 let cancelled = executor
1153 .cancel_execution(
1154 &directory_path,
1155 &config_hash,
1156 Some("User cancelled".to_string()),
1157 )
1158 .await
1159 .unwrap();
1160 assert!(cancelled);
1161
1162 let state = executor
1164 .get_execution_status_for_instance(&directory_path, &config_hash)
1165 .await
1166 .unwrap()
1167 .unwrap();
1168 assert_eq!(state.status, ExecutionStatus::Cancelled);
1169 }
1170
1171 #[tokio::test]
1172 async fn test_large_output_handling() {
1173 let executor = HookExecutor::with_default_config().unwrap();
1174
1175 let large_content = "x".repeat(1000); let mut args = Vec::new();
1179 for i in 0..100 {
1181 args.push(format!("Line {}: {}", i, large_content));
1182 }
1183
1184 let hook = Hook {
1186 order: 100,
1187 propagate: false,
1188 command: "echo".to_string(),
1189 args,
1190 dir: None,
1191 inputs: Vec::new(),
1192 source: Some(false),
1193 };
1194
1195 let result = executor.execute_single_hook(hook).await.unwrap();
1196 assert!(result.success);
1197 assert!(result.stdout.len() > 50_000); }
1200
1201 #[tokio::test]
1202 #[ignore = "Needs investigation - async runtime issues"]
1203 async fn test_state_cleanup() {
1204 let temp_dir = TempDir::new().unwrap();
1205 let config = HookExecutionConfig {
1206 default_timeout_seconds: 30,
1207 fail_fast: false,
1208 state_dir: Some(temp_dir.path().to_path_buf()),
1209 };
1210
1211 let executor = HookExecutor::new(config).unwrap();
1212 let directory_path = PathBuf::from("/test/cleanup");
1213 let config_hash = "cleanup_test".to_string();
1214
1215 let hooks = vec![Hook {
1217 order: 100,
1218 propagate: false,
1219 command: "echo".to_string(),
1220 args: vec!["test".to_string()],
1221 dir: None,
1222 inputs: Vec::new(),
1223 source: Some(false),
1224 }];
1225
1226 executor
1227 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1228 .await
1229 .unwrap();
1230
1231 executor
1233 .wait_for_completion(&directory_path, &config_hash, Some(5))
1234 .await
1235 .unwrap();
1236
1237 let cleaned = executor
1239 .cleanup_old_states(chrono::Duration::seconds(0))
1240 .await
1241 .unwrap();
1242 assert_eq!(cleaned, 1);
1243
1244 let state = executor
1246 .get_execution_status_for_instance(&directory_path, &config_hash)
1247 .await
1248 .unwrap();
1249 assert!(state.is_none());
1250 }
1251
1252 #[tokio::test]
1253 async fn test_execution_state_tracking() {
1254 let temp_dir = TempDir::new().unwrap();
1255 let config = HookExecutionConfig {
1256 default_timeout_seconds: 30,
1257 fail_fast: true,
1258 state_dir: Some(temp_dir.path().to_path_buf()),
1259 };
1260
1261 let executor = HookExecutor::new(config).unwrap();
1262 let directory_path = PathBuf::from("/test/directory");
1263 let config_hash = "hash".to_string();
1264
1265 let status = executor
1267 .get_execution_status_for_instance(&directory_path, &config_hash)
1268 .await
1269 .unwrap();
1270 assert!(status.is_none());
1271
1272 let hooks = vec![Hook {
1274 order: 100,
1275 propagate: false,
1276 command: "echo".to_string(),
1277 args: vec!["test".to_string()],
1278 dir: None,
1279 inputs: Vec::new(),
1280 source: Some(false),
1281 }];
1282
1283 executor
1284 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1285 .await
1286 .unwrap();
1287
1288 let status = executor
1290 .get_execution_status_for_instance(&directory_path, &config_hash)
1291 .await
1292 .unwrap();
1293 assert!(status.is_some());
1294 }
1295
1296 #[tokio::test]
1366 #[ignore = "Needs investigation - timing issues"]
1367 async fn test_fail_fast_mode_edge_cases() {
1368 let temp_dir = TempDir::new().unwrap();
1369
1370 let config = HookExecutionConfig {
1372 default_timeout_seconds: 30,
1373 fail_fast: true,
1374 state_dir: Some(temp_dir.path().to_path_buf()),
1375 };
1376
1377 let executor = HookExecutor::new(config).unwrap();
1378 let directory_path = PathBuf::from("/test/fail-fast");
1379
1380 let hooks = vec![
1381 Hook {
1382 order: 100,
1383 propagate: false,
1384 command: "false".to_string(), args: vec![],
1386 dir: None,
1387 inputs: Vec::new(),
1388 source: Some(false),
1389 },
1390 Hook {
1391 order: 100,
1392 propagate: false,
1393 command: "echo".to_string(), args: vec!["should not run".to_string()],
1395 dir: None,
1396 inputs: Vec::new(),
1397 source: Some(false),
1398 },
1399 Hook {
1400 order: 100,
1401 propagate: false,
1402 command: "echo".to_string(), args: vec!["also should not run".to_string()],
1404 dir: None,
1405 inputs: Vec::new(),
1406 source: Some(false),
1407 },
1408 ];
1409
1410 let config_hash = "fail_fast_test".to_string();
1411 executor
1412 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1413 .await
1414 .unwrap();
1415
1416 executor
1418 .wait_for_completion(&directory_path, &config_hash, Some(10))
1419 .await
1420 .unwrap();
1421
1422 let state = executor
1423 .get_execution_status_for_instance(&directory_path, &config_hash)
1424 .await
1425 .unwrap()
1426 .unwrap();
1427
1428 assert_eq!(state.status, ExecutionStatus::Failed);
1429 assert_eq!(state.completed_hooks, 1);
1432
1433 let directory_path2 = PathBuf::from("/test/fail-fast-continue");
1435
1436 let hooks2 = vec![
1437 Hook {
1438 order: 100,
1439 propagate: false,
1440 command: "false".to_string(),
1441 args: vec![],
1442 dir: None,
1443 inputs: Vec::new(),
1444 source: Some(false),
1445 },
1446 Hook {
1447 order: 100,
1448 propagate: false,
1449 command: "echo".to_string(),
1450 args: vec!["this should run".to_string()],
1451 dir: None,
1452 inputs: Vec::new(),
1453 source: Some(false),
1454 },
1455 Hook {
1456 order: 100,
1457 propagate: false,
1458 command: "false".to_string(), args: vec![],
1460 dir: None,
1461 inputs: Vec::new(),
1462 source: Some(false),
1463 },
1464 Hook {
1465 order: 100,
1466 propagate: false,
1467 command: "echo".to_string(),
1468 args: vec!["this should not run".to_string()],
1469 dir: None,
1470 inputs: Vec::new(),
1471 source: Some(false),
1472 },
1473 ];
1474
1475 let config_hash2 = "fail_fast_continue_test".to_string();
1476 executor
1477 .execute_hooks_background(directory_path2.clone(), config_hash2.clone(), hooks2)
1478 .await
1479 .unwrap();
1480
1481 executor
1482 .wait_for_completion(&directory_path2, &config_hash2, Some(10))
1483 .await
1484 .unwrap();
1485
1486 let state2 = executor
1487 .get_execution_status_for_instance(&directory_path2, &config_hash2)
1488 .await
1489 .unwrap()
1490 .unwrap();
1491
1492 assert_eq!(state2.status, ExecutionStatus::Failed);
1493 assert_eq!(state2.completed_hooks, 3);
1496 }
1497
1498 #[tokio::test]
1499 async fn test_security_validation_comprehensive() {
1500 let executor = HookExecutor::with_default_config().unwrap();
1501
1502 let test_args = vec![
1507 vec!["simple test".to_string()],
1508 vec!["test with spaces".to_string()],
1509 ["test", "multiple", "args"]
1510 .iter()
1511 .map(|s| s.to_string())
1512 .collect(),
1513 ];
1514
1515 for args in test_args {
1516 let hook = Hook {
1517 order: 100,
1518 propagate: false,
1519 command: "echo".to_string(),
1520 args: args.clone(),
1521 dir: None,
1522 inputs: Vec::new(),
1523 source: Some(false),
1524 };
1525
1526 let result = executor.execute_single_hook(hook).await;
1527 assert!(
1528 result.is_ok(),
1529 "Echo command should work with args: {:?}",
1530 args
1531 );
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn test_working_directory_handling() {
1537 let executor = HookExecutor::with_default_config().unwrap();
1538 let temp_dir = TempDir::new().unwrap();
1539
1540 let hook_with_valid_dir = Hook {
1542 order: 100,
1543 propagate: false,
1544 command: "pwd".to_string(),
1545 args: vec![],
1546 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1547 inputs: vec![],
1548 source: None,
1549 };
1550
1551 let result = executor
1552 .execute_single_hook(hook_with_valid_dir)
1553 .await
1554 .unwrap();
1555 assert!(result.success);
1556 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1557
1558 let hook_with_invalid_dir = Hook {
1560 order: 100,
1561 propagate: false,
1562 command: "pwd".to_string(),
1563 args: vec![],
1564 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1565 inputs: vec![],
1566 source: None,
1567 };
1568
1569 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1570 if result.is_ok() {
1573 assert!(
1575 !result
1576 .unwrap()
1577 .stdout
1578 .contains("/nonexistent/directory/that/does/not/exist")
1579 );
1580 }
1581
1582 let hook_with_relative_dir = Hook {
1584 order: 100,
1585 propagate: false,
1586 command: "pwd".to_string(),
1587 args: vec![],
1588 dir: Some("./relative/path".to_string()),
1589 inputs: vec![],
1590 source: None,
1591 };
1592
1593 let _ = executor.execute_single_hook(hook_with_relative_dir).await;
1595 }
1596
1597 #[tokio::test]
1598 async fn test_hook_execution_with_complex_output() {
1599 let executor = HookExecutor::with_default_config().unwrap();
1600
1601 let hook = Hook {
1603 order: 100,
1604 propagate: false,
1605 command: "echo".to_string(),
1606 args: vec!["stdout output".to_string()],
1607 dir: None,
1608 inputs: vec![],
1609 source: None,
1610 };
1611
1612 let result = executor.execute_single_hook(hook).await.unwrap();
1613 assert!(result.success);
1614 assert!(result.stdout.contains("stdout output"));
1615
1616 let hook_with_exit_code = Hook {
1618 order: 100,
1619 propagate: false,
1620 command: "false".to_string(),
1621 args: vec![],
1622 dir: None,
1623 inputs: Vec::new(),
1624 source: Some(false),
1625 };
1626
1627 let result = executor
1628 .execute_single_hook(hook_with_exit_code)
1629 .await
1630 .unwrap();
1631 assert!(!result.success);
1632 assert!(result.exit_status.is_some());
1634 }
1635
1636 #[tokio::test]
1637 #[ignore = "Needs investigation - state management"]
1638 async fn test_multiple_directory_executions() {
1639 let temp_dir = TempDir::new().unwrap();
1640 let config = HookExecutionConfig {
1641 default_timeout_seconds: 30,
1642 fail_fast: false,
1643 state_dir: Some(temp_dir.path().to_path_buf()),
1644 };
1645
1646 let executor = HookExecutor::new(config).unwrap();
1647
1648 let directories = [
1650 PathBuf::from("/test/dir1"),
1651 PathBuf::from("/test/dir2"),
1652 PathBuf::from("/test/dir3"),
1653 ];
1654
1655 let mut config_hashes = Vec::new();
1656 for (i, dir) in directories.iter().enumerate() {
1657 let hooks = vec![Hook {
1658 order: 100,
1659 propagate: false,
1660 command: "echo".to_string(),
1661 args: vec![format!("directory {}", i)],
1662 dir: None,
1663 inputs: Vec::new(),
1664 source: Some(false),
1665 }];
1666
1667 let config_hash = format!("hash_{}", i);
1668 config_hashes.push(config_hash.clone());
1669 executor
1670 .execute_hooks_background(dir.clone(), config_hash.clone(), hooks)
1671 .await
1672 .unwrap();
1673 }
1674
1675 for (dir, config_hash) in directories.iter().zip(config_hashes.iter()) {
1677 executor
1678 .wait_for_completion(dir, config_hash, Some(10))
1679 .await
1680 .unwrap();
1681
1682 let state = executor
1683 .get_execution_status_for_instance(dir, config_hash)
1684 .await
1685 .unwrap()
1686 .unwrap();
1687
1688 assert_eq!(state.status, ExecutionStatus::Completed);
1689 assert_eq!(state.completed_hooks, 1);
1690 assert_eq!(state.total_hooks, 1);
1691 }
1692 }
1693
1694 #[tokio::test]
1695 #[ignore = "Needs investigation - retry logic"]
1696 async fn test_error_recovery_and_retry() {
1697 let temp_dir = TempDir::new().unwrap();
1698 let config = HookExecutionConfig {
1699 default_timeout_seconds: 30,
1700 fail_fast: false,
1701 state_dir: Some(temp_dir.path().to_path_buf()),
1702 };
1703
1704 let executor = HookExecutor::new(config).unwrap();
1705 let directory_path = PathBuf::from("/test/recovery");
1706
1707 let hooks = vec![
1709 Hook {
1710 order: 100,
1711 propagate: false,
1712 command: "echo".to_string(),
1713 args: vec!["success 1".to_string()],
1714 dir: None,
1715 inputs: Vec::new(),
1716 source: Some(false),
1717 },
1718 Hook {
1719 order: 100,
1720 propagate: false,
1721 command: "false".to_string(),
1722 args: vec![],
1723 dir: None,
1724 inputs: Vec::new(),
1725 source: Some(false),
1726 },
1727 Hook {
1728 order: 100,
1729 propagate: false,
1730 command: "echo".to_string(),
1731 args: vec!["success 2".to_string()],
1732 dir: None,
1733 inputs: Vec::new(),
1734 source: Some(false),
1735 },
1736 ];
1737
1738 let config_hash = "recovery_test".to_string();
1739 executor
1740 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1741 .await
1742 .unwrap();
1743
1744 executor
1745 .wait_for_completion(&directory_path, &config_hash, Some(10))
1746 .await
1747 .unwrap();
1748
1749 let state = executor
1750 .get_execution_status_for_instance(&directory_path, &config_hash)
1751 .await
1752 .unwrap()
1753 .unwrap();
1754
1755 assert_eq!(state.status, ExecutionStatus::Completed);
1757 assert_eq!(state.completed_hooks, 3);
1758 assert_eq!(state.total_hooks, 3);
1759 }
1760
1761 #[tokio::test]
1762 #[ignore = "Requires supervisor binary - integration test"]
1763 async fn test_instance_hash_separation() {
1764 let temp_dir = TempDir::new().unwrap();
1766 let config = HookExecutionConfig {
1767 default_timeout_seconds: 30,
1768 fail_fast: false,
1769 state_dir: Some(temp_dir.path().to_path_buf()),
1770 };
1771
1772 let executor = HookExecutor::new(config).unwrap();
1773 let directory_path = PathBuf::from("/test/multi-config");
1774
1775 let hooks1 = vec![Hook {
1777 order: 100,
1778 propagate: false,
1779 command: "echo".to_string(),
1780 args: vec!["config1".to_string()],
1781 dir: None,
1782 inputs: Vec::new(),
1783 source: Some(false),
1784 }];
1785
1786 let hooks2 = vec![Hook {
1788 order: 100,
1789 propagate: false,
1790 command: "echo".to_string(),
1791 args: vec!["config2".to_string()],
1792 dir: None,
1793 inputs: Vec::new(),
1794 source: Some(false),
1795 }];
1796
1797 let config_hash1 = "config_hash_1".to_string();
1798 let config_hash2 = "config_hash_2".to_string();
1799
1800 executor
1802 .execute_hooks_background(directory_path.clone(), config_hash1.clone(), hooks1)
1803 .await
1804 .unwrap();
1805
1806 executor
1807 .execute_hooks_background(directory_path.clone(), config_hash2.clone(), hooks2)
1808 .await
1809 .unwrap();
1810
1811 executor
1813 .wait_for_completion(&directory_path, &config_hash1, Some(5))
1814 .await
1815 .unwrap();
1816
1817 executor
1818 .wait_for_completion(&directory_path, &config_hash2, Some(5))
1819 .await
1820 .unwrap();
1821
1822 let state1 = executor
1824 .get_execution_status_for_instance(&directory_path, &config_hash1)
1825 .await
1826 .unwrap()
1827 .unwrap();
1828
1829 let state2 = executor
1830 .get_execution_status_for_instance(&directory_path, &config_hash2)
1831 .await
1832 .unwrap()
1833 .unwrap();
1834
1835 assert_eq!(state1.status, ExecutionStatus::Completed);
1836 assert_eq!(state2.status, ExecutionStatus::Completed);
1837
1838 assert_ne!(state1.instance_hash, state2.instance_hash);
1840 }
1841
1842 #[tokio::test]
1843 #[ignore = "Requires supervisor binary - integration test"]
1844 async fn test_file_based_argument_passing() {
1845 let temp_dir = TempDir::new().unwrap();
1847 let config = HookExecutionConfig {
1848 default_timeout_seconds: 30,
1849 fail_fast: false,
1850 state_dir: Some(temp_dir.path().to_path_buf()),
1851 };
1852
1853 let executor = HookExecutor::new(config).unwrap();
1854 let directory_path = PathBuf::from("/test/file-args");
1855 let config_hash = "file_test".to_string();
1856
1857 let mut large_hooks = Vec::new();
1859 for i in 0..100 {
1860 large_hooks.push(Hook { order: 100, propagate: false,
1861 command: "echo".to_string(),
1862 args: vec![format!("This is a very long argument string number {} with lots of text to ensure we test the file-based argument passing mechanism properly", i)],
1863 dir: None,
1864 inputs: Vec::new(),
1865 source: Some(false),
1866 });
1867 }
1868
1869 let result = executor
1871 .execute_hooks_background(directory_path.clone(), config_hash.clone(), large_hooks)
1872 .await;
1873
1874 assert!(result.is_ok(), "Should handle large hook configurations");
1875
1876 tokio::time::sleep(Duration::from_millis(500)).await;
1878
1879 executor
1881 .cancel_execution(
1882 &directory_path,
1883 &config_hash,
1884 Some("Test cleanup".to_string()),
1885 )
1886 .await
1887 .ok();
1888 }
1889
1890 #[tokio::test]
1891 async fn test_state_dir_getter() {
1892 use crate::hooks::state::StateManager;
1893
1894 let temp_dir = TempDir::new().unwrap();
1895 let state_dir = temp_dir.path().to_path_buf();
1896 let state_manager = StateManager::new(state_dir.clone());
1897
1898 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1899 }
1900}