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(windows)]
216 {
217 use std::os::windows::process::CommandExt;
218 const DETACHED_PROCESS: u32 = 0x00000008;
220 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
221 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
222 }
223
224 let _child = cmd
225 .spawn()
226 .map_err(|e| Error::process(format!("Failed to spawn supervisor: {}", e)))?;
227
228 info!("Spawned supervisor process for hook execution");
231
232 Ok(format!(
233 "Started execution of {} hooks in background",
234 total_hooks
235 ))
236 }
237
238 pub async fn get_execution_status(
240 &self,
241 directory_path: &Path,
242 ) -> Result<Option<HookExecutionState>> {
243 let states = self.state_manager.list_active_states().await?;
245 for state in states {
246 if state.directory_path == directory_path {
247 return Ok(Some(state));
248 }
249 }
250 Ok(None)
251 }
252
253 pub async fn get_execution_status_for_instance(
255 &self,
256 directory_path: &Path,
257 config_hash: &str,
258 ) -> Result<Option<HookExecutionState>> {
259 let instance_hash = compute_instance_hash(directory_path, config_hash);
260 self.state_manager.load_state(&instance_hash).await
261 }
262
263 pub async fn get_fast_status(
267 &self,
268 directory_path: &Path,
269 ) -> Result<Option<HookExecutionState>> {
270 if !self.state_manager.has_active_marker(directory_path) {
272 return Ok(None);
273 }
274
275 if let Some(instance_hash) = self
277 .state_manager
278 .get_marker_instance_hash(directory_path)
279 .await
280 {
281 let state = self.state_manager.load_state(&instance_hash).await?;
282
283 match &state {
284 Some(s) if s.is_complete() && !s.should_display_completed() => {
285 self.state_manager
287 .remove_directory_marker(directory_path)
288 .await
289 .ok();
290 return Ok(None);
291 }
292 None => {
293 self.state_manager
295 .remove_directory_marker(directory_path)
296 .await
297 .ok();
298 return Ok(None);
299 }
300 Some(_) => return Ok(state),
301 }
302 }
303
304 Ok(None)
305 }
306
307 #[must_use]
309 pub fn state_manager(&self) -> &StateManager {
310 &self.state_manager
311 }
312
313 pub fn get_fast_status_sync(
317 &self,
318 directory_path: &Path,
319 ) -> Result<Option<HookExecutionState>> {
320 if !self.state_manager.has_active_marker(directory_path) {
322 return Ok(None);
323 }
324
325 if let Some(instance_hash) = self
327 .state_manager
328 .get_marker_instance_hash_sync(directory_path)
329 {
330 let state = self.state_manager.load_state_sync(&instance_hash)?;
331
332 match &state {
333 Some(s) if s.is_complete() && !s.should_display_completed() => {
334 return Ok(None);
337 }
338 None => {
339 return Ok(None);
342 }
343 Some(_) => return Ok(state),
344 }
345 }
346
347 Ok(None)
348 }
349
350 pub async fn wait_for_completion(
352 &self,
353 directory_path: &Path,
354 config_hash: &str,
355 timeout_seconds: Option<u64>,
356 ) -> Result<HookExecutionState> {
357 let instance_hash = compute_instance_hash(directory_path, config_hash);
358 let poll_interval = Duration::from_millis(500);
359 let start_time = Instant::now();
360
361 loop {
362 if let Some(state) = self.state_manager.load_state(&instance_hash).await? {
363 if state.is_complete() {
364 return Ok(state);
365 }
366 } else {
367 return Err(Error::state_not_found(&instance_hash));
368 }
369
370 if let Some(timeout) = timeout_seconds
372 && start_time.elapsed().as_secs() >= timeout
373 {
374 return Err(Error::Timeout { seconds: timeout });
375 }
376
377 tokio::time::sleep(poll_interval).await;
378 }
379 }
380
381 pub async fn cancel_execution(
383 &self,
384 directory_path: &Path,
385 config_hash: &str,
386 reason: Option<String>,
387 ) -> Result<bool> {
388 let instance_hash = compute_instance_hash(directory_path, config_hash);
389
390 let pid_file = self
392 .state_manager
393 .get_state_file_path(&instance_hash)
394 .with_extension("pid");
395
396 if pid_file.exists()
397 && let Ok(pid_str) = std::fs::read_to_string(&pid_file)
398 && let Ok(pid) = pid_str.trim().parse::<usize>()
399 {
400 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, Signal, System};
401
402 let mut system = System::new();
403 let process_pid = Pid::from(pid);
404
405 system.refresh_processes_specifics(
407 ProcessesToUpdate::Some(&[process_pid]),
408 false,
409 ProcessRefreshKind::nothing(),
410 );
411
412 if let Some(process) = system.process(process_pid) {
414 if process.kill_with(Signal::Term).is_some() {
415 info!("Sent SIGTERM to supervisor process PID {}", pid);
416 } else {
417 warn!("Failed to send SIGTERM to supervisor process PID {}", pid);
418 }
419 } else {
420 info!(
421 "Supervisor process PID {} not found (may have already exited)",
422 pid
423 );
424 }
425
426 std::fs::remove_file(&pid_file).ok();
428 }
429
430 if let Some(mut state) = self.state_manager.load_state(&instance_hash).await?
432 && !state.is_complete()
433 {
434 state.mark_cancelled(reason);
435 self.state_manager.save_state(&state).await?;
436 info!(
437 "Cancelled execution for directory: {}",
438 directory_path.display()
439 );
440 return Ok(true);
441 }
442
443 Ok(false)
444 }
445
446 pub async fn cleanup_old_states(&self, older_than: chrono::Duration) -> Result<usize> {
448 let states = self.state_manager.list_active_states().await?;
449 let cutoff = chrono::Utc::now() - older_than;
450 let mut cleaned_count = 0;
451
452 for state in states {
453 if state.is_complete()
454 && let Some(finished_at) = state.finished_at
455 && finished_at < cutoff
456 {
457 self.state_manager
458 .remove_state(&state.instance_hash)
459 .await?;
460 cleaned_count += 1;
461 }
462 }
463
464 if cleaned_count > 0 {
465 info!("Cleaned up {} old execution states", cleaned_count);
466 }
467
468 Ok(cleaned_count)
469 }
470
471 pub async fn execute_single_hook(&self, hook: Hook) -> Result<HookResult> {
473 let timeout = self.config.default_timeout_seconds;
475
476 execute_hook_with_timeout(hook, &timeout).await
478 }
479}
480
481pub async fn execute_hooks(
483 hooks: Vec<Hook>,
484 _directory_path: &Path,
485 config: &HookExecutionConfig,
486 state_manager: &StateManager,
487 state: &mut HookExecutionState,
488) -> Result<()> {
489 let hook_count = hooks.len();
490 debug!("execute_hooks called with {} hooks", hook_count);
491 if hook_count == 0 {
492 debug!("No hooks to execute");
493 return Ok(());
494 }
495 debug!("Starting to iterate over {} hooks", hook_count);
496 for (index, hook) in hooks.into_iter().enumerate() {
497 debug!(
498 "Processing hook {}/{}: command={}",
499 index + 1,
500 state.total_hooks,
501 hook.command
502 );
503 debug!("Checking if execution was cancelled");
505 if let Ok(Some(current_state)) = state_manager.load_state(&state.instance_hash).await {
506 debug!("Loaded state: status = {:?}", current_state.status);
507 if current_state.status == ExecutionStatus::Cancelled {
508 debug!("Execution was cancelled, stopping");
509 break;
510 }
511 }
512
513 let timeout_seconds = config.default_timeout_seconds;
516
517 state.mark_hook_running(index);
519
520 let result = execute_hook_with_timeout(hook.clone(), &timeout_seconds).await;
522
523 match result {
525 Ok(hook_result) => {
526 if hook.source.unwrap_or(false) {
531 if hook_result.stdout.is_empty() {
532 warn!(
533 "Source hook produced empty stdout. Stderr content:\n{}",
534 hook_result.stderr
535 );
536 } else {
537 debug!(
538 "Evaluating source hook output for environment variables (success={})",
539 hook_result.success
540 );
541 match evaluate_shell_environment(
542 &hook_result.stdout,
543 &state.environment_vars,
544 )
545 .await
546 {
547 Ok((env_vars, removed_keys)) => {
548 let count = env_vars.len();
549 debug!(
550 "Captured {} environment variables from source hook ({} removed)",
551 count,
552 removed_keys.len()
553 );
554 for (key, value) in env_vars {
556 state.environment_vars.insert(key, value);
557 }
558 for key in &removed_keys {
560 state.environment_vars.remove(key);
561 }
562 }
563 Err(e) => {
564 warn!("Failed to evaluate source hook output: {}", e);
565 }
567 }
568 }
569 }
570
571 state.record_hook_result(index, hook_result.clone());
572 if !hook_result.success && config.fail_fast {
573 warn!(
574 "Hook {} failed and fail_fast is enabled, stopping",
575 index + 1
576 );
577 break;
578 }
579 }
580 Err(e) => {
581 let error_msg = format!("Hook execution error: {}", e);
582 state.record_hook_result(
583 index,
584 HookResult::failure(
585 hook.clone(),
586 None,
587 String::new(),
588 error_msg.clone(),
589 0,
590 error_msg,
591 ),
592 );
593 if config.fail_fast {
594 warn!("Hook {} failed with error, stopping", index + 1);
595 break;
596 }
597 }
598 }
599
600 state_manager.save_state(state).await?;
602 }
603
604 if state.status == ExecutionStatus::Running {
606 state.status = ExecutionStatus::Completed;
607 state.finished_at = Some(chrono::Utc::now());
608 info!(
609 "All hooks completed successfully for directory: {}",
610 state.directory_path.display()
611 );
612 }
613
614 state_manager.save_state(state).await?;
616
617 Ok(())
618}
619
620async fn detect_shell() -> String {
622 if is_shell_capable("bash").await {
624 return "bash".to_string();
625 }
626
627 if is_shell_capable("zsh").await {
629 return "zsh".to_string();
630 }
631
632 "sh".to_string()
634}
635
636async fn is_shell_capable(shell: &str) -> bool {
638 let check_script = "case x in x) true ;& y) true ;; esac";
639 match Command::new(shell)
640 .arg("-c")
641 .arg(check_script)
642 .output()
643 .await
644 {
645 Ok(output) => output.status.success(),
646 Err(_) => false,
647 }
648}
649
650async fn evaluate_shell_environment(
652 shell_script: &str,
653 prior_env: &HashMap<String, String>,
654) -> Result<(HashMap<String, String>, Vec<String>)> {
655 const DELIMITER: &str = "__CUENV_ENV_START__";
656
657 debug!(
658 "Evaluating shell script to extract environment ({} bytes)",
659 shell_script.len()
660 );
661
662 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
663
664 let mut shell = detect_shell().await;
667
668 for line in shell_script.lines() {
669 if let Some(path) = line.strip_prefix("BASH='")
670 && let Some(end) = path.find('\'')
671 {
672 let bash_path = &path[..end];
673 let path = PathBuf::from(bash_path);
674 if path.exists() {
675 debug!("Detected Nix bash in script: {}", bash_path);
676 shell = bash_path.to_string();
677 break;
678 }
679 }
680 }
681
682 debug!("Using shell: {}", shell);
683
684 let mut cmd_before = Command::new(&shell);
686 cmd_before.arg("-c");
687 cmd_before.arg("/usr/bin/env -0");
688 cmd_before.stdout(Stdio::piped());
689 cmd_before.stderr(Stdio::piped());
690 for (key, value) in prior_env {
692 cmd_before.env(key, value);
693 }
694
695 let output_before = cmd_before
696 .output()
697 .await
698 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
699
700 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
701 let mut env_before = HashMap::new();
702 for line in env_before_output.split('\0') {
703 if let Some((key, value)) = line.split_once('=') {
704 env_before.insert(key.to_string(), value.to_string());
705 }
706 }
707
708 let filtered_lines: Vec<&str> = shell_script
710 .lines()
711 .filter(|line| {
712 let trimmed = line.trim();
713 if trimmed.is_empty() {
714 return false;
715 }
716
717 if trimmed.starts_with("✓")
719 || trimmed.starts_with("sh:")
720 || trimmed.starts_with("bash:")
721 {
722 return false;
723 }
724
725 true
728 })
729 .collect();
730
731 let filtered_script = filtered_lines.join("\n");
732 tracing::trace!("Filtered shell script:\n{}", filtered_script);
733
734 let mut cmd = Command::new(shell);
736 cmd.arg("-c");
737
738 let script = format!(
739 "{}\necho -ne '\\0{}\\0'; /usr/bin/env -0",
740 filtered_script, DELIMITER
741 );
742 cmd.arg(script);
743 cmd.stdout(Stdio::piped());
744 cmd.stderr(Stdio::piped());
745 for (key, value) in prior_env {
747 cmd.env(key, value);
748 }
749
750 let output = cmd.output().await.map_err(|e| {
751 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
752 })?;
753
754 if !output.status.success() {
757 let stderr = String::from_utf8_lossy(&output.stderr);
758 warn!(
759 "Shell script evaluation finished with error (exit code {:?}): {}",
760 output.status.code(),
761 stderr
762 );
763 }
765
766 let stdout_bytes = &output.stdout;
768 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
769
770 let env_start_index = stdout_bytes
772 .windows(delimiter_bytes.len())
773 .position(|window| window == delimiter_bytes);
774
775 let env_output_bytes = if let Some(idx) = env_start_index {
776 &stdout_bytes[idx + delimiter_bytes.len()..]
778 } else {
779 debug!("Environment delimiter not found in hook output");
780 let len = stdout_bytes.len();
782 let start = len.saturating_sub(1000);
783 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
784 warn!(
785 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
786 tail
787 );
788
789 &[]
791 };
792
793 let env_output = String::from_utf8_lossy(env_output_bytes);
794 let mut env_delta = HashMap::new();
795 let mut post_env_keys = std::collections::HashSet::new();
796
797 let is_skip_key = |key: &str| -> bool {
798 key.starts_with("BASH_FUNC_")
799 || key == "PS1"
800 || key == "PS2"
801 || key == "_"
802 || key == "PWD"
803 || key == "OLDPWD"
804 || key == "SHLVL"
805 || key.starts_with("BASH")
806 };
807
808 for line in env_output.split('\0') {
809 if line.is_empty() {
810 continue;
811 }
812
813 if let Some((key, value)) = line.split_once('=') {
814 if is_skip_key(key) {
815 continue;
816 }
817
818 if !key.is_empty() {
819 post_env_keys.insert(key.to_string());
820 }
821
822 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
825 env_delta.insert(key.to_string(), value.to_string());
826 }
827 }
828 }
829
830 let removed_keys: Vec<String> = prior_env
832 .keys()
833 .filter(|key| !is_skip_key(key) && !post_env_keys.contains(key.as_str()))
834 .cloned()
835 .collect();
836
837 if env_delta.is_empty() && removed_keys.is_empty() && !output.status.success() {
838 let stderr = String::from_utf8_lossy(&output.stderr);
840 return Err(Error::configuration(format!(
841 "Shell script evaluation failed and no environment captured. Error: {}",
842 stderr
843 )));
844 }
845
846 debug!(
847 "Evaluated shell script and extracted {} new/changed environment variables ({} removed)",
848 env_delta.len(),
849 removed_keys.len()
850 );
851 Ok((env_delta, removed_keys))
852}
853
854async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
856 let start_time = Instant::now();
857
858 debug!(
859 "Executing hook: {} {} (source: {})",
860 hook.command,
861 hook.args.join(" "),
862 hook.source.unwrap_or(false)
863 );
864
865 let mut cmd = Command::new(&hook.command);
867 cmd.args(&hook.args);
868 cmd.stdout(Stdio::piped());
869 cmd.stderr(Stdio::piped());
870
871 if let Some(dir) = &hook.dir {
873 cmd.current_dir(dir);
874 }
875
876 if hook.source.unwrap_or(false) {
879 cmd.env("SHELL", detect_shell().await);
880 }
881
882 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
884
885 #[expect(
887 clippy::cast_possible_truncation,
888 reason = "u128 to u64 truncation is acceptable for duration"
889 )]
890 let duration_ms = start_time.elapsed().as_millis() as u64;
891
892 match execution_result {
893 Ok(Ok(output)) => {
894 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
895 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
896
897 if output.status.success() {
898 debug!("Hook completed successfully in {}ms", duration_ms);
899 Ok(HookResult::success(
900 hook,
901 output.status,
902 stdout,
903 stderr,
904 duration_ms,
905 ))
906 } else {
907 warn!("Hook failed with exit code: {:?}", output.status.code());
908 Ok(HookResult::failure(
909 hook,
910 Some(output.status),
911 stdout,
912 stderr,
913 duration_ms,
914 format!("Command exited with status: {}", output.status),
915 ))
916 }
917 }
918 Ok(Err(io_error)) => {
919 error!("Failed to execute hook: {}", io_error);
920 Ok(HookResult::failure(
921 hook,
922 None,
923 String::new(),
924 String::new(),
925 duration_ms,
926 format!("Failed to execute command: {}", io_error),
927 ))
928 }
929 Err(_timeout_error) => {
930 warn!("Hook timed out after {} seconds", timeout_seconds);
931 Ok(HookResult::timeout(
932 hook,
933 String::new(),
934 String::new(),
935 *timeout_seconds,
936 ))
937 }
938 }
939}
940
941#[cfg(test)]
942#[expect(
943 clippy::print_stderr,
944 reason = "Tests may use eprintln! to report skip conditions"
945)]
946mod tests {
947 use super::*;
948 use crate::types::Hook;
949 use tempfile::TempDir;
950
951 fn setup_cuenv_executable() -> Option<PathBuf> {
954 if std::env::var("CUENV_EXECUTABLE").is_ok() {
956 return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
957 }
958
959 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
961 let workspace_root = manifest_dir.parent()?.parent()?;
962 let cuenv_binary = workspace_root.join("target/debug/cuenv");
963
964 if cuenv_binary.exists() {
965 #[expect(
968 unsafe_code,
969 reason = "Test helper setting env var in controlled test environment"
970 )]
971 unsafe {
972 std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
973 }
974 Some(cuenv_binary)
975 } else {
976 None
977 }
978 }
979
980 #[tokio::test]
981 async fn test_hook_executor_creation() {
982 let temp_dir = TempDir::new().unwrap();
983 let config = HookExecutionConfig {
984 default_timeout_seconds: 60,
985 fail_fast: true,
986 state_dir: Some(temp_dir.path().to_path_buf()),
987 };
988
989 let executor = HookExecutor::new(config).unwrap();
990 assert_eq!(executor.config.default_timeout_seconds, 60);
991 }
992
993 #[tokio::test]
994 async fn test_execute_single_hook_success() {
995 let executor = HookExecutor::with_default_config().unwrap();
996
997 let hook = Hook {
998 order: 100,
999 propagate: false,
1000 command: "echo".to_string(),
1001 args: vec!["hello".to_string()],
1002 dir: None,
1003 inputs: vec![],
1004 source: None,
1005 };
1006
1007 let result = executor.execute_single_hook(hook).await.unwrap();
1008 assert!(result.success);
1009 assert!(result.stdout.contains("hello"));
1010 }
1011
1012 #[tokio::test]
1013 async fn test_execute_single_hook_failure() {
1014 let executor = HookExecutor::with_default_config().unwrap();
1015
1016 let hook = Hook {
1017 order: 100,
1018 propagate: false,
1019 command: "false".to_string(), args: vec![],
1021 dir: None,
1022 inputs: Vec::new(),
1023 source: Some(false),
1024 };
1025
1026 let result = executor.execute_single_hook(hook).await.unwrap();
1027 assert!(!result.success);
1028 assert!(result.exit_status.is_some());
1029 assert_ne!(result.exit_status.unwrap(), 0);
1030 }
1031
1032 #[tokio::test]
1033 async fn test_execute_single_hook_timeout() {
1034 let temp_dir = TempDir::new().unwrap();
1035 let config = HookExecutionConfig {
1036 default_timeout_seconds: 1, fail_fast: true,
1038 state_dir: Some(temp_dir.path().to_path_buf()),
1039 };
1040 let executor = HookExecutor::new(config).unwrap();
1041
1042 let hook = Hook {
1043 order: 100,
1044 propagate: false,
1045 command: "sleep".to_string(),
1046 args: vec!["10".to_string()], dir: None,
1048 inputs: Vec::new(),
1049 source: Some(false),
1050 };
1051
1052 let result = executor.execute_single_hook(hook).await.unwrap();
1053 assert!(!result.success);
1054 assert!(result.error.as_ref().unwrap().contains("timed out"));
1055 }
1056
1057 #[tokio::test]
1058 async fn test_background_execution() {
1059 let temp_dir = TempDir::new().unwrap();
1060 let config = HookExecutionConfig {
1061 default_timeout_seconds: 30,
1062 fail_fast: true,
1063 state_dir: Some(temp_dir.path().to_path_buf()),
1064 };
1065
1066 let executor = HookExecutor::new(config).unwrap();
1067 let directory_path = PathBuf::from("/test/directory");
1068 let config_hash = "test_hash".to_string();
1069
1070 let hooks = vec![
1071 Hook {
1072 order: 100,
1073 propagate: false,
1074 command: "echo".to_string(),
1075 args: vec!["hook1".to_string()],
1076 dir: None,
1077 inputs: Vec::new(),
1078 source: Some(false),
1079 },
1080 Hook {
1081 order: 100,
1082 propagate: false,
1083 command: "echo".to_string(),
1084 args: vec!["hook2".to_string()],
1085 dir: None,
1086 inputs: Vec::new(),
1087 source: Some(false),
1088 },
1089 ];
1090
1091 let result = executor
1092 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1093 .await
1094 .unwrap();
1095
1096 assert!(result.contains("Started execution of 2 hooks"));
1097
1098 tokio::time::sleep(Duration::from_millis(100)).await;
1100
1101 let status = executor
1103 .get_execution_status_for_instance(&directory_path, &config_hash)
1104 .await
1105 .unwrap();
1106 assert!(status.is_some());
1107
1108 let state = status.unwrap();
1109 assert_eq!(state.total_hooks, 2);
1110 assert_eq!(state.directory_path, directory_path);
1111 }
1112
1113 #[tokio::test]
1114 async fn test_command_validation() {
1115 let executor = HookExecutor::with_default_config().unwrap();
1116
1117 let hook = Hook {
1122 order: 100,
1123 propagate: false,
1124 command: "echo".to_string(),
1125 args: vec!["test message".to_string()],
1126 dir: None,
1127 inputs: Vec::new(),
1128 source: Some(false),
1129 };
1130
1131 let result = executor.execute_single_hook(hook).await;
1132 assert!(result.is_ok(), "Echo command should succeed");
1133
1134 let hook_result = result.unwrap();
1136 assert!(hook_result.stdout.contains("test message"));
1137 }
1138
1139 #[tokio::test]
1140 async fn test_cancellation() {
1141 if setup_cuenv_executable().is_none() {
1143 eprintln!("Skipping test_cancellation: cuenv binary not found");
1144 return;
1145 }
1146
1147 let temp_dir = TempDir::new().unwrap();
1148 let config = HookExecutionConfig {
1149 default_timeout_seconds: 30,
1150 fail_fast: false,
1151 state_dir: Some(temp_dir.path().to_path_buf()),
1152 };
1153
1154 let executor = HookExecutor::new(config).unwrap();
1155 let directory_path = PathBuf::from("/test/cancel");
1156 let config_hash = "cancel_test".to_string();
1157
1158 let hooks = vec![Hook {
1160 order: 100,
1161 propagate: false,
1162 command: "sleep".to_string(),
1163 args: vec!["10".to_string()],
1164 dir: None,
1165 inputs: Vec::new(),
1166 source: Some(false),
1167 }];
1168
1169 executor
1170 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1171 .await
1172 .unwrap();
1173
1174 let mut started = false;
1177 for _ in 0..20 {
1178 tokio::time::sleep(Duration::from_millis(100)).await;
1179 if let Ok(Some(state)) = executor
1180 .get_execution_status_for_instance(&directory_path, &config_hash)
1181 .await
1182 && state.status == ExecutionStatus::Running
1183 {
1184 started = true;
1185 break;
1186 }
1187 }
1188
1189 if !started {
1190 eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1191 return;
1192 }
1193
1194 let cancelled = executor
1196 .cancel_execution(
1197 &directory_path,
1198 &config_hash,
1199 Some("User cancelled".to_string()),
1200 )
1201 .await
1202 .unwrap();
1203 assert!(cancelled);
1204
1205 let state = executor
1207 .get_execution_status_for_instance(&directory_path, &config_hash)
1208 .await
1209 .unwrap()
1210 .unwrap();
1211 assert_eq!(state.status, ExecutionStatus::Cancelled);
1212 }
1213
1214 #[tokio::test]
1215 async fn test_large_output_handling() {
1216 let executor = HookExecutor::with_default_config().unwrap();
1217
1218 let large_content = "x".repeat(1000); let mut args = Vec::new();
1222 for i in 0..100 {
1224 args.push(format!("Line {}: {}", i, large_content));
1225 }
1226
1227 let hook = Hook {
1229 order: 100,
1230 propagate: false,
1231 command: "echo".to_string(),
1232 args,
1233 dir: None,
1234 inputs: Vec::new(),
1235 source: Some(false),
1236 };
1237
1238 let result = executor.execute_single_hook(hook).await.unwrap();
1239 assert!(result.success);
1240 assert!(result.stdout.len() > 50_000); }
1243
1244 #[tokio::test]
1245 async fn test_state_cleanup() {
1246 if setup_cuenv_executable().is_none() {
1248 eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1249 return;
1250 }
1251
1252 let temp_dir = TempDir::new().unwrap();
1253 let config = HookExecutionConfig {
1254 default_timeout_seconds: 30,
1255 fail_fast: false,
1256 state_dir: Some(temp_dir.path().to_path_buf()),
1257 };
1258
1259 let executor = HookExecutor::new(config).unwrap();
1260 let directory_path = PathBuf::from("/test/cleanup");
1261 let config_hash = "cleanup_test".to_string();
1262
1263 let hooks = vec![Hook {
1265 order: 100,
1266 propagate: false,
1267 command: "echo".to_string(),
1268 args: vec!["test".to_string()],
1269 dir: None,
1270 inputs: Vec::new(),
1271 source: Some(false),
1272 }];
1273
1274 executor
1275 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1276 .await
1277 .unwrap();
1278
1279 let mut state_exists = false;
1281 for _ in 0..20 {
1282 tokio::time::sleep(Duration::from_millis(100)).await;
1283 if executor
1284 .get_execution_status_for_instance(&directory_path, &config_hash)
1285 .await
1286 .unwrap()
1287 .is_some()
1288 {
1289 state_exists = true;
1290 break;
1291 }
1292 }
1293
1294 if !state_exists {
1295 eprintln!("Warning: State never created, skipping cleanup test");
1296 return;
1297 }
1298
1299 if let Err(e) = executor
1301 .wait_for_completion(&directory_path, &config_hash, Some(15))
1302 .await
1303 {
1304 eprintln!(
1305 "Warning: wait_for_completion timed out: {}, skipping test",
1306 e
1307 );
1308 return;
1309 }
1310
1311 let cleaned = executor
1313 .cleanup_old_states(chrono::Duration::seconds(0))
1314 .await
1315 .unwrap();
1316 assert_eq!(cleaned, 1);
1317
1318 let state = executor
1320 .get_execution_status_for_instance(&directory_path, &config_hash)
1321 .await
1322 .unwrap();
1323 assert!(state.is_none());
1324 }
1325
1326 #[tokio::test]
1327 async fn test_execution_state_tracking() {
1328 let temp_dir = TempDir::new().unwrap();
1329 let config = HookExecutionConfig {
1330 default_timeout_seconds: 30,
1331 fail_fast: true,
1332 state_dir: Some(temp_dir.path().to_path_buf()),
1333 };
1334
1335 let executor = HookExecutor::new(config).unwrap();
1336 let directory_path = PathBuf::from("/test/directory");
1337 let config_hash = "hash".to_string();
1338
1339 let status = executor
1341 .get_execution_status_for_instance(&directory_path, &config_hash)
1342 .await
1343 .unwrap();
1344 assert!(status.is_none());
1345
1346 let hooks = vec![Hook {
1348 order: 100,
1349 propagate: false,
1350 command: "echo".to_string(),
1351 args: vec!["test".to_string()],
1352 dir: None,
1353 inputs: Vec::new(),
1354 source: Some(false),
1355 }];
1356
1357 executor
1358 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1359 .await
1360 .unwrap();
1361
1362 let status = executor
1364 .get_execution_status_for_instance(&directory_path, &config_hash)
1365 .await
1366 .unwrap();
1367 assert!(status.is_some());
1368 }
1369
1370 #[tokio::test]
1371 async fn test_working_directory_handling() {
1372 let executor = HookExecutor::with_default_config().unwrap();
1373 let temp_dir = TempDir::new().unwrap();
1374
1375 let hook_with_valid_dir = Hook {
1377 order: 100,
1378 propagate: false,
1379 command: "pwd".to_string(),
1380 args: vec![],
1381 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1382 inputs: vec![],
1383 source: None,
1384 };
1385
1386 let result = executor
1387 .execute_single_hook(hook_with_valid_dir)
1388 .await
1389 .unwrap();
1390 assert!(result.success);
1391 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1392
1393 let hook_with_invalid_dir = Hook {
1395 order: 100,
1396 propagate: false,
1397 command: "pwd".to_string(),
1398 args: vec![],
1399 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1400 inputs: vec![],
1401 source: None,
1402 };
1403
1404 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1405 if let Ok(output) = result {
1408 assert!(
1410 !output
1411 .stdout
1412 .contains("/nonexistent/directory/that/does/not/exist")
1413 );
1414 }
1415 }
1416
1417 #[tokio::test]
1418 async fn test_hook_execution_with_complex_output() {
1419 let executor = HookExecutor::with_default_config().unwrap();
1420
1421 let hook = Hook {
1423 order: 100,
1424 propagate: false,
1425 command: "echo".to_string(),
1426 args: vec!["stdout output".to_string()],
1427 dir: None,
1428 inputs: vec![],
1429 source: None,
1430 };
1431
1432 let result = executor.execute_single_hook(hook).await.unwrap();
1433 assert!(result.success);
1434 assert!(result.stdout.contains("stdout output"));
1435
1436 let hook_with_exit_code = Hook {
1438 order: 100,
1439 propagate: false,
1440 command: "false".to_string(),
1441 args: vec![],
1442 dir: None,
1443 inputs: Vec::new(),
1444 source: Some(false),
1445 };
1446
1447 let result = executor
1448 .execute_single_hook(hook_with_exit_code)
1449 .await
1450 .unwrap();
1451 assert!(!result.success);
1452 assert!(result.exit_status.is_some());
1454 }
1455
1456 #[tokio::test]
1457 async fn test_state_dir_getter() {
1458 use crate::state::StateManager;
1459
1460 let temp_dir = TempDir::new().unwrap();
1461 let state_dir = temp_dir.path().to_path_buf();
1462 let state_manager = StateManager::new(state_dir.clone());
1463
1464 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1465 }
1466
1467 #[tokio::test]
1472 async fn test_hook_timeout_behavior() {
1473 let temp_dir = TempDir::new().unwrap();
1474
1475 let config = HookExecutionConfig {
1477 default_timeout_seconds: 1,
1478 fail_fast: true,
1479 state_dir: Some(temp_dir.path().to_path_buf()),
1480 };
1481 let executor = HookExecutor::new(config).unwrap();
1482
1483 let slow_hook = Hook {
1485 order: 100,
1486 propagate: false,
1487 command: "sleep".to_string(),
1488 args: vec!["30".to_string()],
1489 dir: None,
1490 inputs: Vec::new(),
1491 source: Some(false),
1492 };
1493
1494 let result = executor.execute_single_hook(slow_hook).await.unwrap();
1495
1496 assert!(!result.success, "Hook should fail due to timeout");
1498 assert!(
1499 result.error.is_some(),
1500 "Should have error message on timeout"
1501 );
1502 let error_msg = result.error.as_ref().unwrap();
1503 assert!(
1504 error_msg.contains("timed out"),
1505 "Error should mention timeout: {}",
1506 error_msg
1507 );
1508 assert!(
1509 error_msg.contains('1'),
1510 "Error should mention timeout duration: {}",
1511 error_msg
1512 );
1513
1514 assert!(
1516 result.exit_status.is_none(),
1517 "Exit status should be None for timed out process"
1518 );
1519
1520 assert!(
1522 result.duration_ms >= 1000,
1523 "Duration should be at least 1 second"
1524 );
1525 assert!(
1526 result.duration_ms < 5000,
1527 "Duration should not be much longer than timeout"
1528 );
1529 }
1530
1531 #[tokio::test]
1533 async fn test_hook_timeout_with_partial_output() {
1534 let temp_dir = TempDir::new().unwrap();
1535
1536 let config = HookExecutionConfig {
1537 default_timeout_seconds: 1,
1538 fail_fast: true,
1539 state_dir: Some(temp_dir.path().to_path_buf()),
1540 };
1541 let executor = HookExecutor::new(config).unwrap();
1542
1543 let hook = Hook {
1546 order: 100,
1547 propagate: false,
1548 command: "bash".to_string(),
1549 args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1550 dir: None,
1551 inputs: Vec::new(),
1552 source: Some(false),
1553 };
1554
1555 let result = executor.execute_single_hook(hook).await.unwrap();
1556
1557 assert!(!result.success, "Hook should timeout");
1558 assert!(
1559 result.error.as_ref().unwrap().contains("timed out"),
1560 "Should indicate timeout"
1561 );
1562 }
1563
1564 #[tokio::test]
1567 async fn test_concurrent_hook_isolation() {
1568 use std::sync::Arc;
1569 use tokio::task::JoinSet;
1570
1571 let temp_dir = TempDir::new().unwrap();
1572 let config = HookExecutionConfig {
1573 default_timeout_seconds: 30,
1574 fail_fast: false,
1575 state_dir: Some(temp_dir.path().to_path_buf()),
1576 };
1577 let executor = Arc::new(HookExecutor::new(config).unwrap());
1578
1579 let mut join_set = JoinSet::new();
1580
1581 for i in 0..5 {
1583 let executor = executor.clone();
1584 let unique_id = format!("hook_{}", i);
1585
1586 join_set.spawn(async move {
1587 let hook = Hook {
1588 order: 100,
1589 propagate: false,
1590 command: "bash".to_string(),
1591 args: vec![
1592 "-c".to_string(),
1593 format!(
1594 "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1595 unique_id, unique_id
1596 ),
1597 ],
1598 dir: None,
1599 inputs: Vec::new(),
1600 source: Some(false),
1601 };
1602
1603 let result = executor.execute_single_hook(hook).await.unwrap();
1604 (i, result)
1605 });
1606 }
1607
1608 let mut results = Vec::new();
1610 while let Some(result) = join_set.join_next().await {
1611 results.push(result.unwrap());
1612 }
1613
1614 assert_eq!(results.len(), 5, "All 5 hooks should complete");
1616
1617 for (i, result) in results {
1618 assert!(result.success, "Hook {} should succeed", i);
1619
1620 let expected_id = format!("hook_{}", i);
1621 assert!(
1622 result.stdout.contains(&format!("ID:{}", expected_id)),
1623 "Hook {} output should contain its ID. Got: {}",
1624 i,
1625 result.stdout
1626 );
1627 assert!(
1628 result.stdout.contains(&format!("DONE:{}", expected_id)),
1629 "Hook {} output should contain its DONE marker. Got: {}",
1630 i,
1631 result.stdout
1632 );
1633
1634 for j in 0..5 {
1636 if j != i {
1637 let other_id = format!("hook_{}", j);
1638 assert!(
1639 !result.stdout.contains(&format!("ID:{}", other_id)),
1640 "Hook {} output should not contain hook {} ID",
1641 i,
1642 j
1643 );
1644 }
1645 }
1646 }
1647 }
1648
1649 #[tokio::test]
1654 async fn test_environment_capture_special_chars() {
1655 let multiline_script = r"
1657export MULTILINE_VAR='line1
1658line2
1659line3'
1660";
1661
1662 let result = evaluate_shell_environment(multiline_script, &HashMap::new()).await;
1663 assert!(result.is_ok(), "Should parse multiline env vars");
1664
1665 let (env_vars, _removed) = result.unwrap();
1666 if let Some(value) = env_vars.get("MULTILINE_VAR") {
1667 assert!(
1668 value.contains("line1"),
1669 "Should contain first line: {}",
1670 value
1671 );
1672 assert!(
1673 value.contains("line2"),
1674 "Should contain second line: {}",
1675 value
1676 );
1677 }
1678
1679 let unicode_script = r"
1681export UNICODE_VAR='Hello 世界 🌍 émoji'
1682export CHINESE_VAR='中文测试'
1683export JAPANESE_VAR='日本語テスト'
1684";
1685
1686 let result = evaluate_shell_environment(unicode_script, &HashMap::new()).await;
1687 assert!(result.is_ok(), "Should parse unicode env vars");
1688
1689 let (env_vars, _removed) = result.unwrap();
1690 if let Some(value) = env_vars.get("UNICODE_VAR") {
1691 assert!(
1692 value.contains("世界"),
1693 "Should preserve Chinese characters: {}",
1694 value
1695 );
1696 assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1697 }
1698
1699 let special_chars_script = r#"
1701export QUOTED_VAR="value with 'single' and \"double\" quotes"
1702export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1703export EQUALS_VAR="key=value=another"
1704"#;
1705
1706 let result = evaluate_shell_environment(special_chars_script, &HashMap::new()).await;
1707 assert!(result.is_ok(), "Should parse special chars");
1708
1709 let (env_vars, _removed) = result.unwrap();
1710 if let Some(value) = env_vars.get("EQUALS_VAR") {
1711 assert!(
1712 value.contains("key=value=another"),
1713 "Should preserve equals signs: {}",
1714 value
1715 );
1716 }
1717 }
1718
1719 #[tokio::test]
1721 async fn test_environment_capture_edge_cases() {
1722 let empty_script = r"
1724export EMPTY_VAR=''
1725export SPACE_VAR=' '
1726";
1727
1728 let result = evaluate_shell_environment(empty_script, &HashMap::new()).await;
1729 assert!(result.is_ok(), "Should handle empty/whitespace values");
1730 let (_env_vars, _removed) = result.unwrap();
1731
1732 let long_value = "x".repeat(10000);
1734 let long_script = format!("export LONG_VAR='{}'", long_value);
1735
1736 let result = evaluate_shell_environment(&long_script, &HashMap::new()).await;
1737 assert!(result.is_ok(), "Should handle very long values");
1738
1739 let (env_vars, _removed) = result.unwrap();
1740 if let Some(value) = env_vars.get("LONG_VAR") {
1741 assert_eq!(value.len(), 10000, "Should preserve full length");
1742 }
1743 }
1744
1745 #[tokio::test]
1747 async fn test_environment_prior_env_chaining() {
1748 let mut prior_env = HashMap::new();
1750 prior_env.insert("CUENV_TEST_PRIOR".to_string(), "original_value".to_string());
1751
1752 let script = r#"export CUENV_TEST_PRIOR="extended_${CUENV_TEST_PRIOR}""#;
1753 let result = evaluate_shell_environment(script, &prior_env).await;
1754 assert!(
1755 result.is_ok(),
1756 "Should evaluate with prior_env: {:?}",
1757 result.as_ref().err()
1758 );
1759
1760 let (env_vars, _removed) = result.unwrap();
1761 if let Some(value) = env_vars.get("CUENV_TEST_PRIOR") {
1762 assert!(
1763 value.contains("extended_"),
1764 "Value should contain extended_ prefix: {}",
1765 value
1766 );
1767 assert!(
1768 value.contains("original_value"),
1769 "Value should contain original_value from prior_env: {}",
1770 value
1771 );
1772 } else {
1773 panic!("CUENV_TEST_PRIOR should be in env_vars delta since it was modified");
1774 }
1775
1776 let mut prior_env = HashMap::new();
1778 prior_env.insert("CUENV_TEST_REMOVE".to_string(), "bar".to_string());
1779
1780 let script = "unset CUENV_TEST_REMOVE";
1781 let result = evaluate_shell_environment(script, &prior_env).await;
1782 assert!(result.is_ok(), "Should evaluate unset script");
1783
1784 let (env_vars, removed) = result.unwrap();
1785 assert!(
1786 !env_vars.contains_key("CUENV_TEST_REMOVE"),
1787 "Unset variable should not appear in env_vars"
1788 );
1789 assert!(
1790 removed.contains(&"CUENV_TEST_REMOVE".to_string()),
1791 "Unset variable should appear in removed_keys: {:?}",
1792 removed
1793 );
1794 }
1795
1796 #[tokio::test]
1798 async fn test_working_directory_isolation() {
1799 let executor = HookExecutor::with_default_config().unwrap();
1800
1801 let temp_dir1 = TempDir::new().unwrap();
1803 let temp_dir2 = TempDir::new().unwrap();
1804
1805 std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1807 std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1808
1809 let hook1 = Hook {
1811 order: 100,
1812 propagate: false,
1813 command: "cat".to_string(),
1814 args: vec!["marker.txt".to_string()],
1815 dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1816 inputs: vec![],
1817 source: None,
1818 };
1819
1820 let hook2 = Hook {
1821 order: 100,
1822 propagate: false,
1823 command: "cat".to_string(),
1824 args: vec!["marker.txt".to_string()],
1825 dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1826 inputs: vec![],
1827 source: None,
1828 };
1829
1830 let result1 = executor.execute_single_hook(hook1).await.unwrap();
1831 let result2 = executor.execute_single_hook(hook2).await.unwrap();
1832
1833 assert!(result1.success, "Hook 1 should succeed");
1834 assert!(result2.success, "Hook 2 should succeed");
1835
1836 assert!(
1837 result1.stdout.contains("dir1"),
1838 "Hook 1 should read from dir1: {}",
1839 result1.stdout
1840 );
1841 assert!(
1842 result2.stdout.contains("dir2"),
1843 "Hook 2 should read from dir2: {}",
1844 result2.stdout
1845 );
1846 }
1847
1848 #[tokio::test]
1850 async fn test_stderr_capture() {
1851 let executor = HookExecutor::with_default_config().unwrap();
1852
1853 let hook = Hook {
1855 order: 100,
1856 propagate: false,
1857 command: "bash".to_string(),
1858 args: vec![
1859 "-c".to_string(),
1860 "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1861 ],
1862 dir: None,
1863 inputs: vec![],
1864 source: None,
1865 };
1866
1867 let result = executor.execute_single_hook(hook).await.unwrap();
1868
1869 assert!(result.success, "Hook should succeed");
1870 assert!(
1871 result.stdout.contains("to stdout"),
1872 "Should capture stdout: {}",
1873 result.stdout
1874 );
1875 assert!(
1876 result.stderr.contains("to stderr"),
1877 "Should capture stderr: {}",
1878 result.stderr
1879 );
1880 }
1881
1882 #[tokio::test]
1884 async fn test_binary_output_handling() {
1885 let executor = HookExecutor::with_default_config().unwrap();
1886
1887 let hook = Hook {
1889 order: 100,
1890 propagate: false,
1891 command: "bash".to_string(),
1892 args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1893 dir: None,
1894 inputs: vec![],
1895 source: None,
1896 };
1897
1898 let result = executor.execute_single_hook(hook).await.unwrap();
1899
1900 assert!(result.success, "Hook should succeed");
1902 assert!(
1904 result.stdout.contains("hello") && result.stdout.contains("world"),
1905 "Should contain text parts: {}",
1906 result.stdout
1907 );
1908 }
1909}