use crate::component::Component;
use crate::error::{Error, Result};
use crate::module;
use crate::ssh::{execute_local_command_in_dir, SshClient};
use crate::utils::template;
use serde::Serialize;
use std::collections::HashMap;
pub type HookMap = HashMap<String, Vec<String>>;
#[derive(Debug, Clone, Serialize)]
pub struct HookCommandResult {
pub command: String,
pub success: bool,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct HookRunResult {
pub event: String,
pub commands: Vec<HookCommandResult>,
pub all_succeeded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookFailureMode {
Fatal,
NonFatal,
}
pub fn resolve_hooks(component: &Component, event: &str) -> Vec<String> {
let mut commands = Vec::new();
if let Some(ref modules) = component.modules {
for module_id in modules.keys() {
if let Ok(manifest) = module::load_module(module_id) {
if let Some(module_commands) = manifest.hooks.get(event) {
commands.extend(module_commands.clone());
}
}
}
}
if let Some(component_commands) = component.hooks.get(event) {
commands.extend(component_commands.clone());
}
commands
}
pub fn run_hooks(
component: &Component,
event: &str,
failure_mode: HookFailureMode,
) -> Result<HookRunResult> {
let commands = resolve_hooks(component, event);
run_commands(&commands, &component.local_path, event, failure_mode)
}
pub fn run_commands(
commands: &[String],
working_dir: &str,
event: &str,
failure_mode: HookFailureMode,
) -> Result<HookRunResult> {
let mut results = Vec::new();
let mut all_succeeded = true;
for command in commands {
let output = execute_local_command_in_dir(command, Some(working_dir), None);
let result = HookCommandResult {
command: command.clone(),
success: output.success,
stdout: output.stdout.clone(),
stderr: output.stderr.clone(),
exit_code: output.exit_code,
};
if !output.success {
all_succeeded = false;
if failure_mode == HookFailureMode::Fatal {
let error_text = if output.stderr.trim().is_empty() {
&output.stdout
} else {
&output.stderr
};
results.push(result);
return Err(Error::internal_unexpected(format!(
"Hook '{}' command failed: {}\n{}",
event, command, error_text
)));
}
}
results.push(result);
}
Ok(HookRunResult {
event: event.to_string(),
commands: results,
all_succeeded,
})
}
pub fn run_hooks_remote(
ssh_client: &SshClient,
component: &Component,
event: &str,
failure_mode: HookFailureMode,
vars: &HashMap<String, String>,
) -> Result<HookRunResult> {
let commands = resolve_hooks(component, event);
let expanded: Vec<String> = commands
.iter()
.map(|c| template::render_map(c, vars))
.collect();
run_commands_remote(ssh_client, &expanded, event, failure_mode)
}
pub fn run_commands_remote(
ssh_client: &SshClient,
commands: &[String],
event: &str,
failure_mode: HookFailureMode,
) -> Result<HookRunResult> {
let mut results = Vec::new();
let mut all_succeeded = true;
for command in commands {
let output = ssh_client.execute(command);
let result = HookCommandResult {
command: command.clone(),
success: output.success,
stdout: output.stdout.clone(),
stderr: output.stderr.clone(),
exit_code: output.exit_code,
};
if !output.success {
all_succeeded = false;
if failure_mode == HookFailureMode::Fatal {
let error_text = if output.stderr.trim().is_empty() {
&output.stdout
} else {
&output.stderr
};
results.push(result);
return Err(Error::internal_unexpected(format!(
"Hook '{}' command failed: {}\n{}",
event, command, error_text
)));
}
}
results.push(result);
}
Ok(HookRunResult {
event: event.to_string(),
commands: results,
all_succeeded,
})
}
pub mod events {
pub const PRE_VERSION_BUMP: &str = "pre:version:bump";
pub const POST_VERSION_BUMP: &str = "post:version:bump";
pub const POST_RELEASE: &str = "post:release";
pub const POST_DEPLOY: &str = "post:deploy";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_hooks_returns_empty_when_no_hooks() {
let component = Component::new(
"test".to_string(),
"/tmp/test".to_string(),
"".to_string(),
None,
);
let commands = resolve_hooks(&component, events::PRE_VERSION_BUMP);
assert!(commands.is_empty());
}
#[test]
fn resolve_hooks_returns_component_hooks() {
let mut component = Component::new(
"test".to_string(),
"/tmp/test".to_string(),
"".to_string(),
None,
);
component.hooks.insert(
events::PRE_VERSION_BUMP.to_string(),
vec!["echo hello".to_string()],
);
let commands = resolve_hooks(&component, events::PRE_VERSION_BUMP);
assert_eq!(commands, vec!["echo hello".to_string()]);
}
#[test]
fn resolve_hooks_ignores_unrelated_events() {
let mut component = Component::new(
"test".to_string(),
"/tmp/test".to_string(),
"".to_string(),
None,
);
component.hooks.insert(
events::POST_DEPLOY.to_string(),
vec!["echo deploy".to_string()],
);
let commands = resolve_hooks(&component, events::PRE_VERSION_BUMP);
assert!(commands.is_empty());
}
#[test]
fn run_commands_succeeds_with_empty_list() {
let result = run_commands(&[], "/tmp", "test:event", HookFailureMode::Fatal).unwrap();
assert!(result.all_succeeded);
assert!(result.commands.is_empty());
assert_eq!(result.event, "test:event");
}
#[test]
fn run_commands_executes_successfully() {
let commands = vec!["echo hello".to_string()];
let result = run_commands(&commands, "/tmp", "test:event", HookFailureMode::Fatal).unwrap();
assert!(result.all_succeeded);
assert_eq!(result.commands.len(), 1);
assert!(result.commands[0].success);
assert_eq!(result.commands[0].stdout.trim(), "hello");
}
#[test]
fn run_commands_fatal_stops_on_failure() {
let commands = vec!["exit 1".to_string(), "echo should-not-run".to_string()];
let result = run_commands(&commands, "/tmp", "test:event", HookFailureMode::Fatal);
assert!(result.is_err());
}
#[test]
fn run_commands_non_fatal_continues_on_failure() {
let commands = vec!["exit 1".to_string(), "echo still-runs".to_string()];
let result =
run_commands(&commands, "/tmp", "test:event", HookFailureMode::NonFatal).unwrap();
assert!(!result.all_succeeded);
assert_eq!(result.commands.len(), 2);
assert!(!result.commands[0].success);
assert!(result.commands[1].success);
}
}