use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::ExitStatus;
fn default_order() -> i32 {
100
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Hook {
#[serde(default = "default_order")]
pub order: i32,
#[serde(default)]
pub propagate: bool,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub dir: Option<String>,
#[serde(default)]
pub inputs: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HookResult {
pub hook: Hook,
pub success: bool,
pub exit_status: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
pub error: Option<String>,
}
impl HookResult {
#[must_use]
pub fn success(
hook: Hook,
exit_status: ExitStatus,
stdout: String,
stderr: String,
duration_ms: u64,
) -> Self {
Self {
hook,
success: true,
exit_status: exit_status.code(),
stdout,
stderr,
duration_ms,
error: None,
}
}
#[allow(clippy::too_many_arguments)] #[must_use]
pub fn failure(
hook: Hook,
exit_status: Option<ExitStatus>,
stdout: String,
stderr: String,
duration_ms: u64,
error: String,
) -> Self {
Self {
hook,
success: false,
exit_status: exit_status.and_then(|s| s.code()),
stdout,
stderr,
duration_ms,
error: Some(error),
}
}
#[must_use]
pub fn timeout(hook: Hook, stdout: String, stderr: String, timeout_seconds: u64) -> Self {
Self {
hook,
success: false,
exit_status: None,
stdout,
stderr,
duration_ms: timeout_seconds * 1000,
error: Some(format!(
"Command timed out after {} seconds",
timeout_seconds
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookExecutionConfig {
pub default_timeout_seconds: u64,
pub fail_fast: bool,
pub state_dir: Option<PathBuf>,
}
impl Default for HookExecutionConfig {
fn default() -> Self {
Self {
default_timeout_seconds: 300, fail_fast: true,
state_dir: None, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExecutionStatus {
Running,
Completed,
Failed,
Cancelled,
}
impl std::fmt::Display for ExecutionStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Running => write!(f, "Running"),
Self::Completed => write!(f, "Completed"),
Self::Failed => write!(f, "Failed"),
Self::Cancelled => write!(f, "Cancelled"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Hooks {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "onEnter")]
pub on_enter: Option<HashMap<String, Hook>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "onExit")]
pub on_exit: Option<HashMap<String, Hook>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "prePush")]
pub pre_push: Option<HashMap<String, Hook>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_serialization() {
let hook = Hook {
order: 50,
propagate: false,
command: "npm".to_string(),
args: vec!["install".to_string()],
dir: Some("/tmp".to_string()),
inputs: vec![],
source: Some(false),
};
let json = serde_json::to_string(&hook).unwrap();
let deserialized: Hook = serde_json::from_str(&json).unwrap();
assert_eq!(hook, deserialized);
}
#[test]
fn test_hook_defaults() {
let json = r#"{"command": "echo", "args": ["hello"]}"#;
let hook: Hook = serde_json::from_str(json).unwrap();
assert_eq!(hook.order, 100); assert_eq!(hook.command, "echo");
assert_eq!(hook.args, vec!["hello"]);
assert_eq!(hook.dir, None);
assert!(hook.inputs.is_empty());
assert_eq!(hook.source, None); }
#[test]
fn test_hook_result_success() {
let hook = Hook {
order: 100,
propagate: false,
command: "echo".to_string(),
args: vec!["test".to_string()],
dir: None,
inputs: vec![],
source: None,
};
let exit_status = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
.args(if cfg!(windows) {
vec!["/C", "exit 0"]
} else {
vec![]
})
.output()
.unwrap()
.status;
let result = HookResult::success(
hook.clone(),
exit_status,
"test\n".to_string(),
String::new(),
100,
);
assert!(result.success);
assert_eq!(result.hook, hook);
assert_eq!(result.exit_status, Some(0));
assert_eq!(result.stdout, "test\n");
assert_eq!(result.stderr, "");
assert_eq!(result.duration_ms, 100);
assert!(result.error.is_none());
}
#[test]
fn test_hook_result_failure() {
let hook = Hook {
order: 100,
propagate: false,
command: "false".to_string(),
args: vec![],
dir: None,
inputs: vec![],
source: None,
};
let exit_status = Some(
std::process::Command::new(if cfg!(windows) { "cmd" } else { "false" })
.args(if cfg!(windows) {
vec!["/C", "exit 1"]
} else {
vec![]
})
.output()
.unwrap()
.status,
);
let result = HookResult::failure(
hook.clone(),
exit_status,
String::new(),
"command failed".to_string(),
50,
"Process exited with non-zero status".to_string(),
);
assert!(!result.success);
assert_eq!(result.hook, hook);
assert_eq!(result.exit_status, Some(1));
assert_eq!(result.stderr, "command failed");
assert_eq!(result.duration_ms, 50);
assert_eq!(
result.error,
Some("Process exited with non-zero status".to_string())
);
}
#[test]
fn test_hook_result_timeout() {
let hook = Hook {
order: 100,
propagate: false,
command: "sleep".to_string(),
args: vec!["1000".to_string()],
dir: None,
inputs: vec![],
source: None,
};
let result = HookResult::timeout(hook.clone(), String::new(), String::new(), 10);
assert!(!result.success);
assert_eq!(result.hook, hook);
assert!(result.exit_status.is_none());
assert_eq!(result.duration_ms, 10000);
assert!(result.error.as_ref().unwrap().contains("timed out"));
}
#[test]
fn test_execution_config_default() {
let config = HookExecutionConfig::default();
assert_eq!(config.default_timeout_seconds, 300);
assert!(config.fail_fast);
assert!(config.state_dir.is_none());
}
#[test]
fn test_execution_status_display() {
assert_eq!(ExecutionStatus::Running.to_string(), "Running");
assert_eq!(ExecutionStatus::Completed.to_string(), "Completed");
assert_eq!(ExecutionStatus::Failed.to_string(), "Failed");
assert_eq!(ExecutionStatus::Cancelled.to_string(), "Cancelled");
}
}