1use crate::state::{HookExecutionState, StateManager, compute_instance_hash};
4use crate::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 use std::process::{Command, Stdio};
58
59 if hooks.is_empty() {
60 return Ok("No hooks to execute".to_string());
61 }
62
63 let instance_hash = compute_instance_hash(&directory_path, &config_hash);
64 let total_hooks = hooks.len();
65
66 let previous_env =
68 if let Ok(Some(existing_state)) = self.state_manager.load_state(&instance_hash).await {
69 if existing_state.status == ExecutionStatus::Completed {
71 Some(existing_state.environment_vars.clone())
72 } else {
73 existing_state.previous_env
74 }
75 } else {
76 None
77 };
78
79 let mut state = HookExecutionState::new(
81 directory_path.clone(),
82 instance_hash.clone(),
83 config_hash.clone(),
84 hooks.clone(),
85 );
86 state.previous_env = previous_env;
87
88 self.state_manager.save_state(&state).await?;
90
91 self.state_manager
93 .create_directory_marker(&directory_path, &instance_hash)
94 .await?;
95
96 info!(
97 "Starting background execution of {} hooks for directory: {}",
98 total_hooks,
99 directory_path.display()
100 );
101
102 let pid_file = self
104 .state_manager
105 .get_state_file_path(&instance_hash)
106 .with_extension("pid");
107
108 if pid_file.exists() {
109 if let Ok(pid_str) = std::fs::read_to_string(&pid_file)
111 && let Ok(pid) = pid_str.trim().parse::<usize>()
112 {
113 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
115 let mut system = System::new();
116 let process_pid = Pid::from(pid);
117 system.refresh_processes_specifics(
118 ProcessesToUpdate::Some(&[process_pid]),
119 false,
120 ProcessRefreshKind::nothing(),
121 );
122
123 if system.process(process_pid).is_some() {
124 info!("Supervisor already running for directory with PID {}", pid);
125 return Ok(format!(
126 "Supervisor already running for {} hooks (PID: {})",
127 total_hooks, pid
128 ));
129 }
130 }
131 std::fs::remove_file(&pid_file).ok();
133 }
134
135 let state_dir = self.state_manager.get_state_dir();
137 let hooks_file = state_dir.join(format!("{}_hooks.json", instance_hash));
138 let config_file = state_dir.join(format!("{}_config.json", instance_hash));
139
140 let hooks_json = serde_json::to_string(&hooks)
142 .map_err(|e| Error::serialization(format!("Failed to serialize hooks: {}", e)))?;
143 std::fs::write(&hooks_file, &hooks_json).map_err(|e| Error::Io {
144 source: e,
145 path: Some(hooks_file.clone().into_boxed_path()),
146 operation: "write".to_string(),
147 })?;
148
149 let config_json = serde_json::to_string(&self.config)
151 .map_err(|e| Error::serialization(format!("Failed to serialize config: {}", e)))?;
152 std::fs::write(&config_file, &config_json).map_err(|e| Error::Io {
153 source: e,
154 path: Some(config_file.clone().into_boxed_path()),
155 operation: "write".to_string(),
156 })?;
157
158 let current_exe = if let Ok(exe_path) = std::env::var("CUENV_EXECUTABLE") {
161 PathBuf::from(exe_path)
162 } else {
163 std::env::current_exe()
164 .map_err(|e| Error::process(format!("Failed to get current exe: {}", e)))?
165 };
166
167 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 #[expect(
224 unsafe_code,
225 reason = "Required for POSIX process detachment via setsid()"
226 )]
227 unsafe {
228 cmd.pre_exec(|| {
229 if libc::setsid() == -1 {
231 return Err(std::io::Error::last_os_error());
232 }
233 Ok(())
234 });
235 }
236 }
237
238 #[cfg(windows)]
239 {
240 use std::os::windows::process::CommandExt;
241 const DETACHED_PROCESS: u32 = 0x00000008;
243 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
244 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
245 }
246
247 let _child = cmd
248 .spawn()
249 .map_err(|e| Error::process(format!("Failed to spawn supervisor: {}", e)))?;
250
251 info!("Spawned supervisor process for hook execution");
254
255 Ok(format!(
256 "Started execution of {} hooks in background",
257 total_hooks
258 ))
259 }
260
261 pub async fn get_execution_status(
263 &self,
264 directory_path: &Path,
265 ) -> Result<Option<HookExecutionState>> {
266 let states = self.state_manager.list_active_states().await?;
268 for state in states {
269 if state.directory_path == directory_path {
270 return Ok(Some(state));
271 }
272 }
273 Ok(None)
274 }
275
276 pub async fn get_execution_status_for_instance(
278 &self,
279 directory_path: &Path,
280 config_hash: &str,
281 ) -> Result<Option<HookExecutionState>> {
282 let instance_hash = compute_instance_hash(directory_path, config_hash);
283 self.state_manager.load_state(&instance_hash).await
284 }
285
286 pub async fn get_fast_status(
290 &self,
291 directory_path: &Path,
292 ) -> Result<Option<HookExecutionState>> {
293 if !self.state_manager.has_active_marker(directory_path) {
295 return Ok(None);
296 }
297
298 if let Some(instance_hash) = self
300 .state_manager
301 .get_marker_instance_hash(directory_path)
302 .await
303 {
304 let state = self.state_manager.load_state(&instance_hash).await?;
305
306 match &state {
307 Some(s) if s.is_complete() && !s.should_display_completed() => {
308 self.state_manager
310 .remove_directory_marker(directory_path)
311 .await
312 .ok();
313 return Ok(None);
314 }
315 None => {
316 self.state_manager
318 .remove_directory_marker(directory_path)
319 .await
320 .ok();
321 return Ok(None);
322 }
323 Some(_) => return Ok(state),
324 }
325 }
326
327 Ok(None)
328 }
329
330 #[must_use]
332 pub fn state_manager(&self) -> &StateManager {
333 &self.state_manager
334 }
335
336 pub fn get_fast_status_sync(
340 &self,
341 directory_path: &Path,
342 ) -> Result<Option<HookExecutionState>> {
343 if !self.state_manager.has_active_marker(directory_path) {
345 return Ok(None);
346 }
347
348 if let Some(instance_hash) = self
350 .state_manager
351 .get_marker_instance_hash_sync(directory_path)
352 {
353 let state = self.state_manager.load_state_sync(&instance_hash)?;
354
355 match &state {
356 Some(s) if s.is_complete() && !s.should_display_completed() => {
357 return Ok(None);
360 }
361 None => {
362 return Ok(None);
365 }
366 Some(_) => return Ok(state),
367 }
368 }
369
370 Ok(None)
371 }
372
373 pub async fn wait_for_completion(
375 &self,
376 directory_path: &Path,
377 config_hash: &str,
378 timeout_seconds: Option<u64>,
379 ) -> Result<HookExecutionState> {
380 let instance_hash = compute_instance_hash(directory_path, config_hash);
381 let poll_interval = Duration::from_millis(500);
382 let start_time = Instant::now();
383
384 loop {
385 if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
386 if state.is_complete() {
387 return Ok(state);
388 }
389 } else {
390 return Err(Error::state_not_found(&instance_hash));
391 }
392
393 if let Some(timeout) = timeout_seconds
395 && start_time.elapsed().as_secs() >= timeout
396 {
397 return Err(Error::Timeout { seconds: timeout });
398 }
399
400 tokio::time::sleep(poll_interval).await;
401 }
402 }
403
404 pub async fn cancel_execution(
406 &self,
407 directory_path: &Path,
408 config_hash: &str,
409 reason: Option<String>,
410 ) -> Result<bool> {
411 let instance_hash = compute_instance_hash(directory_path, config_hash);
412
413 let pid_file = self
415 .state_manager
416 .get_state_file_path(&instance_hash)
417 .with_extension("pid");
418
419 if pid_file.exists()
420 && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
421 && let Ok(pid) = pid_str.trim().parse::<usize>()
422 {
423 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System};
424
425 let mut system = System::new();
426 let process_pid = Pid::from(pid);
427
428 system.refresh_processes_specifics(
430 ProcessesToUpdate::Some(&[process_pid]),
431 false,
432 ProcessRefreshKind::nothing(),
433 );
434
435 if let Some(process) = system.process(process_pid) {
437 if process.kill_with(Signal::Term).is_some() {
438 info!("Sent SIGTERM to supervisor process PID {}", pid);
439 } else {
440 warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
441 }
442 } else {
443 info!(
444 "Supervisor process PID {} not found (may have already exited)",
445 pid
446 );
447 }
448
449 std::fs::remove_file(&pid_file).ok();
451 }
452
453 if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
455 && !state.is_complete()
456 {
457 state.mark_cancelled(reason);
458 self.state_manager.save_state(&state).await?;
459 info!(
460 "Cancelled execution for directory: {}",
461 directory_path.display()
462 );
463 return Ok(true);
464 }
465
466 Ok(false)
467 }
468
469 pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
471 let states = self.state_manager.list_active_states().await?;
472 let cutoff = chrono::Utc::now() - older_than;
473 let mut cleaned_count = 0;
474
475 for state in states {
476 if state.is_complete()
477 && let Some(finished_at) = state.finished_at
478 && finished_at < cutoff
479 {
480 self.state_manager
481 .remove_state(&state.instance_hash)
482 .await?;
483 cleaned_count += 1;
484 }
485 }
486
487 if cleaned_count > 0 {
488 info!("Cleaned up {} old execution states", cleaned_count);
489 }
490
491 Ok(cleaned_count)
492 }
493
494 pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
496 let timeout = self.config.default_timeout_seconds;
498
499 execute_hook_with_timeout(hook, &timeout).await
501 }
502}
503
504pub async fn execute_hooks(
506 hooks: Vec<Hook>,
507 _directory_path: &Path,
508 config: &HookExecutionConfig,
509 state_manager: &StateManager,
510 state: &mut HookExecutionState,
511) -> Result<()> {
512 let hook_count = hooks.len();
513 debug!("execute_hooks called with {} hooks", hook_count);
514 if hook_count == 0 {
515 debug!("No hooks to execute");
516 return Ok(());
517 }
518 debug!("Starting to iterate over {} hooks", hook_count);
519 for (index, hook) in hooks.into_iter().enumerate() {
520 debug!(
521 "Processing hook {}/{}: command={}",
522 index + 1,
523 state.total_hooks,
524 hook.command
525 );
526 debug!("Checking if execution was cancelled");
528 if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
529 debug!("Loaded state: status = {:?}", current_state.status);
530 if current_state.status == ExecutionStatus::Cancelled {
531 debug!("Execution was cancelled, stopping");
532 break;
533 }
534 }
535
536 let timeout_seconds = config.default_timeout_seconds;
539
540 state.mark_hook_running(index);
542
543 let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
545
546 match result {
548 Ok(hook_result) => {
549 if hook.source.unwrap_or(false) {
554 if hook_result.stdout.is_empty() {
555 warn!(
556 "Source hook produced empty stdout. Stderr content:\n{}",
557 hook_result.stderr
558 );
559 } else {
560 debug!(
561 "Evaluating source hook output for environment variables (success={})",
562 hook_result.success
563 );
564 match evaluate_shell_environment(&hook_result.stdout).await {
565 Ok(env_vars) => {
566 let count = env_vars.len();
567 debug!("Captured {} environment variables from source hook", count);
568 if count > 0 {
569 for (key, value) in env_vars {
571 state.environment_vars.insert(key, value);
572 }
573 }
574 }
575 Err(e) => {
576 warn!("Failed to evaluate source hook output: {}", e);
577 }
579 }
580 }
581 }
582
583 state.record_hook_result(index, hook_result.clone());
584 if !hook_result.success && config.fail_fast {
585 warn!(
586 "Hook {} failed and fail_fast is enabled, stopping",
587 index + 1
588 );
589 break;
590 }
591 }
592 Err(e) => {
593 let error_msg = format!("Hook execution error: {}", e);
594 state.record_hook_result(
595 index,
596 HookResult::failure(
597 hook.clone(),
598 None,
599 String::new(),
600 error_msg.clone(),
601 0,
602 error_msg,
603 ),
604 );
605 if config.fail_fast {
606 warn!("Hook {} failed with error, stopping", index + 1);
607 break;
608 }
609 }
610 }
611
612 state_manager.save_state(state).await?;
614 }
615
616 if state.status == ExecutionStatus::Running {
618 state.status = ExecutionStatus::Completed;
619 state.finished_at = Some(chrono::Utc::now());
620 info!(
621 "All hooks completed successfully for directory: {}",
622 state.directory_path.display()
623 );
624 }
625
626 state_manager.save_state(state).await?;
628
629 Ok(())
630}
631
632async fn detect_shell() -> String {
634 if is_shell_capable("bash").await {
636 return "bash".to_string();
637 }
638
639 if is_shell_capable("zsh").await {
641 return "zsh".to_string();
642 }
643
644 "sh".to_string()
646}
647
648async fn is_shell_capable(shell: &str) -> bool {
650 let check_script = "case x in x) true ;& y) true ;; esac";
651 match Command::new(shell)
652 .arg("-c")
653 .arg(check_script)
654 .output()
655 .await
656 {
657 Ok(output) => output.status.success(),
658 Err(_) => false,
659 }
660}
661
662async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
664 const DELIMITER: &str = "__CUENV_ENV_START__";
665
666 debug!(
667 "Evaluating shell script to extract environment ({} bytes)",
668 shell_script.len()
669 );
670
671 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
672
673 let mut shell = detect_shell().await;
676
677 for line in shell_script.lines() {
678 if let Some(path) = line.strip_prefix("BASH='")
679 && let Some(end) = path.find('\'')
680 {
681 let bash_path = &path[..end];
682 let path = PathBuf::from(bash_path);
683 if path.exists() {
684 debug!("Detected Nix bash in script: {}", bash_path);
685 shell = bash_path.to_string();
686 break;
687 }
688 }
689 }
690
691 debug!("Using shell: {}", shell);
692
693 let mut cmd_before = Command::new(&shell);
695 cmd_before.arg("-c");
696 cmd_before.arg("env -0");
697 cmd_before.stdout(Stdio::piped());
698 cmd_before.stderr(Stdio::piped());
699
700 let output_before = cmd_before
701 .output()
702 .await
703 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
704
705 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
706 let mut env_before = HashMap::new();
707 for line in env_before_output.split('\0') {
708 if let Some((key, value)) = line.split_once('=') {
709 env_before.insert(key.to_string(), value.to_string());
710 }
711 }
712
713 let filtered_lines: Vec<&str> = shell_script
715 .lines()
716 .filter(|line| {
717 let trimmed = line.trim();
718 if trimmed.is_empty() {
719 return false;
720 }
721
722 if trimmed.starts_with("✓")
724 || trimmed.starts_with("sh:")
725 || trimmed.starts_with("bash:")
726 {
727 return false;
728 }
729
730 true
733 })
734 .collect();
735
736 let filtered_script = filtered_lines.join("\n");
737 tracing::trace!("Filtered shell script:\n{}", filtered_script);
738
739 let mut cmd = Command::new(shell);
741 cmd.arg("-c");
742
743 let script = format!(
744 "{}\necho -ne '\\0{}\\0'; env -0",
745 filtered_script, DELIMITER
746 );
747 cmd.arg(script);
748 cmd.stdout(Stdio::piped());
749 cmd.stderr(Stdio::piped());
750
751 let output = cmd.output().await.map_err(|e| {
752 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
753 })?;
754
755 if !output.status.success() {
758 let stderr = String::from_utf8_lossy(&output.stderr);
759 warn!(
760 "Shell script evaluation finished with error (exit code {:?}): {}",
761 output.status.code(),
762 stderr
763 );
764 }
766
767 let stdout_bytes = &output.stdout;
769 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
770
771 let env_start_index = stdout_bytes
773 .windows(delimiter_bytes.len())
774 .position(|window| window == delimiter_bytes);
775
776 let env_output_bytes = if let Some(idx) = env_start_index {
777 &stdout_bytes[idx + delimiter_bytes.len()..]
779 } else {
780 debug!("Environment delimiter not found in hook output");
781 let len = stdout_bytes.len();
783 let start = len.saturating_sub(1000);
784 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
785 warn!(
786 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
787 tail
788 );
789
790 &[]
792 };
793
794 let env_output = String::from_utf8_lossy(env_output_bytes);
795 let mut env_delta = HashMap::new();
796
797 for line in env_output.split('\0') {
798 if line.is_empty() {
799 continue;
800 }
801
802 if let Some((key, value)) = line.split_once('=') {
803 if key.starts_with("BASH_FUNC_")
805 || key == "PS1"
806 || key == "PS2"
807 || key == "_"
808 || key == "PWD"
809 || key == "OLDPWD"
810 || key == "SHLVL"
811 || key.starts_with("BASH")
812 {
813 continue;
814 }
815
816 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
819 env_delta.insert(key.to_string(), value.to_string());
820 }
821 }
822 }
823
824 if env_delta.is_empty() && !output.status.success() {
825 let stderr = String::from_utf8_lossy(&output.stderr);
827 return Err(Error::configuration(format!(
828 "Shell script evaluation failed and no environment captured. Error: {}",
829 stderr
830 )));
831 }
832
833 debug!(
834 "Evaluated shell script and extracted {} new/changed environment variables",
835 env_delta.len()
836 );
837 Ok(env_delta)
838}
839
840async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
842 let start_time = Instant::now();
843
844 debug!(
845 "Executing hook: {} {} (source: {})",
846 hook.command,
847 hook.args.join(" "),
848 hook.source.unwrap_or(false)
849 );
850
851 let mut cmd = Command::new(&hook.command);
853 cmd.args(&hook.args);
854 cmd.stdout(Stdio::piped());
855 cmd.stderr(Stdio::piped());
856
857 if let Some(dir) = &hook.dir {
859 cmd.current_dir(dir);
860 }
861
862 if hook.source.unwrap_or(false) {
865 cmd.env("SHELL", detect_shell().await);
866 }
867
868 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
870
871 #[expect(
873 clippy::cast_possible_truncation,
874 reason = "u128 to u64 truncation is acceptable for duration"
875 )]
876 let duration_ms = start_time.elapsed().as_millis() as u64;
877
878 match execution_result {
879 Ok(Ok(output)) => {
880 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
881 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
882
883 if output.status.success() {
884 debug!("Hook completed successfully in {}ms", duration_ms);
885 Ok(HookResult::success(
886 hook,
887 output.status,
888 stdout,
889 stderr,
890 duration_ms,
891 ))
892 } else {
893 warn!("Hook failed with exit code: {:?}", output.status.code());
894 Ok(HookResult::failure(
895 hook,
896 Some(output.status),
897 stdout,
898 stderr,
899 duration_ms,
900 format!("Command exited with status: {}", output.status),
901 ))
902 }
903 }
904 Ok(Err(io_error)) => {
905 error!("Failed to execute hook: {}", io_error);
906 Ok(HookResult::failure(
907 hook,
908 None,
909 String::new(),
910 String::new(),
911 duration_ms,
912 format!("Failed to execute command: {}", io_error),
913 ))
914 }
915 Err(_timeout_error) => {
916 warn!("Hook timed out after {} seconds", timeout_seconds);
917 Ok(HookResult::timeout(
918 hook,
919 String::new(),
920 String::new(),
921 *timeout_seconds,
922 ))
923 }
924 }
925}
926
927#[cfg(test)]
928#[expect(
929 clippy::print_stderr,
930 reason = "Tests may use eprintln! to report skip conditions"
931)]
932mod tests {
933 use super::*;
934 use crate::types::Hook;
935 use tempfile::TempDir;
936
937 fn setup_cuenv_executable() -> Option<PathBuf> {
940 if std::env::var("CUENV_EXECUTABLE").is_ok() {
942 return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
943 }
944
945 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
947 let workspace_root = manifest_dir.parent()?.parent()?;
948 let cuenv_binary = workspace_root.join("target/debug/cuenv");
949
950 if cuenv_binary.exists() {
951 #[expect(
954 unsafe_code,
955 reason = "Test helper setting env var in controlled test environment"
956 )]
957 unsafe {
958 std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
959 }
960 Some(cuenv_binary)
961 } else {
962 None
963 }
964 }
965
966 #[tokio::test]
967 async fn test_hook_executor_creation() {
968 let temp_dir = TempDir::new().unwrap();
969 let config = HookExecutionConfig {
970 default_timeout_seconds: 60,
971 fail_fast: true,
972 state_dir: Some(temp_dir.path().to_path_buf()),
973 };
974
975 let executor = HookExecutor::new(config).unwrap();
976 assert_eq!(executor.config.default_timeout_seconds, 60);
977 }
978
979 #[tokio::test]
980 async fn test_execute_single_hook_success() {
981 let executor = HookExecutor::with_default_config().unwrap();
982
983 let hook = Hook {
984 order: 100,
985 propagate: false,
986 command: "echo".to_string(),
987 args: vec!["hello".to_string()],
988 dir: None,
989 inputs: vec![],
990 source: None,
991 };
992
993 let result = executor.execute_single_hook(hook).await.unwrap();
994 assert!(result.success);
995 assert!(result.stdout.contains("hello"));
996 }
997
998 #[tokio::test]
999 async fn test_execute_single_hook_failure() {
1000 let executor = HookExecutor::with_default_config().unwrap();
1001
1002 let hook = Hook {
1003 order: 100,
1004 propagate: false,
1005 command: "false".to_string(), args: vec![],
1007 dir: None,
1008 inputs: Vec::new(),
1009 source: Some(false),
1010 };
1011
1012 let result = executor.execute_single_hook(hook).await.unwrap();
1013 assert!(!result.success);
1014 assert!(result.exit_status.is_some());
1015 assert_ne!(result.exit_status.unwrap(), 0);
1016 }
1017
1018 #[tokio::test]
1019 async fn test_execute_single_hook_timeout() {
1020 let temp_dir = TempDir::new().unwrap();
1021 let config = HookExecutionConfig {
1022 default_timeout_seconds: 1, fail_fast: true,
1024 state_dir: Some(temp_dir.path().to_path_buf()),
1025 };
1026 let executor = HookExecutor::new(config).unwrap();
1027
1028 let hook = Hook {
1029 order: 100,
1030 propagate: false,
1031 command: "sleep".to_string(),
1032 args: vec!["10".to_string()], dir: None,
1034 inputs: Vec::new(),
1035 source: Some(false),
1036 };
1037
1038 let result = executor.execute_single_hook(hook).await.unwrap();
1039 assert!(!result.success);
1040 assert!(result.error.as_ref().unwrap().contains("timed out"));
1041 }
1042
1043 #[tokio::test]
1044 async fn test_background_execution() {
1045 let temp_dir = TempDir::new().unwrap();
1046 let config = HookExecutionConfig {
1047 default_timeout_seconds: 30,
1048 fail_fast: true,
1049 state_dir: Some(temp_dir.path().to_path_buf()),
1050 };
1051
1052 let executor = HookExecutor::new(config).unwrap();
1053 let directory_path = PathBuf::from("/test/directory");
1054 let config_hash = "test_hash".to_string();
1055
1056 let hooks = vec![
1057 Hook {
1058 order: 100,
1059 propagate: false,
1060 command: "echo".to_string(),
1061 args: vec!["hook1".to_string()],
1062 dir: None,
1063 inputs: Vec::new(),
1064 source: Some(false),
1065 },
1066 Hook {
1067 order: 100,
1068 propagate: false,
1069 command: "echo".to_string(),
1070 args: vec!["hook2".to_string()],
1071 dir: None,
1072 inputs: Vec::new(),
1073 source: Some(false),
1074 },
1075 ];
1076
1077 let result = executor
1078 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1079 .await
1080 .unwrap();
1081
1082 assert!(result.contains("Started execution of 2 hooks"));
1083
1084 tokio::time::sleep(Duration::from_millis(100)).await;
1086
1087 let status = executor
1089 .get_execution_status_for_instance(&directory_path, &config_hash)
1090 .await
1091 .unwrap();
1092 assert!(status.is_some());
1093
1094 let state = status.unwrap();
1095 assert_eq!(state.total_hooks, 2);
1096 assert_eq!(state.directory_path, directory_path);
1097 }
1098
1099 #[tokio::test]
1100 async fn test_command_validation() {
1101 let executor = HookExecutor::with_default_config().unwrap();
1102
1103 let hook = Hook {
1108 order: 100,
1109 propagate: false,
1110 command: "echo".to_string(),
1111 args: vec!["test message".to_string()],
1112 dir: None,
1113 inputs: Vec::new(),
1114 source: Some(false),
1115 };
1116
1117 let result = executor.execute_single_hook(hook).await;
1118 assert!(result.is_ok(), "Echo command should succeed");
1119
1120 let hook_result = result.unwrap();
1122 assert!(hook_result.stdout.contains("test message"));
1123 }
1124
1125 #[tokio::test]
1126 async fn test_cancellation() {
1127 if setup_cuenv_executable().is_none() {
1129 eprintln!("Skipping test_cancellation: cuenv binary not found");
1130 return;
1131 }
1132
1133 let temp_dir = TempDir::new().unwrap();
1134 let config = HookExecutionConfig {
1135 default_timeout_seconds: 30,
1136 fail_fast: false,
1137 state_dir: Some(temp_dir.path().to_path_buf()),
1138 };
1139
1140 let executor = HookExecutor::new(config).unwrap();
1141 let directory_path = PathBuf::from("/test/cancel");
1142 let config_hash = "cancel_test".to_string();
1143
1144 let hooks = vec![Hook {
1146 order: 100,
1147 propagate: false,
1148 command: "sleep".to_string(),
1149 args: vec!["10".to_string()],
1150 dir: None,
1151 inputs: Vec::new(),
1152 source: Some(false),
1153 }];
1154
1155 executor
1156 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1157 .await
1158 .unwrap();
1159
1160 let mut started = false;
1163 for _ in 0..20 {
1164 tokio::time::sleep(Duration::from_millis(100)).await;
1165 if let Ok(Some(state)) = executor
1166 .get_execution_status_for_instance(&directory_path, &config_hash)
1167 .await
1168 && state.status == ExecutionStatus::Running
1169 {
1170 started = true;
1171 break;
1172 }
1173 }
1174
1175 if !started {
1176 eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1177 return;
1178 }
1179
1180 let cancelled = executor
1182 .cancel_execution(
1183 &directory_path,
1184 &config_hash,
1185 Some("User cancelled".to_string()),
1186 )
1187 .await
1188 .unwrap();
1189 assert!(cancelled);
1190
1191 let state = executor
1193 .get_execution_status_for_instance(&directory_path, &config_hash)
1194 .await
1195 .unwrap()
1196 .unwrap();
1197 assert_eq!(state.status, ExecutionStatus::Cancelled);
1198 }
1199
1200 #[tokio::test]
1201 async fn test_large_output_handling() {
1202 let executor = HookExecutor::with_default_config().unwrap();
1203
1204 let large_content = "x".repeat(1000); let mut args = Vec::new();
1208 for i in 0..100 {
1210 args.push(format!("Line {}: {}", i, large_content));
1211 }
1212
1213 let hook = Hook {
1215 order: 100,
1216 propagate: false,
1217 command: "echo".to_string(),
1218 args,
1219 dir: None,
1220 inputs: Vec::new(),
1221 source: Some(false),
1222 };
1223
1224 let result = executor.execute_single_hook(hook).await.unwrap();
1225 assert!(result.success);
1226 assert!(result.stdout.len() > 50_000); }
1229
1230 #[tokio::test]
1231 async fn test_state_cleanup() {
1232 if setup_cuenv_executable().is_none() {
1234 eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1235 return;
1236 }
1237
1238 let temp_dir = TempDir::new().unwrap();
1239 let config = HookExecutionConfig {
1240 default_timeout_seconds: 30,
1241 fail_fast: false,
1242 state_dir: Some(temp_dir.path().to_path_buf()),
1243 };
1244
1245 let executor = HookExecutor::new(config).unwrap();
1246 let directory_path = PathBuf::from("/test/cleanup");
1247 let config_hash = "cleanup_test".to_string();
1248
1249 let hooks = vec![Hook {
1251 order: 100,
1252 propagate: false,
1253 command: "echo".to_string(),
1254 args: vec!["test".to_string()],
1255 dir: None,
1256 inputs: Vec::new(),
1257 source: Some(false),
1258 }];
1259
1260 executor
1261 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1262 .await
1263 .unwrap();
1264
1265 let mut state_exists = false;
1267 for _ in 0..20 {
1268 tokio::time::sleep(Duration::from_millis(100)).await;
1269 if executor
1270 .get_execution_status_for_instance(&directory_path, &config_hash)
1271 .await
1272 .unwrap()
1273 .is_some()
1274 {
1275 state_exists = true;
1276 break;
1277 }
1278 }
1279
1280 if !state_exists {
1281 eprintln!("Warning: State never created, skipping cleanup test");
1282 return;
1283 }
1284
1285 if let Err(e) = executor
1287 .wait_for_completion(&directory_path, &config_hash, Some(15))
1288 .await
1289 {
1290 eprintln!(
1291 "Warning: wait_for_completion timed out: {}, skipping test",
1292 e
1293 );
1294 return;
1295 }
1296
1297 let cleaned = executor
1299 .cleanup_old_states(chrono::Duration::seconds(0))
1300 .await
1301 .unwrap();
1302 assert_eq!(cleaned, 1);
1303
1304 let state = executor
1306 .get_execution_status_for_instance(&directory_path, &config_hash)
1307 .await
1308 .unwrap();
1309 assert!(state.is_none());
1310 }
1311
1312 #[tokio::test]
1313 async fn test_execution_state_tracking() {
1314 let temp_dir = TempDir::new().unwrap();
1315 let config = HookExecutionConfig {
1316 default_timeout_seconds: 30,
1317 fail_fast: true,
1318 state_dir: Some(temp_dir.path().to_path_buf()),
1319 };
1320
1321 let executor = HookExecutor::new(config).unwrap();
1322 let directory_path = PathBuf::from("/test/directory");
1323 let config_hash = "hash".to_string();
1324
1325 let status = executor
1327 .get_execution_status_for_instance(&directory_path, &config_hash)
1328 .await
1329 .unwrap();
1330 assert!(status.is_none());
1331
1332 let hooks = vec![Hook {
1334 order: 100,
1335 propagate: false,
1336 command: "echo".to_string(),
1337 args: vec!["test".to_string()],
1338 dir: None,
1339 inputs: Vec::new(),
1340 source: Some(false),
1341 }];
1342
1343 executor
1344 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1345 .await
1346 .unwrap();
1347
1348 let status = executor
1350 .get_execution_status_for_instance(&directory_path, &config_hash)
1351 .await
1352 .unwrap();
1353 assert!(status.is_some());
1354 }
1355
1356 #[tokio::test]
1357 async fn test_working_directory_handling() {
1358 let executor = HookExecutor::with_default_config().unwrap();
1359 let temp_dir = TempDir::new().unwrap();
1360
1361 let hook_with_valid_dir = Hook {
1363 order: 100,
1364 propagate: false,
1365 command: "pwd".to_string(),
1366 args: vec![],
1367 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1368 inputs: vec![],
1369 source: None,
1370 };
1371
1372 let result = executor
1373 .execute_single_hook(hook_with_valid_dir)
1374 .await
1375 .unwrap();
1376 assert!(result.success);
1377 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1378
1379 let hook_with_invalid_dir = Hook {
1381 order: 100,
1382 propagate: false,
1383 command: "pwd".to_string(),
1384 args: vec![],
1385 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1386 inputs: vec![],
1387 source: None,
1388 };
1389
1390 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1391 if let Ok(output) = result {
1394 assert!(
1396 !output
1397 .stdout
1398 .contains("/nonexistent/directory/that/does/not/exist")
1399 );
1400 }
1401 }
1402
1403 #[tokio::test]
1404 async fn test_hook_execution_with_complex_output() {
1405 let executor = HookExecutor::with_default_config().unwrap();
1406
1407 let hook = Hook {
1409 order: 100,
1410 propagate: false,
1411 command: "echo".to_string(),
1412 args: vec!["stdout output".to_string()],
1413 dir: None,
1414 inputs: vec![],
1415 source: None,
1416 };
1417
1418 let result = executor.execute_single_hook(hook).await.unwrap();
1419 assert!(result.success);
1420 assert!(result.stdout.contains("stdout output"));
1421
1422 let hook_with_exit_code = Hook {
1424 order: 100,
1425 propagate: false,
1426 command: "false".to_string(),
1427 args: vec![],
1428 dir: None,
1429 inputs: Vec::new(),
1430 source: Some(false),
1431 };
1432
1433 let result = executor
1434 .execute_single_hook(hook_with_exit_code)
1435 .await
1436 .unwrap();
1437 assert!(!result.success);
1438 assert!(result.exit_status.is_some());
1440 }
1441
1442 #[tokio::test]
1443 async fn test_state_dir_getter() {
1444 use crate::state::StateManager;
1445
1446 let temp_dir = TempDir::new().unwrap();
1447 let state_dir = temp_dir.path().to_path_buf();
1448 let state_manager = StateManager::new(state_dir.clone());
1449
1450 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1451 }
1452
1453 #[tokio::test]
1458 async fn test_hook_timeout_behavior() {
1459 let temp_dir = TempDir::new().unwrap();
1460
1461 let config = HookExecutionConfig {
1463 default_timeout_seconds: 1,
1464 fail_fast: true,
1465 state_dir: Some(temp_dir.path().to_path_buf()),
1466 };
1467 let executor = HookExecutor::new(config).unwrap();
1468
1469 let slow_hook = Hook {
1471 order: 100,
1472 propagate: false,
1473 command: "sleep".to_string(),
1474 args: vec!["30".to_string()],
1475 dir: None,
1476 inputs: Vec::new(),
1477 source: Some(false),
1478 };
1479
1480 let result = executor.execute_single_hook(slow_hook).await.unwrap();
1481
1482 assert!(!result.success, "Hook should fail due to timeout");
1484 assert!(
1485 result.error.is_some(),
1486 "Should have error message on timeout"
1487 );
1488 let error_msg = result.error.as_ref().unwrap();
1489 assert!(
1490 error_msg.contains("timed out"),
1491 "Error should mention timeout: {}",
1492 error_msg
1493 );
1494 assert!(
1495 error_msg.contains('1'),
1496 "Error should mention timeout duration: {}",
1497 error_msg
1498 );
1499
1500 assert!(
1502 result.exit_status.is_none(),
1503 "Exit status should be None for timed out process"
1504 );
1505
1506 assert!(
1508 result.duration_ms >= 1000,
1509 "Duration should be at least 1 second"
1510 );
1511 assert!(
1512 result.duration_ms < 5000,
1513 "Duration should not be much longer than timeout"
1514 );
1515 }
1516
1517 #[tokio::test]
1519 async fn test_hook_timeout_with_partial_output() {
1520 let temp_dir = TempDir::new().unwrap();
1521
1522 let config = HookExecutionConfig {
1523 default_timeout_seconds: 1,
1524 fail_fast: true,
1525 state_dir: Some(temp_dir.path().to_path_buf()),
1526 };
1527 let executor = HookExecutor::new(config).unwrap();
1528
1529 let hook = Hook {
1532 order: 100,
1533 propagate: false,
1534 command: "bash".to_string(),
1535 args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1536 dir: None,
1537 inputs: Vec::new(),
1538 source: Some(false),
1539 };
1540
1541 let result = executor.execute_single_hook(hook).await.unwrap();
1542
1543 assert!(!result.success, "Hook should timeout");
1544 assert!(
1545 result.error.as_ref().unwrap().contains("timed out"),
1546 "Should indicate timeout"
1547 );
1548 }
1549
1550 #[tokio::test]
1553 async fn test_concurrent_hook_isolation() {
1554 use std::sync::Arc;
1555 use tokio::task::JoinSet;
1556
1557 let temp_dir = TempDir::new().unwrap();
1558 let config = HookExecutionConfig {
1559 default_timeout_seconds: 30,
1560 fail_fast: false,
1561 state_dir: Some(temp_dir.path().to_path_buf()),
1562 };
1563 let executor = Arc::new(HookExecutor::new(config).unwrap());
1564
1565 let mut join_set = JoinSet::new();
1566
1567 for i in 0..5 {
1569 let executor = executor.clone();
1570 let unique_id = format!("hook_{}", i);
1571
1572 join_set.spawn(async move {
1573 let hook = Hook {
1574 order: 100,
1575 propagate: false,
1576 command: "bash".to_string(),
1577 args: vec![
1578 "-c".to_string(),
1579 format!(
1580 "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1581 unique_id, unique_id
1582 ),
1583 ],
1584 dir: None,
1585 inputs: Vec::new(),
1586 source: Some(false),
1587 };
1588
1589 let result = executor.execute_single_hook(hook).await.unwrap();
1590 (i, result)
1591 });
1592 }
1593
1594 let mut results = Vec::new();
1596 while let Some(result) = join_set.join_next().await {
1597 results.push(result.unwrap());
1598 }
1599
1600 assert_eq!(results.len(), 5, "All 5 hooks should complete");
1602
1603 for (i, result) in results {
1604 assert!(result.success, "Hook {} should succeed", i);
1605
1606 let expected_id = format!("hook_{}", i);
1607 assert!(
1608 result.stdout.contains(&format!("ID:{}", expected_id)),
1609 "Hook {} output should contain its ID. Got: {}",
1610 i,
1611 result.stdout
1612 );
1613 assert!(
1614 result.stdout.contains(&format!("DONE:{}", expected_id)),
1615 "Hook {} output should contain its DONE marker. Got: {}",
1616 i,
1617 result.stdout
1618 );
1619
1620 for j in 0..5 {
1622 if j != i {
1623 let other_id = format!("hook_{}", j);
1624 assert!(
1625 !result.stdout.contains(&format!("ID:{}", other_id)),
1626 "Hook {} output should not contain hook {} ID",
1627 i,
1628 j
1629 );
1630 }
1631 }
1632 }
1633 }
1634
1635 #[tokio::test]
1640 async fn test_environment_capture_special_chars() {
1641 let multiline_script = r"
1643export MULTILINE_VAR='line1
1644line2
1645line3'
1646";
1647
1648 let result = evaluate_shell_environment(multiline_script).await;
1649 assert!(result.is_ok(), "Should parse multiline env vars");
1650
1651 let env_vars = result.unwrap();
1652 if let Some(value) = env_vars.get("MULTILINE_VAR") {
1653 assert!(
1654 value.contains("line1"),
1655 "Should contain first line: {}",
1656 value
1657 );
1658 assert!(
1659 value.contains("line2"),
1660 "Should contain second line: {}",
1661 value
1662 );
1663 }
1664
1665 let unicode_script = r"
1667export UNICODE_VAR='Hello 世界 🌍 émoji'
1668export CHINESE_VAR='中文测试'
1669export JAPANESE_VAR='日本語テスト'
1670";
1671
1672 let result = evaluate_shell_environment(unicode_script).await;
1673 assert!(result.is_ok(), "Should parse unicode env vars");
1674
1675 let env_vars = result.unwrap();
1676 if let Some(value) = env_vars.get("UNICODE_VAR") {
1677 assert!(
1678 value.contains("世界"),
1679 "Should preserve Chinese characters: {}",
1680 value
1681 );
1682 assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1683 }
1684
1685 let special_chars_script = r#"
1687export QUOTED_VAR="value with 'single' and \"double\" quotes"
1688export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1689export EQUALS_VAR="key=value=another"
1690"#;
1691
1692 let result = evaluate_shell_environment(special_chars_script).await;
1693 assert!(result.is_ok(), "Should parse special chars");
1694
1695 let env_vars = result.unwrap();
1696 if let Some(value) = env_vars.get("EQUALS_VAR") {
1697 assert!(
1698 value.contains("key=value=another"),
1699 "Should preserve equals signs: {}",
1700 value
1701 );
1702 }
1703 }
1704
1705 #[tokio::test]
1707 async fn test_environment_capture_edge_cases() {
1708 let empty_script = r"
1710export EMPTY_VAR=''
1711export SPACE_VAR=' '
1712";
1713
1714 let result = evaluate_shell_environment(empty_script).await;
1715 assert!(result.is_ok(), "Should handle empty/whitespace values");
1716
1717 let long_value = "x".repeat(10000);
1719 let long_script = format!("export LONG_VAR='{}'", long_value);
1720
1721 let result = evaluate_shell_environment(&long_script).await;
1722 assert!(result.is_ok(), "Should handle very long values");
1723
1724 let env_vars = result.unwrap();
1725 if let Some(value) = env_vars.get("LONG_VAR") {
1726 assert_eq!(value.len(), 10000, "Should preserve full length");
1727 }
1728 }
1729
1730 #[tokio::test]
1732 async fn test_working_directory_isolation() {
1733 let executor = HookExecutor::with_default_config().unwrap();
1734
1735 let temp_dir1 = TempDir::new().unwrap();
1737 let temp_dir2 = TempDir::new().unwrap();
1738
1739 std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1741 std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1742
1743 let hook1 = Hook {
1745 order: 100,
1746 propagate: false,
1747 command: "cat".to_string(),
1748 args: vec!["marker.txt".to_string()],
1749 dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1750 inputs: vec![],
1751 source: None,
1752 };
1753
1754 let hook2 = Hook {
1755 order: 100,
1756 propagate: false,
1757 command: "cat".to_string(),
1758 args: vec!["marker.txt".to_string()],
1759 dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1760 inputs: vec![],
1761 source: None,
1762 };
1763
1764 let result1 = executor.execute_single_hook(hook1).await.unwrap();
1765 let result2 = executor.execute_single_hook(hook2).await.unwrap();
1766
1767 assert!(result1.success, "Hook 1 should succeed");
1768 assert!(result2.success, "Hook 2 should succeed");
1769
1770 assert!(
1771 result1.stdout.contains("dir1"),
1772 "Hook 1 should read from dir1: {}",
1773 result1.stdout
1774 );
1775 assert!(
1776 result2.stdout.contains("dir2"),
1777 "Hook 2 should read from dir2: {}",
1778 result2.stdout
1779 );
1780 }
1781
1782 #[tokio::test]
1784 async fn test_stderr_capture() {
1785 let executor = HookExecutor::with_default_config().unwrap();
1786
1787 let hook = Hook {
1789 order: 100,
1790 propagate: false,
1791 command: "bash".to_string(),
1792 args: vec![
1793 "-c".to_string(),
1794 "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1795 ],
1796 dir: None,
1797 inputs: vec![],
1798 source: None,
1799 };
1800
1801 let result = executor.execute_single_hook(hook).await.unwrap();
1802
1803 assert!(result.success, "Hook should succeed");
1804 assert!(
1805 result.stdout.contains("to stdout"),
1806 "Should capture stdout: {}",
1807 result.stdout
1808 );
1809 assert!(
1810 result.stderr.contains("to stderr"),
1811 "Should capture stderr: {}",
1812 result.stderr
1813 );
1814 }
1815
1816 #[tokio::test]
1818 async fn test_binary_output_handling() {
1819 let executor = HookExecutor::with_default_config().unwrap();
1820
1821 let hook = Hook {
1823 order: 100,
1824 propagate: false,
1825 command: "bash".to_string(),
1826 args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1827 dir: None,
1828 inputs: vec![],
1829 source: None,
1830 };
1831
1832 let result = executor.execute_single_hook(hook).await.unwrap();
1833
1834 assert!(result.success, "Hook should succeed");
1836 assert!(
1838 result.stdout.contains("hello") && result.stdout.contains("world"),
1839 "Should contain text parts: {}",
1840 result.stdout
1841 );
1842 }
1843}