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)]
13#[serde(deny_unknown_fields)]
14pub struct CommandHookConfig {
15 command: Vec<String>,
16 #[serde(default)]
17 abort_on_failure: bool,
18 cwd: Option<PathBuf>,
19}
20
21#[derive(Debug)]
22pub struct CommandHook {
23 config: CommandHookConfig,
24 working_dir: PathBuf,
25}
26
27#[derive(Debug, Serialize)]
28pub struct CommandCaptured {
29 stdout: String,
30 stderr: String,
31 status: i32,
32 #[serde(skip)]
33 abort_requested: bool,
34}
35
36impl<P> Hook<P> for CommandHook
37where
38 P: PhaseMarker,
39{
40 const ID: &'static str = "capture-command";
41
42 type Config = CommandHookConfig;
43 type Output = CommandCaptured;
44
45 fn from_config(
46 config: &serde_json::Value,
47 project_root: &std::path::Path,
48 ) -> CapsulaResult<Self> {
49 let config: CommandHookConfig = serde_json::from_value(config.clone())?;
50
51 let working_dir = match &config.cwd {
52 Some(cwd) => capsula_core::util::resolve_relative(cwd, project_root)?,
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}