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