1#![allow(dead_code)]
10use crate::core::{HookCommand, TwinError, TwinResult};
11use log::{debug, error, info, warn};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::process::{Command, Output};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum HookType {
19 PreCreate,
21 PostCreate,
23 PreRemove,
25 PostRemove,
27}
28
29impl HookType {
30 pub fn as_str(&self) -> &str {
32 match self {
33 HookType::PreCreate => "pre_create",
34 HookType::PostCreate => "post_create",
35 HookType::PreRemove => "pre_remove",
36 HookType::PostRemove => "post_remove",
37 }
38 }
39}
40
41#[derive(Debug)]
43pub struct HookResult {
44 pub hook_type: HookType,
46 pub command: String,
48 pub success: bool,
50 pub exit_code: Option<i32>,
52 pub stdout: String,
54 pub stderr: String,
56 pub duration_ms: u128,
58}
59
60#[derive(Debug, Clone)]
62pub struct HookContext {
63 pub agent_name: String,
65 pub worktree_path: PathBuf,
67 pub branch: String,
69 pub project_root: PathBuf,
71 pub env_vars: HashMap<String, String>,
73}
74
75impl HookContext {
76 pub fn new(
78 agent_name: impl Into<String>,
79 worktree_path: impl Into<PathBuf>,
80 branch: impl Into<String>,
81 project_root: impl Into<PathBuf>,
82 ) -> Self {
83 Self {
84 agent_name: agent_name.into(),
85 worktree_path: worktree_path.into(),
86 branch: branch.into(),
87 project_root: project_root.into(),
88 env_vars: HashMap::new(),
89 }
90 }
91
92 pub fn add_env_var(&mut self, key: impl Into<String>, value: impl Into<String>) {
94 self.env_vars.insert(key.into(), value.into());
95 }
96
97 pub fn as_env_vars(&self) -> HashMap<String, String> {
99 let mut vars = self.env_vars.clone();
100
101 vars.insert("TWIN_AGENT_NAME".to_string(), self.agent_name.clone());
103 vars.insert(
104 "TWIN_WORKTREE_PATH".to_string(),
105 self.worktree_path.display().to_string(),
106 );
107 vars.insert("TWIN_BRANCH".to_string(), self.branch.clone());
108 vars.insert(
109 "TWIN_PROJECT_ROOT".to_string(),
110 self.project_root.display().to_string(),
111 );
112
113 vars
114 }
115}
116
117pub struct HookExecutor {
119 dry_run: bool,
121 timeout_seconds: u64,
123 continue_on_error: bool,
125}
126
127impl HookExecutor {
128 pub fn new() -> Self {
130 Self {
131 dry_run: false,
132 timeout_seconds: 30,
133 continue_on_error: false,
134 }
135 }
136
137 pub fn set_dry_run(&mut self, dry_run: bool) {
139 self.dry_run = dry_run;
140 }
141
142 pub fn set_timeout(&mut self, seconds: u64) {
144 self.timeout_seconds = seconds;
145 }
146
147 pub fn set_continue_on_error(&mut self, continue_on_error: bool) {
149 self.continue_on_error = continue_on_error;
150 }
151
152 pub fn execute(
154 &self,
155 hook_type: HookType,
156 hook: &HookCommand,
157 context: &HookContext,
158 ) -> TwinResult<HookResult> {
159 info!("Executing {} hook: {}", hook_type.as_str(), hook.command);
160
161 let expanded_command = self.expand_command(&hook.command, context);
163 let expanded_args = if hook.args.is_empty() {
164 None
165 } else {
166 Some(
167 hook.args
168 .iter()
169 .map(|arg| self.expand_command(arg, context))
170 .collect::<Vec<_>>(),
171 )
172 };
173
174 if self.dry_run {
175 info!("[DRY RUN] Would execute: {expanded_command}");
176 if let Some(args) = &expanded_args {
177 info!("[DRY RUN] With args: {args:?}");
178 }
179
180 return Ok(HookResult {
181 hook_type,
182 command: expanded_command,
183 success: true,
184 exit_code: Some(0),
185 stdout: "[DRY RUN]".to_string(),
186 stderr: String::new(),
187 duration_ms: 0,
188 });
189 }
190
191 let start_time = std::time::Instant::now();
193 let result = self.execute_command(&expanded_command, expanded_args.as_deref(), context)?;
194 let duration_ms = start_time.elapsed().as_millis();
195
196 let hook_result = HookResult {
197 hook_type,
198 command: expanded_command.clone(),
199 success: result.status.success(),
200 exit_code: result.status.code(),
201 stdout: String::from_utf8_lossy(&result.stdout).to_string(),
202 stderr: String::from_utf8_lossy(&result.stderr).to_string(),
203 duration_ms,
204 };
205
206 if hook_result.success {
208 info!(
209 "{} hook completed successfully in {}ms",
210 hook_type.as_str(),
211 duration_ms
212 );
213 if !hook_result.stdout.is_empty() {
214 debug!("Hook stdout: {}", hook_result.stdout);
215 }
216 } else {
217 error!(
218 "{} hook failed with exit code {:?}",
219 hook_type.as_str(),
220 hook_result.exit_code
221 );
222 if !hook_result.stderr.is_empty() {
223 error!("Hook stderr: {}", hook_result.stderr);
224 }
225
226 if !hook.continue_on_error {
228 return Err(TwinError::hook(
229 format!("{} hook failed: {}", hook_type.as_str(), expanded_command),
230 hook_type.as_str().to_string(),
231 hook_result.exit_code,
232 ));
233 } else {
234 warn!("Continuing despite hook failure (continue_on_error=true)");
235 }
236 }
237
238 Ok(hook_result)
239 }
240
241 pub fn execute_hooks(
243 &self,
244 hook_type: HookType,
245 hooks: &[HookCommand],
246 context: &HookContext,
247 ) -> TwinResult<Vec<HookResult>> {
248 let mut results = Vec::new();
249
250 for hook in hooks {
251 match self.execute(hook_type, hook, context) {
252 Ok(result) => {
253 let should_stop = !result.success && !hook.continue_on_error;
254 results.push(result);
255
256 if should_stop {
257 break;
258 }
259 }
260 Err(e) => {
261 if !hook.continue_on_error {
262 return Err(e);
263 }
264 warn!("Hook execution error (continuing): {e}");
265 }
266 }
267 }
268
269 Ok(results)
270 }
271
272 fn expand_command(&self, command: &str, context: &HookContext) -> String {
274 let mut result = command.to_string();
275
276 result = result.replace("${AGENT_NAME}", &context.agent_name);
278 result = result.replace(
279 "${WORKTREE_PATH}",
280 &context.worktree_path.display().to_string(),
281 );
282 result = result.replace("${BRANCH}", &context.branch);
283 result = result.replace(
284 "${PROJECT_ROOT}",
285 &context.project_root.display().to_string(),
286 );
287
288 for (key, value) in &context.env_vars {
290 result = result.replace(&format!("${{{key}}}"), value);
291 }
292
293 result
294 }
295
296 fn execute_command(
298 &self,
299 command: &str,
300 args: Option<&[String]>,
301 context: &HookContext,
302 ) -> TwinResult<Output> {
303 let mut cmd = if cfg!(windows) {
304 let mut c = Command::new("cmd");
305 c.arg("/C");
306 c
307 } else {
308 let mut c = Command::new("sh");
309 c.arg("-c");
310 c
311 };
312
313 let full_command = if let Some(args) = args {
315 format!("{} {}", command, args.join(" "))
316 } else {
317 command.to_string()
318 };
319
320 cmd.arg(&full_command);
321
322 let work_dir = if context.worktree_path.exists() {
326 &context.worktree_path
327 } else {
328 &context.project_root
329 };
330 cmd.current_dir(work_dir);
331
332 for (key, value) in context.as_env_vars() {
334 cmd.env(key, value);
335 }
336
337 debug!("Executing command: {full_command}");
338 debug!("Working directory: {:?}", context.worktree_path);
339
340 let output = if self.timeout_seconds > 0 {
342 cmd.output().map_err(|e| {
345 TwinError::hook(
346 format!("Failed to execute hook command: {e}"),
347 command.to_string(),
348 None,
349 )
350 })?
351 } else {
352 cmd.output().map_err(|e| {
353 TwinError::hook(
354 format!("Failed to execute hook command: {e}"),
355 command.to_string(),
356 None,
357 )
358 })?
359 };
360
361 Ok(output)
362 }
363}
364
365impl Default for HookExecutor {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_hook_type_string() {
378 assert_eq!(HookType::PreCreate.as_str(), "pre_create");
379 assert_eq!(HookType::PostCreate.as_str(), "post_create");
380 assert_eq!(HookType::PreRemove.as_str(), "pre_remove");
381 assert_eq!(HookType::PostRemove.as_str(), "post_remove");
382 }
383
384 #[test]
385 fn test_context_env_vars() {
386 let mut context = HookContext::new(
387 "test-agent",
388 "/path/to/worktree",
389 "feature/test",
390 "/path/to/project",
391 );
392 context.add_env_var("CUSTOM_VAR", "custom_value");
393
394 let env_vars = context.as_env_vars();
395 assert_eq!(env_vars.get("TWIN_AGENT_NAME").unwrap(), "test-agent");
396 assert_eq!(env_vars.get("TWIN_BRANCH").unwrap(), "feature/test");
397 assert_eq!(env_vars.get("CUSTOM_VAR").unwrap(), "custom_value");
398 }
399
400 #[test]
401 fn test_command_expansion() {
402 let context = HookContext::new(
403 "my-agent",
404 "/workspace/my-agent",
405 "feature/my-agent",
406 "/workspace",
407 );
408
409 let executor = HookExecutor::new();
410 let expanded = executor.expand_command(
411 "echo 'Working on ${AGENT_NAME} in ${WORKTREE_PATH}'",
412 &context,
413 );
414
415 assert_eq!(
416 expanded,
417 "echo 'Working on my-agent in /workspace/my-agent'"
418 );
419 }
420
421 #[test]
422 fn test_dry_run_execution() {
423 let mut executor = HookExecutor::new();
424 executor.set_dry_run(true);
425
426 let context = HookContext::new("test", "/test", "test", "/test");
427
428 let hook = HookCommand {
429 command: "echo test".to_string(),
430 args: vec![],
431 env: HashMap::new(),
432 timeout: 60,
433 continue_on_error: false,
434 };
435
436 let result = executor
437 .execute(HookType::PreCreate, &hook, &context)
438 .unwrap();
439 assert!(result.success);
440 assert_eq!(result.stdout, "[DRY RUN]");
441 }
442}