#![allow(dead_code)]
use crate::core::{HookCommand, TwinError, TwinResult};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::{Command, Output};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookType {
PreCreate,
PostCreate,
PreRemove,
PostRemove,
}
impl HookType {
pub fn as_str(&self) -> &str {
match self {
HookType::PreCreate => "pre_create",
HookType::PostCreate => "post_create",
HookType::PreRemove => "pre_remove",
HookType::PostRemove => "post_remove",
}
}
}
#[derive(Debug)]
pub struct HookResult {
pub hook_type: HookType,
pub command: String,
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration_ms: u128,
}
#[derive(Debug, Clone)]
pub struct HookContext {
pub agent_name: String,
pub worktree_path: PathBuf,
pub branch: String,
pub project_root: PathBuf,
pub env_vars: HashMap<String, String>,
}
impl HookContext {
pub fn new(
agent_name: impl Into<String>,
worktree_path: impl Into<PathBuf>,
branch: impl Into<String>,
project_root: impl Into<PathBuf>,
) -> Self {
Self {
agent_name: agent_name.into(),
worktree_path: worktree_path.into(),
branch: branch.into(),
project_root: project_root.into(),
env_vars: HashMap::new(),
}
}
pub fn add_env_var(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.env_vars.insert(key.into(), value.into());
}
pub fn as_env_vars(&self) -> HashMap<String, String> {
let mut vars = self.env_vars.clone();
vars.insert("TWIN_AGENT_NAME".to_string(), self.agent_name.clone());
vars.insert(
"TWIN_WORKTREE_PATH".to_string(),
self.worktree_path.display().to_string(),
);
vars.insert("TWIN_BRANCH".to_string(), self.branch.clone());
vars.insert(
"TWIN_PROJECT_ROOT".to_string(),
self.project_root.display().to_string(),
);
vars
}
}
pub struct HookExecutor {
dry_run: bool,
timeout_seconds: u64,
continue_on_error: bool,
}
impl HookExecutor {
pub fn new() -> Self {
Self {
dry_run: false,
timeout_seconds: 30,
continue_on_error: false,
}
}
pub fn set_dry_run(&mut self, dry_run: bool) {
self.dry_run = dry_run;
}
pub fn set_timeout(&mut self, seconds: u64) {
self.timeout_seconds = seconds;
}
pub fn set_continue_on_error(&mut self, continue_on_error: bool) {
self.continue_on_error = continue_on_error;
}
pub fn execute(
&self,
hook_type: HookType,
hook: &HookCommand,
context: &HookContext,
) -> TwinResult<HookResult> {
info!("Executing {} hook: {}", hook_type.as_str(), hook.command);
let expanded_command = self.expand_command(&hook.command, context);
let expanded_args = if hook.args.is_empty() {
None
} else {
Some(
hook.args
.iter()
.map(|arg| self.expand_command(arg, context))
.collect::<Vec<_>>(),
)
};
if self.dry_run {
info!("[DRY RUN] Would execute: {expanded_command}");
if let Some(args) = &expanded_args {
info!("[DRY RUN] With args: {args:?}");
}
return Ok(HookResult {
hook_type,
command: expanded_command,
success: true,
exit_code: Some(0),
stdout: "[DRY RUN]".to_string(),
stderr: String::new(),
duration_ms: 0,
});
}
let start_time = std::time::Instant::now();
let result = self.execute_command(&expanded_command, expanded_args.as_deref(), context)?;
let duration_ms = start_time.elapsed().as_millis();
let hook_result = HookResult {
hook_type,
command: expanded_command.clone(),
success: result.status.success(),
exit_code: result.status.code(),
stdout: String::from_utf8_lossy(&result.stdout).to_string(),
stderr: String::from_utf8_lossy(&result.stderr).to_string(),
duration_ms,
};
if hook_result.success {
info!(
"{} hook completed successfully in {}ms",
hook_type.as_str(),
duration_ms
);
if !hook_result.stdout.is_empty() {
debug!("Hook stdout: {}", hook_result.stdout);
}
} else {
error!(
"{} hook failed with exit code {:?}",
hook_type.as_str(),
hook_result.exit_code
);
if !hook_result.stderr.is_empty() {
error!("Hook stderr: {}", hook_result.stderr);
}
if !hook.continue_on_error {
return Err(TwinError::hook(
format!("{} hook failed: {}", hook_type.as_str(), expanded_command),
hook_type.as_str().to_string(),
hook_result.exit_code,
));
} else {
warn!("Continuing despite hook failure (continue_on_error=true)");
}
}
Ok(hook_result)
}
pub fn execute_hooks(
&self,
hook_type: HookType,
hooks: &[HookCommand],
context: &HookContext,
) -> TwinResult<Vec<HookResult>> {
let mut results = Vec::new();
for hook in hooks {
match self.execute(hook_type, hook, context) {
Ok(result) => {
let should_stop = !result.success && !hook.continue_on_error;
results.push(result);
if should_stop {
break;
}
}
Err(e) => {
if !hook.continue_on_error {
return Err(e);
}
warn!("Hook execution error (continuing): {e}");
}
}
}
Ok(results)
}
fn expand_command(&self, command: &str, context: &HookContext) -> String {
let mut result = command.to_string();
result = result.replace("${AGENT_NAME}", &context.agent_name);
result = result.replace(
"${WORKTREE_PATH}",
&context.worktree_path.display().to_string(),
);
result = result.replace("${BRANCH}", &context.branch);
result = result.replace(
"${PROJECT_ROOT}",
&context.project_root.display().to_string(),
);
for (key, value) in &context.env_vars {
result = result.replace(&format!("${{{key}}}"), value);
}
result
}
fn execute_command(
&self,
command: &str,
args: Option<&[String]>,
context: &HookContext,
) -> TwinResult<Output> {
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd");
c.arg("/C");
c
} else {
let mut c = Command::new("sh");
c.arg("-c");
c
};
let full_command = if let Some(args) = args {
format!("{} {}", command, args.join(" "))
} else {
command.to_string()
};
cmd.arg(&full_command);
let work_dir = if context.worktree_path.exists() {
&context.worktree_path
} else {
&context.project_root
};
cmd.current_dir(work_dir);
for (key, value) in context.as_env_vars() {
cmd.env(key, value);
}
debug!("Executing command: {full_command}");
debug!("Working directory: {:?}", context.worktree_path);
let output = if self.timeout_seconds > 0 {
cmd.output().map_err(|e| {
TwinError::hook(
format!("Failed to execute hook command: {e}"),
command.to_string(),
None,
)
})?
} else {
cmd.output().map_err(|e| {
TwinError::hook(
format!("Failed to execute hook command: {e}"),
command.to_string(),
None,
)
})?
};
Ok(output)
}
}
impl Default for HookExecutor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_type_string() {
assert_eq!(HookType::PreCreate.as_str(), "pre_create");
assert_eq!(HookType::PostCreate.as_str(), "post_create");
assert_eq!(HookType::PreRemove.as_str(), "pre_remove");
assert_eq!(HookType::PostRemove.as_str(), "post_remove");
}
#[test]
fn test_context_env_vars() {
let mut context = HookContext::new(
"test-agent",
"/path/to/worktree",
"feature/test",
"/path/to/project",
);
context.add_env_var("CUSTOM_VAR", "custom_value");
let env_vars = context.as_env_vars();
assert_eq!(env_vars.get("TWIN_AGENT_NAME").unwrap(), "test-agent");
assert_eq!(env_vars.get("TWIN_BRANCH").unwrap(), "feature/test");
assert_eq!(env_vars.get("CUSTOM_VAR").unwrap(), "custom_value");
}
#[test]
fn test_command_expansion() {
let context = HookContext::new(
"my-agent",
"/workspace/my-agent",
"feature/my-agent",
"/workspace",
);
let executor = HookExecutor::new();
let expanded = executor.expand_command(
"echo 'Working on ${AGENT_NAME} in ${WORKTREE_PATH}'",
&context,
);
assert_eq!(
expanded,
"echo 'Working on my-agent in /workspace/my-agent'"
);
}
#[test]
fn test_dry_run_execution() {
let mut executor = HookExecutor::new();
executor.set_dry_run(true);
let context = HookContext::new("test", "/test", "test", "/test");
let hook = HookCommand {
command: "echo test".to_string(),
args: vec![],
env: HashMap::new(),
timeout: 60,
continue_on_error: false,
};
let result = executor
.execute(HookType::PreCreate, &hook, &context)
.unwrap();
assert!(result.success);
assert_eq!(result.stdout, "[DRY RUN]");
}
}