capsula_capture_command/
lib.rs1mod 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) => capsula_core::util::resolve_relative(cwd, project_root)?,
52 None => project_root.to_path_buf(),
53 };
54
55 Ok(Self {
56 config,
57 working_dir,
58 })
59 }
60
61 fn config(&self) -> &Self::Config {
62 &self.config
63 }
64
65 fn run(
66 &self,
67 _metadata: &PreparedRun,
68 _params: &RuntimeParams<P>,
69 ) -> CapsulaResult<Self::Output> {
70 use std::process::Command;
71
72 if self.config.command.is_empty() {
73 return Err(CommandHookError::EmptyCommand.into());
74 }
75
76 debug!(
77 "CommandHook: Executing command: {:?} in {}",
78 self.config.command,
79 self.working_dir.display()
80 );
81 let mut cmd = Command::new(&self.config.command[0]);
82 cmd.current_dir(&self.working_dir);
83 if self.config.command.len() > 1 {
84 cmd.args(&self.config.command[1..]);
85 }
86
87 let output = cmd
88 .output()
89 .map_err(|source| CommandHookError::ExecutionFailed {
90 command: self.config.command.join(" "),
91 source,
92 })?;
93
94 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
95 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
96 let status = output.status.code().unwrap_or(-1);
97
98 debug!(
99 "CommandHook: Command completed with exit code {}, {} bytes stdout, {} bytes stderr",
100 status,
101 stdout.len(),
102 stderr.len()
103 );
104
105 let abort_requested = self.config.abort_on_failure && status != 0;
106 if abort_requested {
107 debug!("CommandHook: Requesting abort due to command failure");
108 }
109
110 Ok(CommandCaptured {
111 stdout,
112 stderr,
113 status,
114 abort_requested,
115 })
116 }
117}
118
119impl Captured for CommandCaptured {
120 fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
121 serde_json::to_value(self)
122 }
123
124 fn abort_requested(&self) -> bool {
125 self.abort_requested
126 }
127}