Skip to main content

capsula_capture_command/
lib.rs

1mod error;
2
3use crate::error::CommandHookError;
4use capsula_core::captured::Captured;
5use capsula_core::error::CapsulaResult;
6use capsula_core::hook::{Hook, PhaseMarker, RuntimeParams};
7use capsula_core::run::PreparedRun;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use tracing::debug;
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
13pub struct CommandHookConfig {
14    command: Vec<String>,
15    #[serde(default)]
16    abort_on_failure: bool,
17    cwd: Option<PathBuf>,
18}
19
20#[derive(Debug)]
21pub struct CommandHook {
22    config: CommandHookConfig,
23    working_dir: PathBuf,
24}
25
26#[derive(Debug, Serialize)]
27pub struct CommandCaptured {
28    stdout: String,
29    stderr: String,
30    status: i32,
31    #[serde(skip)]
32    abort_requested: bool,
33}
34
35impl<P> Hook<P> for CommandHook
36where
37    P: PhaseMarker,
38{
39    const ID: &'static str = "capture-command";
40
41    type Config = CommandHookConfig;
42    type Output = CommandCaptured;
43
44    fn from_config(
45        config: &serde_json::Value,
46        project_root: &std::path::Path,
47    ) -> CapsulaResult<Self> {
48        let config: CommandHookConfig = serde_json::from_value(config.clone())?;
49
50        let working_dir = match &config.cwd {
51            Some(cwd) if cwd.is_absolute() => cwd.clone(),
52            Some(cwd) => project_root.join(cwd).canonicalize()?,
53            None => project_root.to_path_buf(),
54        };
55
56        Ok(Self {
57            config,
58            working_dir,
59        })
60    }
61
62    fn config(&self) -> &Self::Config {
63        &self.config
64    }
65
66    fn run(
67        &self,
68        _metadata: &PreparedRun,
69        _params: &RuntimeParams<P>,
70    ) -> CapsulaResult<Self::Output> {
71        use std::process::Command;
72
73        if self.config.command.is_empty() {
74            return Err(CommandHookError::EmptyCommand.into());
75        }
76
77        debug!(
78            "CommandHook: Executing command: {:?} in {}",
79            self.config.command,
80            self.working_dir.display()
81        );
82        let mut cmd = Command::new(&self.config.command[0]);
83        cmd.current_dir(&self.working_dir);
84        if self.config.command.len() > 1 {
85            cmd.args(&self.config.command[1..]);
86        }
87
88        let output = cmd
89            .output()
90            .map_err(|source| CommandHookError::ExecutionFailed {
91                command: self.config.command.join(" "),
92                source,
93            })?;
94
95        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
96        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
97        let status = output.status.code().unwrap_or(-1);
98
99        debug!(
100            "CommandHook: Command completed with exit code {}, {} bytes stdout, {} bytes stderr",
101            status,
102            stdout.len(),
103            stderr.len()
104        );
105
106        let abort_requested = self.config.abort_on_failure && status != 0;
107        if abort_requested {
108            debug!("CommandHook: Requesting abort due to command failure");
109        }
110
111        Ok(CommandCaptured {
112            stdout,
113            stderr,
114            status,
115            abort_requested,
116        })
117    }
118}
119
120impl Captured for CommandCaptured {
121    fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
122        serde_json::to_value(self)
123    }
124
125    fn abort_requested(&self) -> bool {
126        self.abort_requested
127    }
128}