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(&hook_result.stdout).await {
542 Ok(env_vars) => {
543 let count = env_vars.len();
544 debug!("Captured {} environment variables from source hook", count);
545 if count > 0 {
546 for (key, value) in env_vars {
548 state.environment_vars.insert(key, value);
549 }
550 }
551 }
552 Err(e) => {
553 warn!("Failed to evaluate source hook output: {}", e);
554 }
556 }
557 }
558 }
559
560 state.record_hook_result(index, hook_result.clone());
561 if !hook_result.success && config.fail_fast {
562 warn!(
563 "Hook {} failed and fail_fast is enabled, stopping",
564 index + 1
565 );
566 break;
567 }
568 }
569 Err(e) => {
570 let error_msg = format!("Hook execution error: {}", e);
571 state.record_hook_result(
572 index,
573 HookResult::failure(
574 hook.clone(),
575 None,
576 String::new(),
577 error_msg.clone(),
578 0,
579 error_msg,
580 ),
581 );
582 if config.fail_fast {
583 warn!("Hook {} failed with error, stopping", index + 1);
584 break;
585 }
586 }
587 }
588
589 state_manager.save_state(state).await?;
591 }
592
593 if state.status == ExecutionStatus::Running {
595 state.status = ExecutionStatus::Completed;
596 state.finished_at = Some(chrono::Utc::now());
597 info!(
598 "All hooks completed successfully for directory: {}",
599 state.directory_path.display()
600 );
601 }
602
603 state_manager.save_state(state).await?;
605
606 Ok(())
607}
608
609async fn detect_shell() -> String {
611 if is_shell_capable("bash").await {
613 return "bash".to_string();
614 }
615
616 if is_shell_capable("zsh").await {
618 return "zsh".to_string();
619 }
620
621 "sh".to_string()
623}
624
625async fn is_shell_capable(shell: &str) -> bool {
627 let check_script = "case x in x) true ;& y) true ;; esac";
628 match Command::new(shell)
629 .arg("-c")
630 .arg(check_script)
631 .output()
632 .await
633 {
634 Ok(output) => output.status.success(),
635 Err(_) => false,
636 }
637}
638
639async fn evaluate_shell_environment(shell_script: &str) -> Result<HashMap<String, String>> {
641 const DELIMITER: &str = "__CUENV_ENV_START__";
642
643 debug!(
644 "Evaluating shell script to extract environment ({} bytes)",
645 shell_script.len()
646 );
647
648 tracing::trace!("Raw shell script from hook:\n{}", shell_script);
649
650 let mut shell = detect_shell().await;
653
654 for line in shell_script.lines() {
655 if let Some(path) = line.strip_prefix("BASH='")
656 && let Some(end) = path.find('\'')
657 {
658 let bash_path = &path[..end];
659 let path = PathBuf::from(bash_path);
660 if path.exists() {
661 debug!("Detected Nix bash in script: {}", bash_path);
662 shell = bash_path.to_string();
663 break;
664 }
665 }
666 }
667
668 debug!("Using shell: {}", shell);
669
670 let mut cmd_before = Command::new(&shell);
672 cmd_before.arg("-c");
673 cmd_before.arg("env -0");
674 cmd_before.stdout(Stdio::piped());
675 cmd_before.stderr(Stdio::piped());
676
677 let output_before = cmd_before
678 .output()
679 .await
680 .map_err(|e| Error::configuration(format!("Failed to get initial environment: {}", e)))?;
681
682 let env_before_output = String::from_utf8_lossy(&output_before.stdout);
683 let mut env_before = HashMap::new();
684 for line in env_before_output.split('\0') {
685 if let Some((key, value)) = line.split_once('=') {
686 env_before.insert(key.to_string(), value.to_string());
687 }
688 }
689
690 let filtered_lines: Vec<&str> = shell_script
692 .lines()
693 .filter(|line| {
694 let trimmed = line.trim();
695 if trimmed.is_empty() {
696 return false;
697 }
698
699 if trimmed.starts_with("✓")
701 || trimmed.starts_with("sh:")
702 || trimmed.starts_with("bash:")
703 {
704 return false;
705 }
706
707 true
710 })
711 .collect();
712
713 let filtered_script = filtered_lines.join("\n");
714 tracing::trace!("Filtered shell script:\n{}", filtered_script);
715
716 let mut cmd = Command::new(shell);
718 cmd.arg("-c");
719
720 let script = format!(
721 "{}\necho -ne '\\0{}\\0'; env -0",
722 filtered_script, DELIMITER
723 );
724 cmd.arg(script);
725 cmd.stdout(Stdio::piped());
726 cmd.stderr(Stdio::piped());
727
728 let output = cmd.output().await.map_err(|e| {
729 Error::configuration(format!("Failed to evaluate shell environment: {}", e))
730 })?;
731
732 if !output.status.success() {
735 let stderr = String::from_utf8_lossy(&output.stderr);
736 warn!(
737 "Shell script evaluation finished with error (exit code {:?}): {}",
738 output.status.code(),
739 stderr
740 );
741 }
743
744 let stdout_bytes = &output.stdout;
746 let delimiter_bytes = format!("\0{}\0", DELIMITER).into_bytes();
747
748 let env_start_index = stdout_bytes
750 .windows(delimiter_bytes.len())
751 .position(|window| window == delimiter_bytes);
752
753 let env_output_bytes = if let Some(idx) = env_start_index {
754 &stdout_bytes[idx + delimiter_bytes.len()..]
756 } else {
757 debug!("Environment delimiter not found in hook output");
758 let len = stdout_bytes.len();
760 let start = len.saturating_sub(1000);
761 let tail = String::from_utf8_lossy(&stdout_bytes[start..]);
762 warn!(
763 "Delimiter missing. Tail of stdout (last 1000 bytes):\n{}",
764 tail
765 );
766
767 &[]
769 };
770
771 let env_output = String::from_utf8_lossy(env_output_bytes);
772 let mut env_delta = HashMap::new();
773
774 for line in env_output.split('\0') {
775 if line.is_empty() {
776 continue;
777 }
778
779 if let Some((key, value)) = line.split_once('=') {
780 if key.starts_with("BASH_FUNC_")
782 || key == "PS1"
783 || key == "PS2"
784 || key == "_"
785 || key == "PWD"
786 || key == "OLDPWD"
787 || key == "SHLVL"
788 || key.starts_with("BASH")
789 {
790 continue;
791 }
792
793 if !key.is_empty() && env_before.get(key) != Some(&value.to_string()) {
796 env_delta.insert(key.to_string(), value.to_string());
797 }
798 }
799 }
800
801 if env_delta.is_empty() && !output.status.success() {
802 let stderr = String::from_utf8_lossy(&output.stderr);
804 return Err(Error::configuration(format!(
805 "Shell script evaluation failed and no environment captured. Error: {}",
806 stderr
807 )));
808 }
809
810 debug!(
811 "Evaluated shell script and extracted {} new/changed environment variables",
812 env_delta.len()
813 );
814 Ok(env_delta)
815}
816
817async fn execute_hook_with_timeout(hook: Hook, timeout_seconds: &u64) -> Result<HookResult> {
819 let start_time = Instant::now();
820
821 debug!(
822 "Executing hook: {} {} (source: {})",
823 hook.command,
824 hook.args.join(" "),
825 hook.source.unwrap_or(false)
826 );
827
828 let mut cmd = Command::new(&hook.command);
830 cmd.args(&hook.args);
831 cmd.stdout(Stdio::piped());
832 cmd.stderr(Stdio::piped());
833
834 if let Some(dir) = &hook.dir {
836 cmd.current_dir(dir);
837 }
838
839 if hook.source.unwrap_or(false) {
842 cmd.env("SHELL", detect_shell().await);
843 }
844
845 let execution_result = timeout(Duration::from_secs(*timeout_seconds), cmd.output()).await;
847
848 #[expect(
850 clippy::cast_possible_truncation,
851 reason = "u128 to u64 truncation is acceptable for duration"
852 )]
853 let duration_ms = start_time.elapsed().as_millis() as u64;
854
855 match execution_result {
856 Ok(Ok(output)) => {
857 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
858 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
859
860 if output.status.success() {
861 debug!("Hook completed successfully in {}ms", duration_ms);
862 Ok(HookResult::success(
863 hook,
864 output.status,
865 stdout,
866 stderr,
867 duration_ms,
868 ))
869 } else {
870 warn!("Hook failed with exit code: {:?}", output.status.code());
871 Ok(HookResult::failure(
872 hook,
873 Some(output.status),
874 stdout,
875 stderr,
876 duration_ms,
877 format!("Command exited with status: {}", output.status),
878 ))
879 }
880 }
881 Ok(Err(io_error)) => {
882 error!("Failed to execute hook: {}", io_error);
883 Ok(HookResult::failure(
884 hook,
885 None,
886 String::new(),
887 String::new(),
888 duration_ms,
889 format!("Failed to execute command: {}", io_error),
890 ))
891 }
892 Err(_timeout_error) => {
893 warn!("Hook timed out after {} seconds", timeout_seconds);
894 Ok(HookResult::timeout(
895 hook,
896 String::new(),
897 String::new(),
898 *timeout_seconds,
899 ))
900 }
901 }
902}
903
904#[cfg(test)]
905#[expect(
906 clippy::print_stderr,
907 reason = "Tests may use eprintln! to report skip conditions"
908)]
909mod tests {
910 use super::*;
911 use crate::types::Hook;
912 use tempfile::TempDir;
913
914 fn setup_cuenv_executable() -> Option<PathBuf> {
917 if std::env::var("CUENV_EXECUTABLE").is_ok() {
919 return Some(PathBuf::from(std::env::var("CUENV_EXECUTABLE").unwrap()));
920 }
921
922 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
924 let workspace_root = manifest_dir.parent()?.parent()?;
925 let cuenv_binary = workspace_root.join("target/debug/cuenv");
926
927 if cuenv_binary.exists() {
928 #[expect(
931 unsafe_code,
932 reason = "Test helper setting env var in controlled test environment"
933 )]
934 unsafe {
935 std::env::set_var("CUENV_EXECUTABLE", &cuenv_binary);
936 }
937 Some(cuenv_binary)
938 } else {
939 None
940 }
941 }
942
943 #[tokio::test]
944 async fn test_hook_executor_creation() {
945 let temp_dir = TempDir::new().unwrap();
946 let config = HookExecutionConfig {
947 default_timeout_seconds: 60,
948 fail_fast: true,
949 state_dir: Some(temp_dir.path().to_path_buf()),
950 };
951
952 let executor = HookExecutor::new(config).unwrap();
953 assert_eq!(executor.config.default_timeout_seconds, 60);
954 }
955
956 #[tokio::test]
957 async fn test_execute_single_hook_success() {
958 let executor = HookExecutor::with_default_config().unwrap();
959
960 let hook = Hook {
961 order: 100,
962 propagate: false,
963 command: "echo".to_string(),
964 args: vec!["hello".to_string()],
965 dir: None,
966 inputs: vec![],
967 source: None,
968 };
969
970 let result = executor.execute_single_hook(hook).await.unwrap();
971 assert!(result.success);
972 assert!(result.stdout.contains("hello"));
973 }
974
975 #[tokio::test]
976 async fn test_execute_single_hook_failure() {
977 let executor = HookExecutor::with_default_config().unwrap();
978
979 let hook = Hook {
980 order: 100,
981 propagate: false,
982 command: "false".to_string(), args: vec![],
984 dir: None,
985 inputs: Vec::new(),
986 source: Some(false),
987 };
988
989 let result = executor.execute_single_hook(hook).await.unwrap();
990 assert!(!result.success);
991 assert!(result.exit_status.is_some());
992 assert_ne!(result.exit_status.unwrap(), 0);
993 }
994
995 #[tokio::test]
996 async fn test_execute_single_hook_timeout() {
997 let temp_dir = TempDir::new().unwrap();
998 let config = HookExecutionConfig {
999 default_timeout_seconds: 1, fail_fast: true,
1001 state_dir: Some(temp_dir.path().to_path_buf()),
1002 };
1003 let executor = HookExecutor::new(config).unwrap();
1004
1005 let hook = Hook {
1006 order: 100,
1007 propagate: false,
1008 command: "sleep".to_string(),
1009 args: vec!["10".to_string()], dir: None,
1011 inputs: Vec::new(),
1012 source: Some(false),
1013 };
1014
1015 let result = executor.execute_single_hook(hook).await.unwrap();
1016 assert!(!result.success);
1017 assert!(result.error.as_ref().unwrap().contains("timed out"));
1018 }
1019
1020 #[tokio::test]
1021 async fn test_background_execution() {
1022 let temp_dir = TempDir::new().unwrap();
1023 let config = HookExecutionConfig {
1024 default_timeout_seconds: 30,
1025 fail_fast: true,
1026 state_dir: Some(temp_dir.path().to_path_buf()),
1027 };
1028
1029 let executor = HookExecutor::new(config).unwrap();
1030 let directory_path = PathBuf::from("/test/directory");
1031 let config_hash = "test_hash".to_string();
1032
1033 let hooks = vec![
1034 Hook {
1035 order: 100,
1036 propagate: false,
1037 command: "echo".to_string(),
1038 args: vec!["hook1".to_string()],
1039 dir: None,
1040 inputs: Vec::new(),
1041 source: Some(false),
1042 },
1043 Hook {
1044 order: 100,
1045 propagate: false,
1046 command: "echo".to_string(),
1047 args: vec!["hook2".to_string()],
1048 dir: None,
1049 inputs: Vec::new(),
1050 source: Some(false),
1051 },
1052 ];
1053
1054 let result = executor
1055 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1056 .await
1057 .unwrap();
1058
1059 assert!(result.contains("Started execution of 2 hooks"));
1060
1061 tokio::time::sleep(Duration::from_millis(100)).await;
1063
1064 let status = executor
1066 .get_execution_status_for_instance(&directory_path, &config_hash)
1067 .await
1068 .unwrap();
1069 assert!(status.is_some());
1070
1071 let state = status.unwrap();
1072 assert_eq!(state.total_hooks, 2);
1073 assert_eq!(state.directory_path, directory_path);
1074 }
1075
1076 #[tokio::test]
1077 async fn test_command_validation() {
1078 let executor = HookExecutor::with_default_config().unwrap();
1079
1080 let hook = Hook {
1085 order: 100,
1086 propagate: false,
1087 command: "echo".to_string(),
1088 args: vec!["test message".to_string()],
1089 dir: None,
1090 inputs: Vec::new(),
1091 source: Some(false),
1092 };
1093
1094 let result = executor.execute_single_hook(hook).await;
1095 assert!(result.is_ok(), "Echo command should succeed");
1096
1097 let hook_result = result.unwrap();
1099 assert!(hook_result.stdout.contains("test message"));
1100 }
1101
1102 #[tokio::test]
1103 async fn test_cancellation() {
1104 if setup_cuenv_executable().is_none() {
1106 eprintln!("Skipping test_cancellation: cuenv binary not found");
1107 return;
1108 }
1109
1110 let temp_dir = TempDir::new().unwrap();
1111 let config = HookExecutionConfig {
1112 default_timeout_seconds: 30,
1113 fail_fast: false,
1114 state_dir: Some(temp_dir.path().to_path_buf()),
1115 };
1116
1117 let executor = HookExecutor::new(config).unwrap();
1118 let directory_path = PathBuf::from("/test/cancel");
1119 let config_hash = "cancel_test".to_string();
1120
1121 let hooks = vec![Hook {
1123 order: 100,
1124 propagate: false,
1125 command: "sleep".to_string(),
1126 args: vec!["10".to_string()],
1127 dir: None,
1128 inputs: Vec::new(),
1129 source: Some(false),
1130 }];
1131
1132 executor
1133 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1134 .await
1135 .unwrap();
1136
1137 let mut started = false;
1140 for _ in 0..20 {
1141 tokio::time::sleep(Duration::from_millis(100)).await;
1142 if let Ok(Some(state)) = executor
1143 .get_execution_status_for_instance(&directory_path, &config_hash)
1144 .await
1145 && state.status == ExecutionStatus::Running
1146 {
1147 started = true;
1148 break;
1149 }
1150 }
1151
1152 if !started {
1153 eprintln!("Warning: Supervisor didn't start in time, skipping cancellation test");
1154 return;
1155 }
1156
1157 let cancelled = executor
1159 .cancel_execution(
1160 &directory_path,
1161 &config_hash,
1162 Some("User cancelled".to_string()),
1163 )
1164 .await
1165 .unwrap();
1166 assert!(cancelled);
1167
1168 let state = executor
1170 .get_execution_status_for_instance(&directory_path, &config_hash)
1171 .await
1172 .unwrap()
1173 .unwrap();
1174 assert_eq!(state.status, ExecutionStatus::Cancelled);
1175 }
1176
1177 #[tokio::test]
1178 async fn test_large_output_handling() {
1179 let executor = HookExecutor::with_default_config().unwrap();
1180
1181 let large_content = "x".repeat(1000); let mut args = Vec::new();
1185 for i in 0..100 {
1187 args.push(format!("Line {}: {}", i, large_content));
1188 }
1189
1190 let hook = Hook {
1192 order: 100,
1193 propagate: false,
1194 command: "echo".to_string(),
1195 args,
1196 dir: None,
1197 inputs: Vec::new(),
1198 source: Some(false),
1199 };
1200
1201 let result = executor.execute_single_hook(hook).await.unwrap();
1202 assert!(result.success);
1203 assert!(result.stdout.len() > 50_000); }
1206
1207 #[tokio::test]
1208 async fn test_state_cleanup() {
1209 if setup_cuenv_executable().is_none() {
1211 eprintln!("Skipping test_state_cleanup: cuenv binary not found");
1212 return;
1213 }
1214
1215 let temp_dir = TempDir::new().unwrap();
1216 let config = HookExecutionConfig {
1217 default_timeout_seconds: 30,
1218 fail_fast: false,
1219 state_dir: Some(temp_dir.path().to_path_buf()),
1220 };
1221
1222 let executor = HookExecutor::new(config).unwrap();
1223 let directory_path = PathBuf::from("/test/cleanup");
1224 let config_hash = "cleanup_test".to_string();
1225
1226 let hooks = vec![Hook {
1228 order: 100,
1229 propagate: false,
1230 command: "echo".to_string(),
1231 args: vec!["test".to_string()],
1232 dir: None,
1233 inputs: Vec::new(),
1234 source: Some(false),
1235 }];
1236
1237 executor
1238 .execute_hooks_background(directory_path.clone(), config_hash.clone(), hooks)
1239 .await
1240 .unwrap();
1241
1242 let mut state_exists = false;
1244 for _ in 0..20 {
1245 tokio::time::sleep(Duration::from_millis(100)).await;
1246 if executor
1247 .get_execution_status_for_instance(&directory_path, &config_hash)
1248 .await
1249 .unwrap()
1250 .is_some()
1251 {
1252 state_exists = true;
1253 break;
1254 }
1255 }
1256
1257 if !state_exists {
1258 eprintln!("Warning: State never created, skipping cleanup test");
1259 return;
1260 }
1261
1262 if let Err(e) = executor
1264 .wait_for_completion(&directory_path, &config_hash, Some(15))
1265 .await
1266 {
1267 eprintln!(
1268 "Warning: wait_for_completion timed out: {}, skipping test",
1269 e
1270 );
1271 return;
1272 }
1273
1274 let cleaned = executor
1276 .cleanup_old_states(chrono::Duration::seconds(0))
1277 .await
1278 .unwrap();
1279 assert_eq!(cleaned, 1);
1280
1281 let state = executor
1283 .get_execution_status_for_instance(&directory_path, &config_hash)
1284 .await
1285 .unwrap();
1286 assert!(state.is_none());
1287 }
1288
1289 #[tokio::test]
1290 async fn test_execution_state_tracking() {
1291 let temp_dir = TempDir::new().unwrap();
1292 let config = HookExecutionConfig {
1293 default_timeout_seconds: 30,
1294 fail_fast: true,
1295 state_dir: Some(temp_dir.path().to_path_buf()),
1296 };
1297
1298 let executor = HookExecutor::new(config).unwrap();
1299 let directory_path = PathBuf::from("/test/directory");
1300 let config_hash = "hash".to_string();
1301
1302 let status = executor
1304 .get_execution_status_for_instance(&directory_path, &config_hash)
1305 .await
1306 .unwrap();
1307 assert!(status.is_none());
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 status = executor
1327 .get_execution_status_for_instance(&directory_path, &config_hash)
1328 .await
1329 .unwrap();
1330 assert!(status.is_some());
1331 }
1332
1333 #[tokio::test]
1334 async fn test_working_directory_handling() {
1335 let executor = HookExecutor::with_default_config().unwrap();
1336 let temp_dir = TempDir::new().unwrap();
1337
1338 let hook_with_valid_dir = Hook {
1340 order: 100,
1341 propagate: false,
1342 command: "pwd".to_string(),
1343 args: vec![],
1344 dir: Some(temp_dir.path().to_string_lossy().to_string()),
1345 inputs: vec![],
1346 source: None,
1347 };
1348
1349 let result = executor
1350 .execute_single_hook(hook_with_valid_dir)
1351 .await
1352 .unwrap();
1353 assert!(result.success);
1354 assert!(result.stdout.contains(temp_dir.path().to_str().unwrap()));
1355
1356 let hook_with_invalid_dir = Hook {
1358 order: 100,
1359 propagate: false,
1360 command: "pwd".to_string(),
1361 args: vec![],
1362 dir: Some("/nonexistent/directory/that/does/not/exist".to_string()),
1363 inputs: vec![],
1364 source: None,
1365 };
1366
1367 let result = executor.execute_single_hook(hook_with_invalid_dir).await;
1368 if let Ok(output) = result {
1371 assert!(
1373 !output
1374 .stdout
1375 .contains("/nonexistent/directory/that/does/not/exist")
1376 );
1377 }
1378 }
1379
1380 #[tokio::test]
1381 async fn test_hook_execution_with_complex_output() {
1382 let executor = HookExecutor::with_default_config().unwrap();
1383
1384 let hook = Hook {
1386 order: 100,
1387 propagate: false,
1388 command: "echo".to_string(),
1389 args: vec!["stdout output".to_string()],
1390 dir: None,
1391 inputs: vec![],
1392 source: None,
1393 };
1394
1395 let result = executor.execute_single_hook(hook).await.unwrap();
1396 assert!(result.success);
1397 assert!(result.stdout.contains("stdout output"));
1398
1399 let hook_with_exit_code = Hook {
1401 order: 100,
1402 propagate: false,
1403 command: "false".to_string(),
1404 args: vec![],
1405 dir: None,
1406 inputs: Vec::new(),
1407 source: Some(false),
1408 };
1409
1410 let result = executor
1411 .execute_single_hook(hook_with_exit_code)
1412 .await
1413 .unwrap();
1414 assert!(!result.success);
1415 assert!(result.exit_status.is_some());
1417 }
1418
1419 #[tokio::test]
1420 async fn test_state_dir_getter() {
1421 use crate::state::StateManager;
1422
1423 let temp_dir = TempDir::new().unwrap();
1424 let state_dir = temp_dir.path().to_path_buf();
1425 let state_manager = StateManager::new(state_dir.clone());
1426
1427 assert_eq!(state_manager.get_state_dir(), state_dir.as_path());
1428 }
1429
1430 #[tokio::test]
1435 async fn test_hook_timeout_behavior() {
1436 let temp_dir = TempDir::new().unwrap();
1437
1438 let config = HookExecutionConfig {
1440 default_timeout_seconds: 1,
1441 fail_fast: true,
1442 state_dir: Some(temp_dir.path().to_path_buf()),
1443 };
1444 let executor = HookExecutor::new(config).unwrap();
1445
1446 let slow_hook = Hook {
1448 order: 100,
1449 propagate: false,
1450 command: "sleep".to_string(),
1451 args: vec!["30".to_string()],
1452 dir: None,
1453 inputs: Vec::new(),
1454 source: Some(false),
1455 };
1456
1457 let result = executor.execute_single_hook(slow_hook).await.unwrap();
1458
1459 assert!(!result.success, "Hook should fail due to timeout");
1461 assert!(
1462 result.error.is_some(),
1463 "Should have error message on timeout"
1464 );
1465 let error_msg = result.error.as_ref().unwrap();
1466 assert!(
1467 error_msg.contains("timed out"),
1468 "Error should mention timeout: {}",
1469 error_msg
1470 );
1471 assert!(
1472 error_msg.contains('1'),
1473 "Error should mention timeout duration: {}",
1474 error_msg
1475 );
1476
1477 assert!(
1479 result.exit_status.is_none(),
1480 "Exit status should be None for timed out process"
1481 );
1482
1483 assert!(
1485 result.duration_ms >= 1000,
1486 "Duration should be at least 1 second"
1487 );
1488 assert!(
1489 result.duration_ms < 5000,
1490 "Duration should not be much longer than timeout"
1491 );
1492 }
1493
1494 #[tokio::test]
1496 async fn test_hook_timeout_with_partial_output() {
1497 let temp_dir = TempDir::new().unwrap();
1498
1499 let config = HookExecutionConfig {
1500 default_timeout_seconds: 1,
1501 fail_fast: true,
1502 state_dir: Some(temp_dir.path().to_path_buf()),
1503 };
1504 let executor = HookExecutor::new(config).unwrap();
1505
1506 let hook = Hook {
1509 order: 100,
1510 propagate: false,
1511 command: "bash".to_string(),
1512 args: vec!["-c".to_string(), "echo 'started'; sleep 30".to_string()],
1513 dir: None,
1514 inputs: Vec::new(),
1515 source: Some(false),
1516 };
1517
1518 let result = executor.execute_single_hook(hook).await.unwrap();
1519
1520 assert!(!result.success, "Hook should timeout");
1521 assert!(
1522 result.error.as_ref().unwrap().contains("timed out"),
1523 "Should indicate timeout"
1524 );
1525 }
1526
1527 #[tokio::test]
1530 async fn test_concurrent_hook_isolation() {
1531 use std::sync::Arc;
1532 use tokio::task::JoinSet;
1533
1534 let temp_dir = TempDir::new().unwrap();
1535 let config = HookExecutionConfig {
1536 default_timeout_seconds: 30,
1537 fail_fast: false,
1538 state_dir: Some(temp_dir.path().to_path_buf()),
1539 };
1540 let executor = Arc::new(HookExecutor::new(config).unwrap());
1541
1542 let mut join_set = JoinSet::new();
1543
1544 for i in 0..5 {
1546 let executor = executor.clone();
1547 let unique_id = format!("hook_{}", i);
1548
1549 join_set.spawn(async move {
1550 let hook = Hook {
1551 order: 100,
1552 propagate: false,
1553 command: "bash".to_string(),
1554 args: vec![
1555 "-c".to_string(),
1556 format!(
1557 "echo 'ID:{}'; sleep 0.1; echo 'DONE:{}'",
1558 unique_id, unique_id
1559 ),
1560 ],
1561 dir: None,
1562 inputs: Vec::new(),
1563 source: Some(false),
1564 };
1565
1566 let result = executor.execute_single_hook(hook).await.unwrap();
1567 (i, result)
1568 });
1569 }
1570
1571 let mut results = Vec::new();
1573 while let Some(result) = join_set.join_next().await {
1574 results.push(result.unwrap());
1575 }
1576
1577 assert_eq!(results.len(), 5, "All 5 hooks should complete");
1579
1580 for (i, result) in results {
1581 assert!(result.success, "Hook {} should succeed", i);
1582
1583 let expected_id = format!("hook_{}", i);
1584 assert!(
1585 result.stdout.contains(&format!("ID:{}", expected_id)),
1586 "Hook {} output should contain its ID. Got: {}",
1587 i,
1588 result.stdout
1589 );
1590 assert!(
1591 result.stdout.contains(&format!("DONE:{}", expected_id)),
1592 "Hook {} output should contain its DONE marker. Got: {}",
1593 i,
1594 result.stdout
1595 );
1596
1597 for j in 0..5 {
1599 if j != i {
1600 let other_id = format!("hook_{}", j);
1601 assert!(
1602 !result.stdout.contains(&format!("ID:{}", other_id)),
1603 "Hook {} output should not contain hook {} ID",
1604 i,
1605 j
1606 );
1607 }
1608 }
1609 }
1610 }
1611
1612 #[tokio::test]
1617 async fn test_environment_capture_special_chars() {
1618 let multiline_script = r"
1620export MULTILINE_VAR='line1
1621line2
1622line3'
1623";
1624
1625 let result = evaluate_shell_environment(multiline_script).await;
1626 assert!(result.is_ok(), "Should parse multiline env vars");
1627
1628 let env_vars = result.unwrap();
1629 if let Some(value) = env_vars.get("MULTILINE_VAR") {
1630 assert!(
1631 value.contains("line1"),
1632 "Should contain first line: {}",
1633 value
1634 );
1635 assert!(
1636 value.contains("line2"),
1637 "Should contain second line: {}",
1638 value
1639 );
1640 }
1641
1642 let unicode_script = r"
1644export UNICODE_VAR='Hello 世界 🌍 émoji'
1645export CHINESE_VAR='中文测试'
1646export JAPANESE_VAR='日本語テスト'
1647";
1648
1649 let result = evaluate_shell_environment(unicode_script).await;
1650 assert!(result.is_ok(), "Should parse unicode env vars");
1651
1652 let env_vars = result.unwrap();
1653 if let Some(value) = env_vars.get("UNICODE_VAR") {
1654 assert!(
1655 value.contains("世界"),
1656 "Should preserve Chinese characters: {}",
1657 value
1658 );
1659 assert!(value.contains("🌍"), "Should preserve emoji: {}", value);
1660 }
1661
1662 let special_chars_script = r#"
1664export QUOTED_VAR="value with 'single' and \"double\" quotes"
1665export PATH_VAR="/usr/local/bin:/usr/bin:/bin"
1666export EQUALS_VAR="key=value=another"
1667"#;
1668
1669 let result = evaluate_shell_environment(special_chars_script).await;
1670 assert!(result.is_ok(), "Should parse special chars");
1671
1672 let env_vars = result.unwrap();
1673 if let Some(value) = env_vars.get("EQUALS_VAR") {
1674 assert!(
1675 value.contains("key=value=another"),
1676 "Should preserve equals signs: {}",
1677 value
1678 );
1679 }
1680 }
1681
1682 #[tokio::test]
1684 async fn test_environment_capture_edge_cases() {
1685 let empty_script = r"
1687export EMPTY_VAR=''
1688export SPACE_VAR=' '
1689";
1690
1691 let result = evaluate_shell_environment(empty_script).await;
1692 assert!(result.is_ok(), "Should handle empty/whitespace values");
1693
1694 let long_value = "x".repeat(10000);
1696 let long_script = format!("export LONG_VAR='{}'", long_value);
1697
1698 let result = evaluate_shell_environment(&long_script).await;
1699 assert!(result.is_ok(), "Should handle very long values");
1700
1701 let env_vars = result.unwrap();
1702 if let Some(value) = env_vars.get("LONG_VAR") {
1703 assert_eq!(value.len(), 10000, "Should preserve full length");
1704 }
1705 }
1706
1707 #[tokio::test]
1709 async fn test_working_directory_isolation() {
1710 let executor = HookExecutor::with_default_config().unwrap();
1711
1712 let temp_dir1 = TempDir::new().unwrap();
1714 let temp_dir2 = TempDir::new().unwrap();
1715
1716 std::fs::write(temp_dir1.path().join("marker.txt"), "dir1").unwrap();
1718 std::fs::write(temp_dir2.path().join("marker.txt"), "dir2").unwrap();
1719
1720 let hook1 = Hook {
1722 order: 100,
1723 propagate: false,
1724 command: "cat".to_string(),
1725 args: vec!["marker.txt".to_string()],
1726 dir: Some(temp_dir1.path().to_string_lossy().to_string()),
1727 inputs: vec![],
1728 source: None,
1729 };
1730
1731 let hook2 = Hook {
1732 order: 100,
1733 propagate: false,
1734 command: "cat".to_string(),
1735 args: vec!["marker.txt".to_string()],
1736 dir: Some(temp_dir2.path().to_string_lossy().to_string()),
1737 inputs: vec![],
1738 source: None,
1739 };
1740
1741 let result1 = executor.execute_single_hook(hook1).await.unwrap();
1742 let result2 = executor.execute_single_hook(hook2).await.unwrap();
1743
1744 assert!(result1.success, "Hook 1 should succeed");
1745 assert!(result2.success, "Hook 2 should succeed");
1746
1747 assert!(
1748 result1.stdout.contains("dir1"),
1749 "Hook 1 should read from dir1: {}",
1750 result1.stdout
1751 );
1752 assert!(
1753 result2.stdout.contains("dir2"),
1754 "Hook 2 should read from dir2: {}",
1755 result2.stdout
1756 );
1757 }
1758
1759 #[tokio::test]
1761 async fn test_stderr_capture() {
1762 let executor = HookExecutor::with_default_config().unwrap();
1763
1764 let hook = Hook {
1766 order: 100,
1767 propagate: false,
1768 command: "bash".to_string(),
1769 args: vec![
1770 "-c".to_string(),
1771 "echo 'to stdout'; echo 'to stderr' >&2".to_string(),
1772 ],
1773 dir: None,
1774 inputs: vec![],
1775 source: None,
1776 };
1777
1778 let result = executor.execute_single_hook(hook).await.unwrap();
1779
1780 assert!(result.success, "Hook should succeed");
1781 assert!(
1782 result.stdout.contains("to stdout"),
1783 "Should capture stdout: {}",
1784 result.stdout
1785 );
1786 assert!(
1787 result.stderr.contains("to stderr"),
1788 "Should capture stderr: {}",
1789 result.stderr
1790 );
1791 }
1792
1793 #[tokio::test]
1795 async fn test_binary_output_handling() {
1796 let executor = HookExecutor::with_default_config().unwrap();
1797
1798 let hook = Hook {
1800 order: 100,
1801 propagate: false,
1802 command: "bash".to_string(),
1803 args: vec!["-c".to_string(), "printf 'hello\\x00world'".to_string()],
1804 dir: None,
1805 inputs: vec![],
1806 source: None,
1807 };
1808
1809 let result = executor.execute_single_hook(hook).await.unwrap();
1810
1811 assert!(result.success, "Hook should succeed");
1813 assert!(
1815 result.stdout.contains("hello") && result.stdout.contains("world"),
1816 "Should contain text parts: {}",
1817 result.stdout
1818 );
1819 }
1820}