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
620pub async fn capture_source_environment(
626 hook: Hook,
627 prior_env: &HashMap<String, String>,
628 timeout_seconds: u64,
629) -> Result<HashMap<String, String>> {
630 if !hook.source.unwrap_or(false) {
631 return Err(Error::configuration(
632 "capture_source_environment requires a source hook",
633 ));
634 }
635
636 let hook_result = execute_hook_with_timeout(hook, &timeout_seconds).await?;
637 let (env_delta, removed_keys) =
638 evaluate_shell_environment(&hook_result.stdout, prior_env).await?;
639
640 let mut environment = prior_env.clone();
641 for (key, value) in env_delta {
642 environment.insert(key, value);
643 }
644 for key in removed_keys {
645 environment.remove(&key);
646 }
647
648 Ok(environment)
649}
650
651fn find_env_command() -> String {
654 let path_var = std::env::var_os("PATH").unwrap_or_default();
655 for dir in std::env::split_paths(&path_var) {
656 let candidate = dir.join("env");
657 if candidate.is_file() {
658 return candidate.to_string_lossy().into_owned();
659 }
660 }
661 "/usr/bin/env".to_string()
662}
663
664async fn detect_shell() -> String {
666 if is_shell_capable("bash").await {
668 return "bash".to_string();
669 }
670
671 if is_shell_capable("zsh").await {
673 return "zsh".to_string();
674 }
675
676 "sh".to_string()
678}
679
680async fn is_shell_capable(shell: &str) -> bool {
682 let check_script = "case x in x) true ;& y) true ;; esac";
683 match Command::new(shell)
684 .arg("-c")
685 .arg(check_script)
686 .output()
687 .await
688 {
689 Ok(output) => output.status.success(),
690 Err(_) => false,
691 }
692}
693
694async fn evaluate_shell_environment(
696 shell_script: &str,
697 prior_env: &HashMap<String, String>,
698) -> Result<(HashMap<String, String>, Vec<String>)> {
699 const DELIMITER: &str = "__CUENV_ENV_START__";
700
701 debug!(
702 "Evaluating shell script to extract environment ({} bytes)",
703 shell_script.len()
704 );
705
706 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
707
708 let mut shell = detect_shell().await;
711
712 for line in shell_script.lines() {
713 if let Some(path) = line.strip_prefix("BASH='")
714 && let Some(end) = path.find('\'')
715 {
716 let bash_path = &path[..end];
717 let path = PathBuf::from(bash_path);
718 if path.exists() {
719 debug!("Detected Nix bash in script: {}", bash_path);
720 shell = bash_path.to_string();
721 break;
722 }
723 }
724 }
725
726 debug!("Using shell: {}", shell);
727
728 let env_cmd = find_env_command();
729
730 let mut cmd_before = Command::new(&shell);
732 cmd_before.arg("-c");
733 cmd_before.arg(format!("{env_cmd} -0"));
734 cmd_before.stdout(Stdio::piped());
735 cmd_before.stderr(Stdio::piped());
736 for (key, value) in prior_env {
738 cmd_before.env(key, value);
739 }
740
741 let output_before = cmd_before
742 .output()
743 .await
744 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
745
746 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
747 let mut env_before = HashMap::new();
748 for line in env_before_output.split('\0') {
749 if let Some((key, value)) = line.split_once('=') {
750 env_before.insert(key.to_string(), value.to_string());
751 }
752 }
753
754 let filtered_lines: Vec<&str> = shell_script
756 .lines()
757 .filter(|line| {
758 let trimmed = line.trim();
759 if trimmed.is_empty() {
760 return false;
761 }
762
763 if trimmed.starts_with("✓")
765 || trimmed.starts_with("sh:")
766 || trimmed.starts_with("bash:")
767 {
768 return false;
769 }
770
771 true
774 })
775 .collect();
776
777 let filtered_script = filtered_lines.join("\n");
778 tracing::trace!("Filtered shell script:\n{}", filtered_script);
779
780 let mut cmd = Command::new(shell);
782 cmd.arg("-c");
783
784 let script = format!(
785 "{}\necho -ne '\\0{}\\0'; {env_cmd} -0",
786 filtered_script, DELIMITER
787 );
788 cmd.arg(script);
789 cmd.stdout(Stdio::piped());
790 cmd.stderr(Stdio::piped());
791 for (key, value) in prior_env {
793 cmd.env(key, value);
794 }
795
796 let output = cmd.output().await.map_err(|e| {
797 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
798 })?;
799
800 if !output.status.success() {
803 let stderr = String::from_utf8_lossy(&output.stderr);
804 warn!(
805 "Shell script evaluation finished with error (exit code {:?}): {}",
806 output.status.code(),
807 stderr
808 );
809 }
811
812 let stdout_bytes = &output.stdout;
814 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
815
816 let env_start_index = stdout_bytes
818 .windows(delimiter_bytes.len())
819 .position(|window| window == delimiter_bytes);
820
821 let env_output_bytes = if let Some(idx) = env_start_index {
822 &stdout_bytes[idx + delimiter_bytes.len()..]
824 } else {
825 debug!("Environment delimiter not found in hook output");
826 let len = stdout_bytes.len();
828 let start = len.saturating_sub(1000);
829 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
830 warn!(
831 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
832 tail
833 );
834
835 &[]
837 };
838
839 let env_output = String::from_utf8_lossy(env_output_bytes);
840 let mut env_delta = HashMap::new();
841 let mut post_env_keys = std::collections::HashSet::new();
842
843 let is_skip_key = |key: &str| -> bool {
844 key.starts_with("BASH_FUNC_")
845 || key == "PS1"
846 || key == "PS2"
847 || key == "_"
848 || key == "PWD"
849 || key == "OLDPWD"
850 || key == "SHLVL"
851 || key.starts_with("BASH")
852 };
853
854 for line in env_output.split('\0') {
855 if line.is_empty() {
856 continue;
857 }
858
859 if let Some((key, value)) = line.split_once('=') {
860 if is_skip_key(key) {
861 continue;
862 }
863
864 if !key.is_empty() {
865 post_env_keys.insert(key.to_string());
866 }
867
868 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
871 env_delta.insert(key.to_string(), value.to_string());
872 }
873 }
874 }
875
876 let removed_keys: Vec<String> = prior_env
878 .keys()
879 .filter(|key| !is_skip_key(key) && !post_env_keys.contains(key.as_str()))
880 .cloned()
881 .collect();
882
883 if env_delta.is_empty() && removed_keys.is_empty() && !output.status.success() {
884 let stderr = String::from_utf8_lossy(&output.stderr);
886 return Err(Error::configuration(format!(
887 "Shell script evaluation failed and no environment captured. Error: {}",
888 stderr
889 )));
890 }
891
892 debug!(
893 "Evaluated shell script and extracted {} new/changed environment variables ({} removed)",
894 env_delta.len(),
895 removed_keys.len()
896 );
897 Ok((env_delta, removed_keys))
898}
899
900async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
902 let start_time = Instant::now();
903
904 debug!(
905 "Executing hook: {} {} (source: {})",
906 hook.command,
907 hook.args.join(" "),
908 hook.source.unwrap_or(false)
909 );
910
911 let mut cmd = Command::new(&hook.command);
913 cmd.args(&hook.args);
914 cmd.stdout(Stdio::piped());
915 cmd.stderr(Stdio::piped());
916
917 if let Some(dir) = &hook.dir {
919 cmd.current_dir(dir);
920 }
921
922 if hook.source.unwrap_or(false) {
925 cmd.env("SHELL", detect_shell().await);
926 }
927
928 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
930
931 #[expect(
933 clippy::cast_possible_truncation,
934 reason = "u128 to u64 truncation is acceptable for duration"
935 )]
936 let duration_ms = start_time.elapsed().as_millis() as u64;
937
938 match execution_result {
939 Ok(Ok(output)) => {
940 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
941 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
942
943 if output.status.success() {
944 debug!("Hook completed successfully in {}ms", duration_ms);
945 Ok(HookResult::success(
946 hook,
947 output.status,
948 stdout,
949 stderr,
950 duration_ms,
951 ))
952 } else {
953 warn!("Hook failed with exit code: {:?}", output.status.code());
954 Ok(HookResult::failure(
955 hook,
956 Some(output.status),
957 stdout,
958 stderr,
959 duration_ms,
960 format!("Command exited with status: {}", output.status),
961 ))
962 }
963 }
964 Ok(Err(io_error)) => {
965 error!("Failed to execute hook: {}", io_error);
966 Ok(HookResult::failure(
967 hook,
968 None,
969 String::new(),
970 String::new(),
971 duration_ms,
972 format!("Failed to execute command: {}", io_error),
973 ))
974 }
975 Err(_timeout_error) => {
976 warn!("Hook timed out after {} seconds", timeout_seconds);
977 Ok(HookResult::timeout(
978 hook,
979 String::new(),
980 String::new(),
981 *timeout_seconds,
982 ))
983 }
984 }
985}
986
987#[cfg(test)]
988#[expect(
989 clippy::print_stderr,
990 reason = "Tests may use eprintln! to report skip conditions"
991)]
992mod tests {
993 use super::*;
994 use crate::types::Hook;
995 use tempfile::TempDir;
996
997 fn setup_cuenv_executable() -> Option<PathBuf> {
1000 if std::env::var("CUENV_EXECUTABLE").is_ok() {
1002 return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
1003 }
1004
1005 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1007 let workspace_root = manifest_dir.parent()?.parent()?;
1008 let cuenv_binary = workspace_root.join("target/debug/cuenv");
1009
1010 if cuenv_binary.exists() {
1011 #[expect(
1014 unsafe_code,
1015 reason = "Test helper setting env var in controlled test environment"
1016 )]
1017 unsafe {
1018 std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
1019 }
1020 Some(cuenv_binary)
1021 } else {
1022 None
1023 }
1024 }
1025
1026 #[tokio::test]
1027 async fn test_hook_executor_creation() {
1028 let temp_dir = TempDir::new().unwrap();
1029 let config = HookExecutionConfig {
1030 default_timeout_seconds: 60,
1031 fail_fast: true,
1032 state_dir: Some(temp_dir.path().to_path_buf()),
1033 };
1034
1035 let executor = HookExecutor::new(config).unwrap();
1036 assert_eq!(executor.config.default_timeout_seconds, 60);
1037 }
1038
1039 #[tokio::test]
1040 async fn test_execute_single_hook_success() {
1041 let executor = HookExecutor::with_default_config().unwrap();
1042
1043 let hook = Hook {
1044 order: 100,
1045 propagate: false,
1046 command: "echo".to_string(),
1047 args: vec!["hello".to_string()],
1048 dir: None,
1049 inputs: vec![],
1050 source: None,
1051 };
1052
1053 let result = executor.execute_single_hook(hook).await.unwrap();
1054 assert!(result.success);
1055 assert!(result.stdout.contains("hello"));
1056 }
1057
1058 #[tokio::test]
1059 async fn test_execute_single_hook_failure() {
1060 let executor = HookExecutor::with_default_config().unwrap();
1061
1062 let hook = Hook {
1063 order: 100,
1064 propagate: false,
1065 command: "false".to_string(), args: vec![],
1067 dir: None,
1068 inputs: Vec::new(),
1069 source: Some(false),
1070 };
1071
1072 let result = executor.execute_single_hook(hook).await.unwrap();
1073 assert!(!result.success);
1074 assert!(result.exit_status.is_some());
1075 assert_ne!(result.exit_status.unwrap(), 0);
1076 }
1077
1078 #[tokio::test]
1079 async fn test_execute_single_hook_timeout() {
1080 let temp_dir = TempDir::new().unwrap();
1081 let config = HookExecutionConfig {
1082 default_timeout_seconds: 1, fail_fast: true,
1084 state_dir: Some(temp_dir.path().to_path_buf()),
1085 };
1086 let executor = HookExecutor::new(config).unwrap();
1087
1088 let hook = Hook {
1089 order: 100,
1090 propagate: false,
1091 command: "sleep".to_string(),
1092 args: vec!["10".to_string()], dir: None,
1094 inputs: Vec::new(),
1095 source: Some(false),
1096 };
1097
1098 let result = executor.execute_single_hook(hook).await.unwrap();
1099 assert!(!result.success);
1100 assert!(result.error.as_ref().unwrap().contains("timed out"));
1101 }
1102
1103 #[tokio::test]
1104 async fn test_background_execution() {
1105 let temp_dir = TempDir::new().unwrap();
1106 let config = HookExecutionConfig {
1107 default_timeout_seconds: 30,
1108 fail_fast: true,
1109 state_dir: Some(temp_dir.path().to_path_buf()),
1110 };
1111
1112 let executor = HookExecutor::new(config).unwrap();
1113 let directory_path = PathBuf::from("/test/directory");
1114 let config_hash = "test_hash".to_string();
1115
1116 let hooks = vec![
1117 Hook {
1118 order: 100,
1119 propagate: false,
1120 command: "echo".to_string(),
1121 args: vec!["hook1".to_string()],
1122 dir: None,
1123 inputs: Vec::new(),
1124 source: Some(false),
1125 },
1126 Hook {
1127 order: 100,
1128 propagate: false,
1129 command: "echo".to_string(),
1130 args: vec!["hook2".to_string()],
1131 dir: None,
1132 inputs: Vec::new(),
1133 source: Some(false),
1134 },
1135 ];
1136
1137 let result = executor
1138 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1139 .await
1140 .unwrap();
1141
1142 assert!(result.contains("Started execution of 2 hooks"));
1143
1144 tokio::time::sleep(Duration::from_millis(100)).await;
1146
1147 let status = executor
1149 .get_execution_status_for_instance(&directory_path, &config_hash)
1150 .await
1151 .unwrap();
1152 assert!(status.is_some());
1153
1154 let state = status.unwrap();
1155 assert_eq!(state.total_hooks, 2);
1156 assert_eq!(state.directory_path, directory_path);
1157 }
1158
1159 #[tokio::test]
1160 async fn test_command_validation() {
1161 let executor = HookExecutor::with_default_config().unwrap();
1162
1163 let hook = Hook {
1168 order: 100,
1169 propagate: false,
1170 command: "echo".to_string(),
1171 args: vec!["test message".to_string()],
1172 dir: None,
1173 inputs: Vec::new(),
1174 source: Some(false),
1175 };
1176
1177 let result = executor.execute_single_hook(hook).await;
1178 assert!(result.is_ok(), "Echo command should succeed");
1179
1180 let hook_result = result.unwrap();
1182 assert!(hook_result.stdout.contains("test message"));
1183 }
1184
1185 #[tokio::test]
1186 async fn test_cancellation() {
1187 if setup_cuenv_executable().is_none() {
1189 eprintln!("Skipping test_cancellation: cuenv binary not found");
1190 return;
1191 }
1192
1193 let temp_dir = TempDir::new().unwrap();
1194 let config = HookExecutionConfig {
1195 default_timeout_seconds: 30,
1196 fail_fast: false,
1197 state_dir: Some(temp_dir.path().to_path_buf()),
1198 };
1199
1200 let executor = HookExecutor::new(config).unwrap();
1201 let directory_path = PathBuf::from("/test/cancel");
1202 let config_hash = "cancel_test".to_string();
1203
1204 let hooks = vec![Hook {
1206 order: 100,
1207 propagate: false,
1208 command: "sleep".to_string(),
1209 args: vec!["10".to_string()],
1210 dir: None,
1211 inputs: Vec::new(),
1212 source: Some(false),
1213 }];
1214
1215 executor
1216 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1217 .await
1218 .unwrap();
1219
1220 let mut started = false;
1223 for _ in 0..20 {
1224 tokio::time::sleep(Duration::from_millis(100)).await;
1225 if let Ok(Some(state)) = executor
1226 .get_execution_status_for_instance(&directory_path, &config_hash)
1227 .await
1228 && state.status == ExecutionStatus::Running
1229 {
1230 started = true;
1231 break;
1232 }
1233 }
1234
1235 if !started {
1236 eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1237 return;
1238 }
1239
1240 let cancelled = executor
1242 .cancel_execution(
1243 &directory_path,
1244 &config_hash,
1245 Some("User cancelled".to_string()),
1246 )
1247 .await
1248 .unwrap();
1249 assert!(cancelled);
1250
1251 let state = executor
1253 .get_execution_status_for_instance(&directory_path, &config_hash)
1254 .await
1255 .unwrap()
1256 .unwrap();
1257 assert_eq!(state.status, ExecutionStatus::Cancelled);
1258 }
1259
1260 #[tokio::test]
1261 async fn test_large_output_handling() {
1262 let executor = HookExecutor::with_default_config().unwrap();
1263
1264 let large_content = "x".repeat(1000); let mut args = Vec::new();
1268 for i in 0..100 {
1270 args.push(format!("Line {}: {}", i, large_content));
1271 }
1272
1273 let hook = Hook {
1275 order: 100,
1276 propagate: false,
1277 command: "echo".to_string(),
1278 args,
1279 dir: None,
1280 inputs: Vec::new(),
1281 source: Some(false),
1282 };
1283
1284 let result = executor.execute_single_hook(hook).await.unwrap();
1285 assert!(result.success);
1286 assert!(result.stdout.len() > 50_000); }
1289
1290 #[tokio::test]
1291 async fn test_state_cleanup() {
1292 if setup_cuenv_executable().is_none() {
1294 eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1295 return;
1296 }
1297
1298 let temp_dir = TempDir::new().unwrap();
1299 let config = HookExecutionConfig {
1300 default_timeout_seconds: 30,
1301 fail_fast: false,
1302 state_dir: Some(temp_dir.path().to_path_buf()),
1303 };
1304
1305 let executor = HookExecutor::new(config).unwrap();
1306 let directory_path = PathBuf::from("/test/cleanup");
1307 let config_hash = "cleanup_test".to_string();
1308
1309 let hooks = vec![Hook {
1311 order: 100,
1312 propagate: false,
1313 command: "echo".to_string(),
1314 args: vec!["test".to_string()],
1315 dir: None,
1316 inputs: Vec::new(),
1317 source: Some(false),
1318 }];
1319
1320 executor
1321 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1322 .await
1323 .unwrap();
1324
1325 let mut state_exists = false;
1327 for _ in 0..20 {
1328 tokio::time::sleep(Duration::from_millis(100)).await;
1329 if executor
1330 .get_execution_status_for_instance(&directory_path, &config_hash)
1331 .await
1332 .unwrap()
1333 .is_some()
1334 {
1335 state_exists = true;
1336 break;
1337 }
1338 }
1339
1340 if !state_exists {
1341 eprintln!("Warning: State never created, skipping cleanup test");
1342 return;
1343 }
1344
1345 if let Err(e) = executor
1347 .wait_for_completion(&directory_path, &config_hash, Some(15))
1348 .await
1349 {
1350 eprintln!(
1351 "Warning: wait_for_completion timed out: {}, skipping test",
1352 e
1353 );
1354 return;
1355 }
1356
1357 let cleaned = executor
1359 .cleanup_old_states(chrono::Duration::seconds(0))
1360 .await
1361 .unwrap();
1362 assert_eq!(cleaned, 1);
1363
1364 let state = executor
1366 .get_execution_status_for_instance(&directory_path, &config_hash)
1367 .await
1368 .unwrap();
1369 assert!(state.is_none());
1370 }
1371
1372 #[tokio::test]
1373 async fn test_execution_state_tracking() {
1374 let temp_dir = TempDir::new().unwrap();
1375 let config = HookExecutionConfig {
1376 default_timeout_seconds: 30,
1377 fail_fast: true,
1378 state_dir: Some(temp_dir.path().to_path_buf()),
1379 };
1380
1381 let executor = HookExecutor::new(config).unwrap();
1382 let directory_path = PathBuf::from("/test/directory");
1383 let config_hash = "hash".to_string();
1384
1385 let status = executor
1387 .get_execution_status_for_instance(&directory_path, &config_hash)
1388 .await
1389 .unwrap();
1390 assert!(status.is_none());
1391
1392 let hooks = vec![Hook {
1394 order: 100,
1395 propagate: false,
1396 command: "echo".to_string(),
1397 args: vec!["test".to_string()],
1398 dir: None,
1399 inputs: Vec::new(),
1400 source: Some(false),
1401 }];
1402
1403 executor
1404 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1405 .await
1406 .unwrap();
1407
1408 let status = executor
1410 .get_execution_status_for_instance(&directory_path, &config_hash)
1411 .await
1412 .unwrap();
1413 assert!(status.is_some());
1414 }
1415
1416 #[tokio::test]
1417 async fn test_working_directory_handling() {
1418 let executor = HookExecutor::with_default_config().unwrap();
1419 let temp_dir = TempDir::new().unwrap();
1420
1421 let hook_with_valid_dir = Hook {
1423 order: 100,
1424 propagate: false,
1425 command: "pwd".to_string(),
1426 args: vec![],
1427 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1428 inputs: vec![],
1429 source: None,
1430 };
1431
1432 let result = executor
1433 .execute_single_hook(hook_with_valid_dir)
1434 .await
1435 .unwrap();
1436 assert!(result.success);
1437 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1438
1439 let hook_with_invalid_dir = Hook {
1441 order: 100,
1442 propagate: false,
1443 command: "pwd".to_string(),
1444 args: vec![],
1445 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1446 inputs: vec![],
1447 source: None,
1448 };
1449
1450 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1451 if let Ok(output) = result {
1454 assert!(
1456 !output
1457 .stdout
1458 .contains("/nonexistent/directory/that/does/not/exist")
1459 );
1460 }
1461 }
1462
1463 #[tokio::test]
1464 async fn test_hook_execution_with_complex_output() {
1465 let executor = HookExecutor::with_default_config().unwrap();
1466
1467 let hook = Hook {
1469 order: 100,
1470 propagate: false,
1471 command: "echo".to_string(),
1472 args: vec!["stdout output".to_string()],
1473 dir: None,
1474 inputs: vec![],
1475 source: None,
1476 };
1477
1478 let result = executor.execute_single_hook(hook).await.unwrap();
1479 assert!(result.success);
1480 assert!(result.stdout.contains("stdout output"));
1481
1482 let hook_with_exit_code = Hook {
1484 order: 100,
1485 propagate: false,
1486 command: "false".to_string(),
1487 args: vec![],
1488 dir: None,
1489 inputs: Vec::new(),
1490 source: Some(false),
1491 };
1492
1493 let result = executor
1494 .execute_single_hook(hook_with_exit_code)
1495 .await
1496 .unwrap();
1497 assert!(!result.success);
1498 assert!(result.exit_status.is_some());
1500 }
1501
1502 #[tokio::test]
1503 async fn test_state_dir_getter() {
1504 use crate::state::StateManager;
1505
1506 let temp_dir = TempDir::new().unwrap();
1507 let state_dir = temp_dir.path().to_path_buf();
1508 let state_manager = StateManager::new(state_dir.clone());
1509
1510 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1511 }
1512
1513 #[tokio::test]
1518 async fn test_hook_timeout_behavior() {
1519 let temp_dir = TempDir::new().unwrap();
1520
1521 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 slow_hook = Hook {
1531 order: 100,
1532 propagate: false,
1533 command: "sleep".to_string(),
1534 args: vec!["30".to_string()],
1535 dir: None,
1536 inputs: Vec::new(),
1537 source: Some(false),
1538 };
1539
1540 let result = executor.execute_single_hook(slow_hook).await.unwrap();
1541
1542 assert!(!result.success, "Hook should fail due to timeout");
1544 assert!(
1545 result.error.is_some(),
1546 "Should have error message on timeout"
1547 );
1548 let error_msg = result.error.as_ref().unwrap();
1549 assert!(
1550 error_msg.contains("timed out"),
1551 "Error should mention timeout: {}",
1552 error_msg
1553 );
1554 assert!(
1555 error_msg.contains('1'),
1556 "Error should mention timeout duration: {}",
1557 error_msg
1558 );
1559
1560 assert!(
1562 result.exit_status.is_none(),
1563 "Exit status should be None for timed out process"
1564 );
1565
1566 assert!(
1568 result.duration_ms >= 1000,
1569 "Duration should be at least 1 second"
1570 );
1571 assert!(
1572 result.duration_ms < 5000,
1573 "Duration should not be much longer than timeout"
1574 );
1575 }
1576
1577 #[tokio::test]
1579 async fn test_hook_timeout_with_partial_output() {
1580 let temp_dir = TempDir::new().unwrap();
1581
1582 let config = HookExecutionConfig {
1583 default_timeout_seconds: 1,
1584 fail_fast: true,
1585 state_dir: Some(temp_dir.path().to_path_buf()),
1586 };
1587 let executor = HookExecutor::new(config).unwrap();
1588
1589 let hook = Hook {
1592 order: 100,
1593 propagate: false,
1594 command: "bash".to_string(),
1595 args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1596 dir: None,
1597 inputs: Vec::new(),
1598 source: Some(false),
1599 };
1600
1601 let result = executor.execute_single_hook(hook).await.unwrap();
1602
1603 assert!(!result.success, "Hook should timeout");
1604 assert!(
1605 result.error.as_ref().unwrap().contains("timed out"),
1606 "Should indicate timeout"
1607 );
1608 }
1609
1610 #[tokio::test]
1613 async fn test_concurrent_hook_isolation() {
1614 use std::sync::Arc;
1615 use tokio::task::JoinSet;
1616
1617 let temp_dir = TempDir::new().unwrap();
1618 let config = HookExecutionConfig {
1619 default_timeout_seconds: 30,
1620 fail_fast: false,
1621 state_dir: Some(temp_dir.path().to_path_buf()),
1622 };
1623 let executor = Arc::new(HookExecutor::new(config).unwrap());
1624
1625 let mut join_set = JoinSet::new();
1626
1627 for i in 0..5 {
1629 let executor = executor.clone();
1630 let unique_id = format!("hook_{}", i);
1631
1632 join_set.spawn(async move {
1633 let hook = Hook {
1634 order: 100,
1635 propagate: false,
1636 command: "bash".to_string(),
1637 args: vec![
1638 "-c".to_string(),
1639 format!(
1640 "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1641 unique_id, unique_id
1642 ),
1643 ],
1644 dir: None,
1645 inputs: Vec::new(),
1646 source: Some(false),
1647 };
1648
1649 let result = executor.execute_single_hook(hook).await.unwrap();
1650 (i, result)
1651 });
1652 }
1653
1654 let mut results = Vec::new();
1656 while let Some(result) = join_set.join_next().await {
1657 results.push(result.unwrap());
1658 }
1659
1660 assert_eq!(results.len(), 5, "All 5 hooks should complete");
1662
1663 for (i, result) in results {
1664 assert!(result.success, "Hook {} should succeed", i);
1665
1666 let expected_id = format!("hook_{}", i);
1667 assert!(
1668 result.stdout.contains(&format!("ID:{}", expected_id)),
1669 "Hook {} output should contain its ID. Got: {}",
1670 i,
1671 result.stdout
1672 );
1673 assert!(
1674 result.stdout.contains(&format!("DONE:{}", expected_id)),
1675 "Hook {} output should contain its DONE marker. Got: {}",
1676 i,
1677 result.stdout
1678 );
1679
1680 for j in 0..5 {
1682 if j != i {
1683 let other_id = format!("hook_{}", j);
1684 assert!(
1685 !result.stdout.contains(&format!("ID:{}", other_id)),
1686 "Hook {} output should not contain hook {} ID",
1687 i,
1688 j
1689 );
1690 }
1691 }
1692 }
1693 }
1694
1695 #[tokio::test]
1700 async fn test_environment_capture_special_chars() {
1701 let multiline_script = r"
1703export MULTILINE_VAR='line1
1704line2
1705line3'
1706";
1707
1708 let result = evaluate_shell_environment(multiline_script, &HashMap::new()).await;
1709 assert!(result.is_ok(), "Should parse multiline env vars");
1710
1711 let (env_vars, _removed) = result.unwrap();
1712 if let Some(value) = env_vars.get("MULTILINE_VAR") {
1713 assert!(
1714 value.contains("line1"),
1715 "Should contain first line: {}",
1716 value
1717 );
1718 assert!(
1719 value.contains("line2"),
1720 "Should contain second line: {}",
1721 value
1722 );
1723 }
1724
1725 let unicode_script = r"
1727export UNICODE_VAR='Hello 世界 🌍 émoji'
1728export CHINESE_VAR='中文测试'
1729export JAPANESE_VAR='日本語テスト'
1730";
1731
1732 let result = evaluate_shell_environment(unicode_script, &HashMap::new()).await;
1733 assert!(result.is_ok(), "Should parse unicode env vars");
1734
1735 let (env_vars, _removed) = result.unwrap();
1736 if let Some(value) = env_vars.get("UNICODE_VAR") {
1737 assert!(
1738 value.contains("世界"),
1739 "Should preserve Chinese characters: {}",
1740 value
1741 );
1742 assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1743 }
1744
1745 let special_chars_script = r#"
1747export QUOTED_VAR="value with 'single' and \"double\" quotes"
1748export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1749export EQUALS_VAR="key=value=another"
1750"#;
1751
1752 let result = evaluate_shell_environment(special_chars_script, &HashMap::new()).await;
1753 assert!(result.is_ok(), "Should parse special chars");
1754
1755 let (env_vars, _removed) = result.unwrap();
1756 if let Some(value) = env_vars.get("EQUALS_VAR") {
1757 assert!(
1758 value.contains("key=value=another"),
1759 "Should preserve equals signs: {}",
1760 value
1761 );
1762 }
1763 }
1764
1765 #[tokio::test]
1767 async fn test_environment_capture_edge_cases() {
1768 let empty_script = r"
1770export EMPTY_VAR=''
1771export SPACE_VAR=' '
1772";
1773
1774 let result = evaluate_shell_environment(empty_script, &HashMap::new()).await;
1775 assert!(result.is_ok(), "Should handle empty/whitespace values");
1776 let (_env_vars, _removed) = result.unwrap();
1777
1778 let long_value = "x".repeat(10000);
1780 let long_script = format!("export LONG_VAR='{}'", long_value);
1781
1782 let result = evaluate_shell_environment(&long_script, &HashMap::new()).await;
1783 assert!(result.is_ok(), "Should handle very long values");
1784
1785 let (env_vars, _removed) = result.unwrap();
1786 if let Some(value) = env_vars.get("LONG_VAR") {
1787 assert_eq!(value.len(), 10000, "Should preserve full length");
1788 }
1789 }
1790
1791 #[tokio::test]
1793 async fn test_environment_prior_env_chaining() {
1794 let mut prior_env = HashMap::new();
1796 prior_env.insert("CUENV_TEST_PRIOR".to_string(), "original_value".to_string());
1797
1798 let script = r#"export CUENV_TEST_PRIOR="extended_${CUENV_TEST_PRIOR}""#;
1799 let result = evaluate_shell_environment(script, &prior_env).await;
1800 assert!(
1801 result.is_ok(),
1802 "Should evaluate with prior_env: {:?}",
1803 result.as_ref().err()
1804 );
1805
1806 let (env_vars, _removed) = result.unwrap();
1807 if let Some(value) = env_vars.get("CUENV_TEST_PRIOR") {
1808 assert!(
1809 value.contains("extended_"),
1810 "Value should contain extended_ prefix: {}",
1811 value
1812 );
1813 assert!(
1814 value.contains("original_value"),
1815 "Value should contain original_value from prior_env: {}",
1816 value
1817 );
1818 } else {
1819 panic!("CUENV_TEST_PRIOR should be in env_vars delta since it was modified");
1820 }
1821
1822 let mut prior_env = HashMap::new();
1824 prior_env.insert("CUENV_TEST_REMOVE".to_string(), "bar".to_string());
1825
1826 let script = "unset CUENV_TEST_REMOVE";
1827 let result = evaluate_shell_environment(script, &prior_env).await;
1828 assert!(result.is_ok(), "Should evaluate unset script");
1829
1830 let (env_vars, removed) = result.unwrap();
1831 assert!(
1832 !env_vars.contains_key("CUENV_TEST_REMOVE"),
1833 "Unset variable should not appear in env_vars"
1834 );
1835 assert!(
1836 removed.contains(&"CUENV_TEST_REMOVE".to_string()),
1837 "Unset variable should appear in removed_keys: {:?}",
1838 removed
1839 );
1840 }
1841
1842 #[tokio::test]
1844 async fn test_working_directory_isolation() {
1845 let executor = HookExecutor::with_default_config().unwrap();
1846
1847 let temp_dir1 = TempDir::new().unwrap();
1849 let temp_dir2 = TempDir::new().unwrap();
1850
1851 std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1853 std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1854
1855 let hook1 = Hook {
1857 order: 100,
1858 propagate: false,
1859 command: "cat".to_string(),
1860 args: vec!["marker.txt".to_string()],
1861 dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1862 inputs: vec![],
1863 source: None,
1864 };
1865
1866 let hook2 = Hook {
1867 order: 100,
1868 propagate: false,
1869 command: "cat".to_string(),
1870 args: vec!["marker.txt".to_string()],
1871 dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1872 inputs: vec![],
1873 source: None,
1874 };
1875
1876 let result1 = executor.execute_single_hook(hook1).await.unwrap();
1877 let result2 = executor.execute_single_hook(hook2).await.unwrap();
1878
1879 assert!(result1.success, "Hook 1 should succeed");
1880 assert!(result2.success, "Hook 2 should succeed");
1881
1882 assert!(
1883 result1.stdout.contains("dir1"),
1884 "Hook 1 should read from dir1: {}",
1885 result1.stdout
1886 );
1887 assert!(
1888 result2.stdout.contains("dir2"),
1889 "Hook 2 should read from dir2: {}",
1890 result2.stdout
1891 );
1892 }
1893
1894 #[tokio::test]
1896 async fn test_stderr_capture() {
1897 let executor = HookExecutor::with_default_config().unwrap();
1898
1899 let hook = Hook {
1901 order: 100,
1902 propagate: false,
1903 command: "bash".to_string(),
1904 args: vec![
1905 "-c".to_string(),
1906 "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1907 ],
1908 dir: None,
1909 inputs: vec![],
1910 source: None,
1911 };
1912
1913 let result = executor.execute_single_hook(hook).await.unwrap();
1914
1915 assert!(result.success, "Hook should succeed");
1916 assert!(
1917 result.stdout.contains("to stdout"),
1918 "Should capture stdout: {}",
1919 result.stdout
1920 );
1921 assert!(
1922 result.stderr.contains("to stderr"),
1923 "Should capture stderr: {}",
1924 result.stderr
1925 );
1926 }
1927
1928 #[tokio::test]
1930 async fn test_binary_output_handling() {
1931 let executor = HookExecutor::with_default_config().unwrap();
1932
1933 let hook = Hook {
1935 order: 100,
1936 propagate: false,
1937 command: "bash".to_string(),
1938 args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1939 dir: None,
1940 inputs: vec![],
1941 source: None,
1942 };
1943
1944 let result = executor.execute_single_hook(hook).await.unwrap();
1945
1946 assert!(result.success, "Hook should succeed");
1948 assert!(
1950 result.stdout.contains("hello") && result.stdout.contains("world"),
1951 "Should contain text parts: {}",
1952 result.stdout
1953 );
1954 }
1955
1956 #[tokio::test]
1957 async fn test_capture_source_environment_returns_resulting_env() {
1958 let hook = Hook {
1959 order: 100,
1960 propagate: false,
1961 command: "bash".to_string(),
1962 args: vec![
1963 "-c".to_string(),
1964 "printf '%s\n' 'export CUENV_RUNTIME_TEST=from_runtime'".to_string(),
1965 ],
1966 dir: None,
1967 inputs: vec![],
1968 source: Some(true),
1969 };
1970
1971 let environment = capture_source_environment(hook, &HashMap::new(), 5)
1972 .await
1973 .unwrap();
1974
1975 assert_eq!(
1976 environment.get("CUENV_RUNTIME_TEST"),
1977 Some(&"from_runtime".to_string())
1978 );
1979 }
1980}