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 total_hooks,
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 #[cfg(unix)]
202 {
203 use std::os::unix::process::CommandExt;
204 unsafe {
206 cmd.pre_exec(|| {
207 if libc::setsid() == -1 {
209 return Err(std::io::Error::last_os_error());
210 }
211 Ok(())
212 });
213 }
214 }
215
216 #[cfg(windows)]
217 {
218 use std::os::windows::process::CommandExt;
219 const DETACHED_PROCESS: u32 = 0x00000008;
221 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
222 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
223 }
224
225 let _child = cmd
226 .spawn()
227 .map_err(|e| Error::configuration(format!("Failed to spawn supervisor: {}", e)))?;
228
229 info!("Spawned supervisor process for hook execution");
232
233 Ok(format!(
234 "Started execution of {} hooks in background",
235 total_hooks
236 ))
237 }
238
239 pub async fn get_execution_status(
241 &self,
242 directory_path: &Path,
243 ) -> Result<Option<HookExecutionState>> {
244 let states = self.state_manager.list_active_states().await?;
246 for state in states {
247 if state.directory_path == directory_path {
248 return Ok(Some(state));
249 }
250 }
251 Ok(None)
252 }
253
254 pub async fn get_execution_status_for_instance(
256 &self,
257 directory_path: &Path,
258 config_hash: &str,
259 ) -> Result<Option<HookExecutionState>> {
260 let instance_hash = compute_instance_hash(directory_path, config_hash);
261 self.state_manager.load_state(&instance_hash).await
262 }
263
264 pub async fn wait_for_completion(
266 &self,
267 directory_path: &Path,
268 config_hash: &str,
269 timeout_seconds: Option<u64>,
270 ) -> Result<HookExecutionState> {
271 let instance_hash = compute_instance_hash(directory_path, config_hash);
272 let poll_interval = Duration::from_millis(500);
273 let start_time = Instant::now();
274
275 loop {
276 if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
277 if state.is_complete() {
278 return Ok(state);
279 }
280 } else {
281 return Err(Error::configuration("No execution state found"));
282 }
283
284 if let Some(timeout) = timeout_seconds
286 && start_time.elapsed().as_secs() >= timeout
287 {
288 return Err(Error::Timeout { seconds: timeout });
289 }
290
291 tokio::time::sleep(poll_interval).await;
292 }
293 }
294
295 pub async fn cancel_execution(
297 &self,
298 directory_path: &Path,
299 config_hash: &str,
300 reason: Option<String>,
301 ) -> Result<bool> {
302 let instance_hash = compute_instance_hash(directory_path, config_hash);
303
304 let pid_file = self
306 .state_manager
307 .get_state_file_path(&instance_hash)
308 .with_extension("pid");
309
310 if pid_file.exists()
311 && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
312 && let Ok(pid) = pid_str.trim().parse::<usize>()
313 {
314 use sysinfo::{Pid, ProcessRefreshKind, Signal, System};
315
316 let mut system = System::new();
317 let process_pid = Pid::from(pid);
318
319 system.refresh_process_specifics(process_pid, ProcessRefreshKind::new());
321
322 if let Some(process) = system.process(process_pid) {
324 if process.kill_with(Signal::Term).is_some() {
325 info!("Sent SIGTERM to supervisor process PID {}", pid);
326 } else {
327 warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
328 }
329 } else {
330 info!(
331 "Supervisor process PID {} not found (may have already exited)",
332 pid
333 );
334 }
335
336 std::fs::remove_file(&pid_file).ok();
338 }
339
340 if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
342 && !state.is_complete()
343 {
344 state.mark_cancelled(reason);
345 self.state_manager.save_state(&state).await?;
346 info!(
347 "Cancelled execution for directory: {}",
348 directory_path.display()
349 );
350 return Ok(true);
351 }
352
353 Ok(false)
354 }
355
356 pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
358 let states = self.state_manager.list_active_states().await?;
359 let cutoff = chrono::Utc::now() - older_than;
360 let mut cleaned_count = 0;
361
362 for state in states {
363 if state.is_complete()
364 && let Some(finished_at) = state.finished_at
365 && finished_at < cutoff
366 {
367 self.state_manager
368 .remove_state(&state.instance_hash)
369 .await?;
370 cleaned_count += 1;
371 }
372 }
373
374 if cleaned_count > 0 {
375 info!("Cleaned up {} old execution states", cleaned_count);
376 }
377
378 Ok(cleaned_count)
379 }
380
381 pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
383 let timeout = self.config.default_timeout_seconds;
385
386 execute_hook_with_timeout(hook, &timeout).await
388 }
389}
390
391pub async fn execute_hooks(
393 hooks: Vec<Hook>,
394 _directory_path: &Path,
395 config: &HookExecutionConfig,
396 state_manager: &StateManager,
397 state: &mut HookExecutionState,
398) -> Result<()> {
399 let hook_count = hooks.len();
400 debug!("execute_hooks called with {} hooks", hook_count);
401 if hook_count == 0 {
402 debug!("No hooks to execute");
403 return Ok(());
404 }
405 debug!("Starting to iterate over {} hooks", hook_count);
406 for (index, hook) in hooks.into_iter().enumerate() {
407 debug!(
408 "Processing hook {}/{}: command={}",
409 index + 1,
410 state.total_hooks,
411 hook.command
412 );
413 debug!("Checking if execution was cancelled");
415 if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
416 debug!("Loaded state: status = {:?}", current_state.status);
417 if current_state.status == ExecutionStatus::Cancelled {
418 debug!("Execution was cancelled, stopping");
419 break;
420 }
421 }
422
423 let timeout_seconds = config.default_timeout_seconds;
426
427 state.mark_hook_running(index);
429
430 let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
432
433 match result {
435 Ok(hook_result) => {
436 if hook.source.unwrap_or(false)
438 && hook_result.success
439 && !hook_result.stdout.is_empty()
440 {
441 debug!("Evaluating source hook output for environment variables");
442 match evaluate_shell_environment(&hook_result.stdout).await {
443 Ok(env_vars) => {
444 debug!(
445 "Captured {} environment variables from source hook",
446 env_vars.len()
447 );
448 for (key, value) in env_vars {
450 state.environment_vars.insert(key, value);
451 }
452 }
453 Err(e) => {
454 warn!("Failed to evaluate source hook output: {}", e);
455 }
457 }
458 }
459
460 state.record_hook_result(index, hook_result.clone());
461 if !hook_result.success && config.fail_fast {
462 warn!(
463 "Hook {} failed and fail_fast is enabled, stopping",
464 index + 1
465 );
466 break;
467 }
468 }
469 Err(e) => {
470 let error_msg = format!("Hook execution error: {}", e);
471 state.record_hook_result(
472 index,
473 HookResult::failure(
474 hook.clone(),
475 None,
476 String::new(),
477 error_msg.clone(),
478 0,
479 error_msg,
480 ),
481 );
482 if config.fail_fast {
483 warn!("Hook {} failed with error, stopping", index + 1);
484 break;
485 }
486 }
487 }
488
489 state_manager.save_state(state).await?;
491 }
492
493 if state.status == ExecutionStatus::Running {
495 state.status = ExecutionStatus::Completed;
496 state.finished_at = Some(chrono::Utc::now());
497 info!(
498 "All hooks completed successfully for directory: {}",
499 state.directory_path.display()
500 );
501 }
502
503 state_manager.save_state(state).await?;
505
506 Ok(())
507}
508
509fn detect_shell() -> &'static str {
511 if std::process::Command::new("bash")
513 .arg("--version")
514 .output()
515 .is_ok()
516 {
517 return "bash";
518 }
519
520 "sh"
522}
523
524async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
526 debug!(
527 "Evaluating shell script to extract environment ({} bytes)",
528 shell_script.len()
529 );
530
531 let shell = detect_shell();
532 debug!("Using shell: {}", shell);
533
534 let mut cmd_before = Command::new(shell);
536 cmd_before.arg("-c");
537 cmd_before.arg("env -0");
538 cmd_before.stdout(Stdio::piped());
539 cmd_before.stderr(Stdio::piped());
540
541 let output_before = cmd_before
542 .output()
543 .await
544 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
545
546 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
547 let mut env_before = HashMap::new();
548 for line in env_before_output.split('\0') {
549 if let Some((key, value)) = line.split_once('=') {
550 env_before.insert(key.to_string(), value.to_string());
551 }
552 }
553
554 let mut cmd = Command::new(shell);
556 cmd.arg("-c");
557 let script = format!("{}\nenv -0", shell_script);
559 cmd.arg(script);
560 cmd.stdout(Stdio::piped());
561 cmd.stderr(Stdio::piped());
562
563 let output = cmd.output().await.map_err(|e| {
564 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
565 })?;
566
567 if !output.status.success() {
568 let stderr = String::from_utf8_lossy(&output.stderr);
569 return Err(Error::configuration(format!(
570 "Shell script evaluation failed: {}",
571 stderr
572 )));
573 }
574
575 let env_output = String::from_utf8_lossy(&output.stdout);
577 let mut env_delta = HashMap::new();
578
579 for line in env_output.split('\0') {
580 if line.is_empty() {
581 continue;
582 }
583
584 if let Some((key, value)) = line.split_once('=') {
585 if key.starts_with("BASH_FUNC_")
587 || key == "PS1"
588 || key == "PS2"
589 || key == "_"
590 || key == "PWD"
591 || key == "OLDPWD"
592 || key == "SHLVL"
593 || key.starts_with("BASH")
594 {
595 continue;
596 }
597
598 if env_before.get(key) != Some(&value.to_string()) {
600 env_delta.insert(key.to_string(), value.to_string());
601 }
602 }
603 }
604
605 debug!(
606 "Evaluated shell script and extracted {} new/changed environment variables",
607 env_delta.len()
608 );
609 Ok(env_delta)
610}
611
612async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
614 let start_time = Instant::now();
615
616 debug!(
617 "Executing hook: {} {} (source: {})",
618 hook.command,
619 hook.args.join(" "),
620 hook.source.unwrap_or(false)
621 );
622
623 let mut cmd = Command::new(&hook.command);
625 cmd.args(&hook.args);
626 cmd.stdout(Stdio::piped());
627 cmd.stderr(Stdio::piped());
628
629 if let Some(dir) = &hook.dir {
631 cmd.current_dir(dir);
632 }
633
634 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
636
637 let duration_ms = start_time.elapsed().as_millis() as u64;
638
639 match execution_result {
640 Ok(Ok(output)) => {
641 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
642 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
643
644 if output.status.success() {
645 debug!("Hook completed successfully in {}ms", duration_ms);
646 Ok(HookResult::success(
647 hook,
648 output.status,
649 stdout,
650 stderr,
651 duration_ms,
652 ))
653 } else {
654 warn!("Hook failed with exit code: {:?}", output.status.code());
655 Ok(HookResult::failure(
656 hook,
657 Some(output.status),
658 stdout,
659 stderr,
660 duration_ms,
661 format!("Command exited with status: {}", output.status),
662 ))
663 }
664 }
665 Ok(Err(io_error)) => {
666 error!("Failed to execute hook: {}", io_error);
667 Ok(HookResult::failure(
668 hook,
669 None,
670 String::new(),
671 String::new(),
672 duration_ms,
673 format!("Failed to execute command: {}", io_error),
674 ))
675 }
676 Err(_timeout_error) => {
677 warn!("Hook timed out after {} seconds", timeout_seconds);
678 Ok(HookResult::timeout(
679 hook,
680 String::new(),
681 String::new(),
682 *timeout_seconds,
683 ))
684 }
685 }
686}
687
688#[cfg(test)]
689mod tests {
690 use super::*;
691 use crate::hooks::types::Hook;
692 use tempfile::TempDir;
693
694 #[tokio::test]
695 async fn test_hook_executor_creation() {
696 let temp_dir = TempDir::new().unwrap();
697 let config = HookExecutionConfig {
698 default_timeout_seconds: 60,
699 fail_fast: true,
700 state_dir: Some(temp_dir.path().to_path_buf()),
701 };
702
703 let executor = HookExecutor::new(config).unwrap();
704 assert_eq!(executor.config.default_timeout_seconds, 60);
705 }
706
707 #[tokio::test]
708 async fn test_execute_single_hook_success() {
709 let executor = HookExecutor::with_default_config().unwrap();
710
711 let hook = Hook {
712 command: "echo".to_string(),
713 args: vec!["hello".to_string()],
714 dir: None,
715 inputs: vec![],
716 source: None,
717 };
718
719 let result = executor.execute_single_hook(hook).await.unwrap();
720 assert!(result.success);
721 assert!(result.stdout.contains("hello"));
722 }
723
724 #[tokio::test]
725 async fn test_execute_single_hook_failure() {
726 let executor = HookExecutor::with_default_config().unwrap();
727
728 let hook = Hook {
729 command: "false".to_string(), args: vec![],
731 dir: None,
732 inputs: Vec::new(),
733 source: Some(false),
734 };
735
736 let result = executor.execute_single_hook(hook).await.unwrap();
737 assert!(!result.success);
738 assert!(result.exit_status.is_some());
739 assert_ne!(result.exit_status.unwrap(), 0);
740 }
741
742 #[tokio::test]
743 async fn test_execute_single_hook_timeout() {
744 let temp_dir = TempDir::new().unwrap();
745 let config = HookExecutionConfig {
746 default_timeout_seconds: 1, fail_fast: true,
748 state_dir: Some(temp_dir.path().to_path_buf()),
749 };
750 let executor = HookExecutor::new(config).unwrap();
751
752 let hook = Hook {
753 command: "sleep".to_string(),
754 args: vec!["10".to_string()], dir: None,
756 inputs: Vec::new(),
757 source: Some(false),
758 };
759
760 let result = executor.execute_single_hook(hook).await.unwrap();
761 assert!(!result.success);
762 assert!(result.error.as_ref().unwrap().contains("timed out"));
763 }
764
765 #[tokio::test]
766 async fn test_background_execution() {
767 let temp_dir = TempDir::new().unwrap();
768 let config = HookExecutionConfig {
769 default_timeout_seconds: 30,
770 fail_fast: true,
771 state_dir: Some(temp_dir.path().to_path_buf()),
772 };
773
774 let executor = HookExecutor::new(config).unwrap();
775 let directory_path = PathBuf::from("/test/directory");
776 let config_hash = "test_hash".to_string();
777
778 let hooks = vec![
779 Hook {
780 command: "echo".to_string(),
781 args: vec!["hook1".to_string()],
782 dir: None,
783 inputs: Vec::new(),
784 source: Some(false),
785 },
786 Hook {
787 command: "echo".to_string(),
788 args: vec!["hook2".to_string()],
789 dir: None,
790 inputs: Vec::new(),
791 source: Some(false),
792 },
793 ];
794
795 let result = executor
796 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
797 .await
798 .unwrap();
799
800 assert!(result.contains("Started execution of 2 hooks"));
801
802 tokio::time::sleep(Duration::from_millis(100)).await;
804
805 let status = executor
807 .get_execution_status_for_instance(&directory_path, &config_hash)
808 .await
809 .unwrap();
810 assert!(status.is_some());
811
812 let state = status.unwrap();
813 assert_eq!(state.total_hooks, 2);
814 assert_eq!(state.directory_path, directory_path);
815 }
816
817 #[tokio::test]
818 async fn test_command_validation() {
819 let executor = HookExecutor::with_default_config().unwrap();
820
821 let hook = Hook {
826 command: "echo".to_string(),
827 args: vec!["test message".to_string()],
828 dir: None,
829 inputs: Vec::new(),
830 source: Some(false),
831 };
832
833 let result = executor.execute_single_hook(hook).await;
834 assert!(result.is_ok(), "Echo command should succeed");
835
836 let hook_result = result.unwrap();
838 assert!(hook_result.stdout.contains("test message"));
839 }
840
841 #[tokio::test]
842 #[ignore = "Needs investigation - async state management"]
843 async fn test_cancellation() {
844 let temp_dir = TempDir::new().unwrap();
845 let config = HookExecutionConfig {
846 default_timeout_seconds: 30,
847 fail_fast: false,
848 state_dir: Some(temp_dir.path().to_path_buf()),
849 };
850
851 let executor = HookExecutor::new(config).unwrap();
852 let directory_path = PathBuf::from("/test/cancel");
853 let config_hash = "cancel_test".to_string();
854
855 let hooks = vec![Hook {
857 command: "sleep".to_string(),
858 args: vec!["10".to_string()],
859 dir: None,
860 inputs: Vec::new(),
861 source: Some(false),
862 }];
863
864 executor
865 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
866 .await
867 .unwrap();
868
869 tokio::time::sleep(Duration::from_millis(100)).await;
871
872 let cancelled = executor
874 .cancel_execution(
875 &directory_path,
876 &config_hash,
877 Some("User cancelled".to_string()),
878 )
879 .await
880 .unwrap();
881 assert!(cancelled);
882
883 let state = executor
885 .get_execution_status_for_instance(&directory_path, &config_hash)
886 .await
887 .unwrap()
888 .unwrap();
889 assert_eq!(state.status, ExecutionStatus::Cancelled);
890 }
891
892 #[tokio::test]
893 async fn test_large_output_handling() {
894 let executor = HookExecutor::with_default_config().unwrap();
895
896 let large_content = "x".repeat(1000); let mut args = Vec::new();
900 for i in 0..100 {
902 args.push(format!("Line {}: {}", i, large_content));
903 }
904
905 let hook = Hook {
907 command: "echo".to_string(),
908 args,
909 dir: None,
910 inputs: Vec::new(),
911 source: Some(false),
912 };
913
914 let result = executor.execute_single_hook(hook).await.unwrap();
915 assert!(result.success);
916 assert!(result.stdout.len() > 50_000); }
919
920 #[tokio::test]
921 #[ignore = "Needs investigation - async runtime issues"]
922 async fn test_state_cleanup() {
923 let temp_dir = TempDir::new().unwrap();
924 let config = HookExecutionConfig {
925 default_timeout_seconds: 30,
926 fail_fast: false,
927 state_dir: Some(temp_dir.path().to_path_buf()),
928 };
929
930 let executor = HookExecutor::new(config).unwrap();
931 let directory_path = PathBuf::from("/test/cleanup");
932 let config_hash = "cleanup_test".to_string();
933
934 let hooks = vec![Hook {
936 command: "echo".to_string(),
937 args: vec!["test".to_string()],
938 dir: None,
939 inputs: Vec::new(),
940 source: Some(false),
941 }];
942
943 executor
944 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
945 .await
946 .unwrap();
947
948 executor
950 .wait_for_completion(&directory_path, &config_hash, Some(5))
951 .await
952 .unwrap();
953
954 let cleaned = executor
956 .cleanup_old_states(chrono::Duration::seconds(0))
957 .await
958 .unwrap();
959 assert_eq!(cleaned, 1);
960
961 let state = executor
963 .get_execution_status_for_instance(&directory_path, &config_hash)
964 .await
965 .unwrap();
966 assert!(state.is_none());
967 }
968
969 #[tokio::test]
970 async fn test_execution_state_tracking() {
971 let temp_dir = TempDir::new().unwrap();
972 let config = HookExecutionConfig {
973 default_timeout_seconds: 30,
974 fail_fast: true,
975 state_dir: Some(temp_dir.path().to_path_buf()),
976 };
977
978 let executor = HookExecutor::new(config).unwrap();
979 let directory_path = PathBuf::from("/test/directory");
980 let config_hash = "hash".to_string();
981
982 let status = executor
984 .get_execution_status_for_instance(&directory_path, &config_hash)
985 .await
986 .unwrap();
987 assert!(status.is_none());
988
989 let hooks = vec![Hook {
991 command: "echo".to_string(),
992 args: vec!["test".to_string()],
993 dir: None,
994 inputs: Vec::new(),
995 source: Some(false),
996 }];
997
998 executor
999 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1000 .await
1001 .unwrap();
1002
1003 let status = executor
1005 .get_execution_status_for_instance(&directory_path, &config_hash)
1006 .await
1007 .unwrap();
1008 assert!(status.is_some());
1009 }
1010
1011 #[tokio::test]
1081 #[ignore = "Needs investigation - timing issues"]
1082 async fn test_fail_fast_mode_edge_cases() {
1083 let temp_dir = TempDir::new().unwrap();
1084
1085 let config = HookExecutionConfig {
1087 default_timeout_seconds: 30,
1088 fail_fast: true,
1089 state_dir: Some(temp_dir.path().to_path_buf()),
1090 };
1091
1092 let executor = HookExecutor::new(config).unwrap();
1093 let directory_path = PathBuf::from("/test/fail-fast");
1094
1095 let hooks = vec![
1096 Hook {
1097 command: "false".to_string(), args: vec![],
1099 dir: None,
1100 inputs: Vec::new(),
1101 source: Some(false),
1102 },
1103 Hook {
1104 command: "echo".to_string(), args: vec!["should not run".to_string()],
1106 dir: None,
1107 inputs: Vec::new(),
1108 source: Some(false),
1109 },
1110 Hook {
1111 command: "echo".to_string(), args: vec!["also should not run".to_string()],
1113 dir: None,
1114 inputs: Vec::new(),
1115 source: Some(false),
1116 },
1117 ];
1118
1119 let config_hash = "fail_fast_test".to_string();
1120 executor
1121 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1122 .await
1123 .unwrap();
1124
1125 executor
1127 .wait_for_completion(&directory_path, &config_hash, Some(10))
1128 .await
1129 .unwrap();
1130
1131 let state = executor
1132 .get_execution_status_for_instance(&directory_path, &config_hash)
1133 .await
1134 .unwrap()
1135 .unwrap();
1136
1137 assert_eq!(state.status, ExecutionStatus::Failed);
1138 assert_eq!(state.completed_hooks, 1);
1141
1142 let directory_path2 = PathBuf::from("/test/fail-fast-continue");
1144
1145 let hooks2 = vec![
1146 Hook {
1147 command: "false".to_string(),
1148 args: vec![],
1149 dir: None,
1150 inputs: Vec::new(),
1151 source: Some(false),
1152 },
1153 Hook {
1154 command: "echo".to_string(),
1155 args: vec!["this should run".to_string()],
1156 dir: None,
1157 inputs: Vec::new(),
1158 source: Some(false),
1159 },
1160 Hook {
1161 command: "false".to_string(), args: vec![],
1163 dir: None,
1164 inputs: Vec::new(),
1165 source: Some(false),
1166 },
1167 Hook {
1168 command: "echo".to_string(),
1169 args: vec!["this should not run".to_string()],
1170 dir: None,
1171 inputs: Vec::new(),
1172 source: Some(false),
1173 },
1174 ];
1175
1176 let config_hash2 = "fail_fast_continue_test".to_string();
1177 executor
1178 .execute_hooks_background(directory_path2.clone(), config_hash2.clone(), hooks2)
1179 .await
1180 .unwrap();
1181
1182 executor
1183 .wait_for_completion(&directory_path2, &config_hash2, Some(10))
1184 .await
1185 .unwrap();
1186
1187 let state2 = executor
1188 .get_execution_status_for_instance(&directory_path2, &config_hash2)
1189 .await
1190 .unwrap()
1191 .unwrap();
1192
1193 assert_eq!(state2.status, ExecutionStatus::Failed);
1194 assert_eq!(state2.completed_hooks, 3);
1197 }
1198
1199 #[tokio::test]
1200 async fn test_security_validation_comprehensive() {
1201 let executor = HookExecutor::with_default_config().unwrap();
1202
1203 let test_args = vec![
1208 vec!["simple test".to_string()],
1209 vec!["test with spaces".to_string()],
1210 ["test", "multiple", "args"]
1211 .iter()
1212 .map(|s| s.to_string())
1213 .collect(),
1214 ];
1215
1216 for args in test_args {
1217 let hook = Hook {
1218 command: "echo".to_string(),
1219 args: args.clone(),
1220 dir: None,
1221 inputs: Vec::new(),
1222 source: Some(false),
1223 };
1224
1225 let result = executor.execute_single_hook(hook).await;
1226 assert!(
1227 result.is_ok(),
1228 "Echo command should work with args: {:?}",
1229 args
1230 );
1231 }
1232 }
1233
1234 #[tokio::test]
1235 async fn test_working_directory_handling() {
1236 let executor = HookExecutor::with_default_config().unwrap();
1237 let temp_dir = TempDir::new().unwrap();
1238
1239 let hook_with_valid_dir = Hook {
1241 command: "pwd".to_string(),
1242 args: vec![],
1243 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1244 inputs: vec![],
1245 source: None,
1246 };
1247
1248 let result = executor
1249 .execute_single_hook(hook_with_valid_dir)
1250 .await
1251 .unwrap();
1252 assert!(result.success);
1253 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1254
1255 let hook_with_invalid_dir = Hook {
1257 command: "pwd".to_string(),
1258 args: vec![],
1259 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1260 inputs: vec![],
1261 source: None,
1262 };
1263
1264 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1265 if result.is_ok() {
1268 assert!(
1270 !result
1271 .unwrap()
1272 .stdout
1273 .contains("/nonexistent/directory/that/does/not/exist")
1274 );
1275 }
1276
1277 let hook_with_relative_dir = Hook {
1279 command: "pwd".to_string(),
1280 args: vec![],
1281 dir: Some("./relative/path".to_string()),
1282 inputs: vec![],
1283 source: None,
1284 };
1285
1286 let _ = executor.execute_single_hook(hook_with_relative_dir).await;
1288 }
1289
1290 #[tokio::test]
1291 async fn test_hook_execution_with_complex_output() {
1292 let executor = HookExecutor::with_default_config().unwrap();
1293
1294 let hook = Hook {
1296 command: "echo".to_string(),
1297 args: vec!["stdout output".to_string()],
1298 dir: None,
1299 inputs: vec![],
1300 source: None,
1301 };
1302
1303 let result = executor.execute_single_hook(hook).await.unwrap();
1304 assert!(result.success);
1305 assert!(result.stdout.contains("stdout output"));
1306
1307 let hook_with_exit_code = Hook {
1309 command: "false".to_string(),
1310 args: vec![],
1311 dir: None,
1312 inputs: Vec::new(),
1313 source: Some(false),
1314 };
1315
1316 let result = executor
1317 .execute_single_hook(hook_with_exit_code)
1318 .await
1319 .unwrap();
1320 assert!(!result.success);
1321 assert!(result.exit_status.is_some());
1323 }
1324
1325 #[tokio::test]
1326 #[ignore = "Needs investigation - state management"]
1327 async fn test_multiple_directory_executions() {
1328 let temp_dir = TempDir::new().unwrap();
1329 let config = HookExecutionConfig {
1330 default_timeout_seconds: 30,
1331 fail_fast: false,
1332 state_dir: Some(temp_dir.path().to_path_buf()),
1333 };
1334
1335 let executor = HookExecutor::new(config).unwrap();
1336
1337 let directories = [
1339 PathBuf::from("/test/dir1"),
1340 PathBuf::from("/test/dir2"),
1341 PathBuf::from("/test/dir3"),
1342 ];
1343
1344 let mut config_hashes = Vec::new();
1345 for (i, dir) in directories.iter().enumerate() {
1346 let hooks = vec![Hook {
1347 command: "echo".to_string(),
1348 args: vec![format!("directory {}", i)],
1349 dir: None,
1350 inputs: Vec::new(),
1351 source: Some(false),
1352 }];
1353
1354 let config_hash = format!("hash_{}", i);
1355 config_hashes.push(config_hash.clone());
1356 executor
1357 .execute_hooks_background(dir.clone(), config_hash.clone(), hooks)
1358 .await
1359 .unwrap();
1360 }
1361
1362 for (dir, config_hash) in directories.iter().zip(config_hashes.iter()) {
1364 executor
1365 .wait_for_completion(dir, config_hash, Some(10))
1366 .await
1367 .unwrap();
1368
1369 let state = executor
1370 .get_execution_status_for_instance(dir, config_hash)
1371 .await
1372 .unwrap()
1373 .unwrap();
1374
1375 assert_eq!(state.status, ExecutionStatus::Completed);
1376 assert_eq!(state.completed_hooks, 1);
1377 assert_eq!(state.total_hooks, 1);
1378 }
1379 }
1380
1381 #[tokio::test]
1382 #[ignore = "Needs investigation - retry logic"]
1383 async fn test_error_recovery_and_retry() {
1384 let temp_dir = TempDir::new().unwrap();
1385 let config = HookExecutionConfig {
1386 default_timeout_seconds: 30,
1387 fail_fast: false,
1388 state_dir: Some(temp_dir.path().to_path_buf()),
1389 };
1390
1391 let executor = HookExecutor::new(config).unwrap();
1392 let directory_path = PathBuf::from("/test/recovery");
1393
1394 let hooks = vec![
1396 Hook {
1397 command: "echo".to_string(),
1398 args: vec!["success 1".to_string()],
1399 dir: None,
1400 inputs: Vec::new(),
1401 source: Some(false),
1402 },
1403 Hook {
1404 command: "false".to_string(),
1405 args: vec![],
1406 dir: None,
1407 inputs: Vec::new(),
1408 source: Some(false),
1409 },
1410 Hook {
1411 command: "echo".to_string(),
1412 args: vec!["success 2".to_string()],
1413 dir: None,
1414 inputs: Vec::new(),
1415 source: Some(false),
1416 },
1417 ];
1418
1419 let config_hash = "recovery_test".to_string();
1420 executor
1421 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1422 .await
1423 .unwrap();
1424
1425 executor
1426 .wait_for_completion(&directory_path, &config_hash, Some(10))
1427 .await
1428 .unwrap();
1429
1430 let state = executor
1431 .get_execution_status_for_instance(&directory_path, &config_hash)
1432 .await
1433 .unwrap()
1434 .unwrap();
1435
1436 assert_eq!(state.status, ExecutionStatus::Completed);
1438 assert_eq!(state.completed_hooks, 3);
1439 assert_eq!(state.total_hooks, 3);
1440 }
1441
1442 #[tokio::test]
1443 #[ignore = "Requires supervisor binary - integration test"]
1444 async fn test_instance_hash_separation() {
1445 let temp_dir = TempDir::new().unwrap();
1447 let config = HookExecutionConfig {
1448 default_timeout_seconds: 30,
1449 fail_fast: false,
1450 state_dir: Some(temp_dir.path().to_path_buf()),
1451 };
1452
1453 let executor = HookExecutor::new(config).unwrap();
1454 let directory_path = PathBuf::from("/test/multi-config");
1455
1456 let hooks1 = vec![Hook {
1458 command: "echo".to_string(),
1459 args: vec!["config1".to_string()],
1460 dir: None,
1461 inputs: Vec::new(),
1462 source: Some(false),
1463 }];
1464
1465 let hooks2 = vec![Hook {
1467 command: "echo".to_string(),
1468 args: vec!["config2".to_string()],
1469 dir: None,
1470 inputs: Vec::new(),
1471 source: Some(false),
1472 }];
1473
1474 let config_hash1 = "config_hash_1".to_string();
1475 let config_hash2 = "config_hash_2".to_string();
1476
1477 executor
1479 .execute_hooks_background(directory_path.clone(), config_hash1.clone(), hooks1)
1480 .await
1481 .unwrap();
1482
1483 executor
1484 .execute_hooks_background(directory_path.clone(), config_hash2.clone(), hooks2)
1485 .await
1486 .unwrap();
1487
1488 executor
1490 .wait_for_completion(&directory_path, &config_hash1, Some(5))
1491 .await
1492 .unwrap();
1493
1494 executor
1495 .wait_for_completion(&directory_path, &config_hash2, Some(5))
1496 .await
1497 .unwrap();
1498
1499 let state1 = executor
1501 .get_execution_status_for_instance(&directory_path, &config_hash1)
1502 .await
1503 .unwrap()
1504 .unwrap();
1505
1506 let state2 = executor
1507 .get_execution_status_for_instance(&directory_path, &config_hash2)
1508 .await
1509 .unwrap()
1510 .unwrap();
1511
1512 assert_eq!(state1.status, ExecutionStatus::Completed);
1513 assert_eq!(state2.status, ExecutionStatus::Completed);
1514
1515 assert_ne!(state1.instance_hash, state2.instance_hash);
1517 }
1518
1519 #[tokio::test]
1520 #[ignore = "Requires supervisor binary - integration test"]
1521 async fn test_file_based_argument_passing() {
1522 let temp_dir = TempDir::new().unwrap();
1524 let config = HookExecutionConfig {
1525 default_timeout_seconds: 30,
1526 fail_fast: false,
1527 state_dir: Some(temp_dir.path().to_path_buf()),
1528 };
1529
1530 let executor = HookExecutor::new(config).unwrap();
1531 let directory_path = PathBuf::from("/test/file-args");
1532 let config_hash = "file_test".to_string();
1533
1534 let mut large_hooks = Vec::new();
1536 for i in 0..100 {
1537 large_hooks.push(Hook {
1538 command: "echo".to_string(),
1539 args: vec![format!("This is a very long argument string number {} with lots of text to ensure we test the file-based argument passing mechanism properly", i)],
1540 dir: None,
1541 inputs: Vec::new(),
1542 source: Some(false),
1543 });
1544 }
1545
1546 let result = executor
1548 .execute_hooks_background(directory_path.clone(), config_hash.clone(), large_hooks)
1549 .await;
1550
1551 assert!(result.is_ok(), "Should handle large hook configurations");
1552
1553 tokio::time::sleep(Duration::from_millis(500)).await;
1555
1556 executor
1558 .cancel_execution(
1559 &directory_path,
1560 &config_hash,
1561 Some("Test cleanup".to_string()),
1562 )
1563 .await
1564 .ok();
1565 }
1566
1567 #[tokio::test]
1568 async fn test_state_dir_getter() {
1569 use crate::hooks::state::StateManager;
1570
1571 let temp_dir = TempDir::new().unwrap();
1572 let state_dir = temp_dir.path().to_path_buf();
1573 let state_manager = StateManager::new(state_dir.clone());
1574
1575 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1576 }
1577}