gestura_core_hooks/
executor.rs

1//! Hook execution backends.
2
3use async_trait::async_trait;
4use std::time::Instant;
5use tokio::process::Command;
6
7use gestura_core_foundation::error::{AppError, Result};
8
9/// Output produced by an executed hook.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct HookOutput {
12    /// Exit code, if available.
13    pub exit_code: Option<i32>,
14    /// Captured stdout (possibly truncated).
15    pub stdout: String,
16    /// Captured stderr (possibly truncated).
17    pub stderr: String,
18    /// Elapsed time in milliseconds.
19    pub duration_ms: u64,
20}
21
22/// A hook executor.
23///
24/// This trait exists to make the hook engine testable without spawning real
25/// processes (though we do use process execution in unit tests on Unix).
26#[async_trait]
27pub trait HookExecutor: Send + Sync {
28    /// Execute a command.
29    async fn execute(
30        &self,
31        program: &str,
32        args: &[String],
33        cwd: Option<&std::path::Path>,
34        max_output_bytes: usize,
35    ) -> Result<HookOutput>;
36}
37
38/// Executor that spawns local OS processes.
39pub struct ProcessHookExecutor;
40
41#[async_trait]
42impl HookExecutor for ProcessHookExecutor {
43    async fn execute(
44        &self,
45        program: &str,
46        args: &[String],
47        cwd: Option<&std::path::Path>,
48        max_output_bytes: usize,
49    ) -> Result<HookOutput> {
50        let start = Instant::now();
51
52        let mut cmd = Command::new(program);
53        cmd.args(args);
54        if let Some(dir) = cwd {
55            cmd.current_dir(dir);
56        }
57
58        let output = cmd.output().await.map_err(AppError::from)?;
59
60        let mut stdout = String::from_utf8_lossy(&output.stdout).to_string();
61        let mut stderr = String::from_utf8_lossy(&output.stderr).to_string();
62
63        if stdout.len() > max_output_bytes {
64            stdout.truncate(max_output_bytes);
65        }
66        if stderr.len() > max_output_bytes {
67            stderr.truncate(max_output_bytes);
68        }
69
70        Ok(HookOutput {
71            exit_code: output.status.code(),
72            stdout,
73            stderr,
74            duration_ms: start.elapsed().as_millis() as u64,
75        })
76    }
77}