use crate::error::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookType {
PreSync,
PostSync,
}
impl HookType {
fn file_name(&self) -> &str {
match self {
HookType::PreSync => "pre-sync",
HookType::PostSync => "post-sync",
}
}
}
#[derive(Debug, Clone)]
pub struct HookContext {
pub source: String,
pub destination: String,
pub files_scanned: usize,
pub files_created: usize,
pub files_updated: usize,
pub files_deleted: usize,
pub files_skipped: usize,
pub bytes_transferred: u64,
pub duration_secs: u64,
pub dry_run: bool,
}
impl HookContext {
pub fn to_env_vars(&self) -> HashMap<String, String> {
let mut vars = HashMap::new();
vars.insert("SY_SOURCE".to_string(), self.source.clone());
vars.insert("SY_DESTINATION".to_string(), self.destination.clone());
vars.insert("SY_FILES_SCANNED".to_string(), self.files_scanned.to_string());
vars.insert("SY_FILES_CREATED".to_string(), self.files_created.to_string());
vars.insert("SY_FILES_UPDATED".to_string(), self.files_updated.to_string());
vars.insert("SY_FILES_DELETED".to_string(), self.files_deleted.to_string());
vars.insert("SY_FILES_SKIPPED".to_string(), self.files_skipped.to_string());
vars.insert("SY_BYTES_TRANSFERRED".to_string(), self.bytes_transferred.to_string());
vars.insert("SY_DURATION_SECS".to_string(), self.duration_secs.to_string());
vars.insert("SY_DRY_RUN".to_string(), if self.dry_run { "1" } else { "0" }.to_string());
vars
}
}
#[derive(Debug)]
#[allow(dead_code)] pub struct HookResult {
pub hook_type: HookType,
pub path: PathBuf,
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub duration: Duration,
}
pub struct HookExecutor {
hooks_dir: PathBuf,
abort_on_failure: bool,
}
impl HookExecutor {
pub fn new() -> Result<Self> {
let hooks_dir = Self::default_hooks_dir()?;
Ok(Self { hooks_dir, abort_on_failure: false })
}
pub fn with_abort_on_failure(mut self, abort: bool) -> Self {
self.abort_on_failure = abort;
self
}
fn default_hooks_dir() -> Result<PathBuf> {
let config_dir = dirs::config_dir().ok_or_else(|| crate::error::SyncError::Config("Could not determine config directory".to_string()))?;
Ok(config_dir.join("sy").join("hooks"))
}
fn find_hook(&self, hook_type: HookType) -> Option<PathBuf> {
let base_name = hook_type.file_name();
let extensions = if cfg!(windows) { vec!["bat", "cmd", "ps1", "exe"] } else { vec!["sh", "bash", "zsh", "fish", ""] };
for ext in extensions {
let file_name = if ext.is_empty() { base_name.to_string() } else { format!("{}.{}", base_name, ext) };
let path = self.hooks_dir.join(&file_name);
if path.exists() && path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let permissions = metadata.permissions();
if permissions.mode() & 0o111 == 0 {
tracing::warn!("Hook found but not executable: {}", path.display());
continue;
}
}
}
return Some(path);
}
}
None
}
pub fn execute(&self, hook_type: HookType, context: &HookContext) -> Result<Option<HookResult>> {
let hook_path = match self.find_hook(hook_type) {
Some(path) => path,
None => {
tracing::debug!("No {:?} hook found in {}", hook_type, self.hooks_dir.display());
return Ok(None);
}
};
tracing::info!("Executing {:?} hook: {}", hook_type, hook_path.display());
let start = std::time::Instant::now();
let mut cmd = Command::new(&hook_path);
for (key, value) in context.to_env_vars() {
cmd.env(key, value);
}
let output = match cmd.output() {
Ok(output) => output,
Err(e) => {
let err_msg = format!("Failed to execute hook {}: {}", hook_path.display(), e);
tracing::error!("{}", err_msg);
if self.abort_on_failure {
return Err(crate::error::SyncError::Hook(err_msg));
}
return Ok(Some(HookResult {
hook_type,
path: hook_path,
success: false,
exit_code: None,
stdout: String::new(),
stderr: err_msg,
duration: start.elapsed(),
}));
}
};
let duration = start.elapsed();
let success = output.status.success();
let exit_code = output.status.code();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !success {
tracing::warn!("Hook {:?} failed with exit code {:?}: {}", hook_type, exit_code, hook_path.display());
if !stderr.is_empty() {
tracing::warn!("Hook stderr: {}", stderr);
}
if self.abort_on_failure {
return Err(crate::error::SyncError::Hook(format!("Hook {:?} failed with exit code {:?}", hook_type, exit_code)));
}
} else {
tracing::info!("Hook {:?} completed successfully in {:?}", hook_type, duration);
if !stdout.is_empty() {
tracing::debug!("Hook stdout: {}", stdout);
}
}
Ok(Some(HookResult { hook_type, path: hook_path, success, exit_code, stdout, stderr, duration }))
}
}
impl Default for HookExecutor {
fn default() -> Self {
Self::new().unwrap_or_else(|_| Self { hooks_dir: PathBuf::from("/dev/null"), abort_on_failure: false })
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_hook_context_env_vars() {
let context = HookContext {
source: "/src".to_string(),
destination: "/dst".to_string(),
files_scanned: 100,
files_created: 10,
files_updated: 5,
files_deleted: 2,
files_skipped: 83,
bytes_transferred: 1024,
duration_secs: 30,
dry_run: false,
};
let vars = context.to_env_vars();
assert_eq!(vars.get("SY_SOURCE").unwrap(), "/src");
assert_eq!(vars.get("SY_DESTINATION").unwrap(), "/dst");
assert_eq!(vars.get("SY_FILES_SCANNED").unwrap(), "100");
assert_eq!(vars.get("SY_FILES_CREATED").unwrap(), "10");
assert_eq!(vars.get("SY_DRY_RUN").unwrap(), "0");
}
#[test]
fn test_hook_not_found() {
let temp_dir = TempDir::new().unwrap();
let executor = HookExecutor { hooks_dir: temp_dir.path().to_path_buf(), abort_on_failure: false };
let context = HookContext {
source: "/src".to_string(),
destination: "/dst".to_string(),
files_scanned: 0,
files_created: 0,
files_updated: 0,
files_deleted: 0,
files_skipped: 0,
bytes_transferred: 0,
duration_secs: 0,
dry_run: false,
};
let result = executor.execute(HookType::PreSync, &context).unwrap();
assert!(result.is_none());
}
#[cfg(unix)]
#[test]
fn test_hook_execution() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("pre-sync.sh");
fs::write(&hook_path, "#!/bin/sh\necho \"Source: $SY_SOURCE\"\necho \"Files: $SY_FILES_SCANNED\"\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
}
let executor = HookExecutor { hooks_dir: temp_dir.path().to_path_buf(), abort_on_failure: false };
let context = HookContext {
source: "/test/src".to_string(),
destination: "/test/dst".to_string(),
files_scanned: 42,
files_created: 0,
files_updated: 0,
files_deleted: 0,
files_skipped: 0,
bytes_transferred: 0,
duration_secs: 0,
dry_run: false,
};
let result = executor.execute(HookType::PreSync, &context).unwrap();
assert!(result.is_some());
let hook_result = result.unwrap();
assert!(hook_result.success);
assert!(hook_result.stdout.contains("/test/src"));
assert!(hook_result.stdout.contains("42"));
}
#[cfg(unix)]
#[test]
fn test_hook_failure_abort() {
let temp_dir = TempDir::new().unwrap();
let hook_path = temp_dir.path().join("pre-sync.sh");
fs::write(&hook_path, "#!/bin/sh\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
}
let executor = HookExecutor { hooks_dir: temp_dir.path().to_path_buf(), abort_on_failure: true };
let context = HookContext {
source: "/src".to_string(),
destination: "/dst".to_string(),
files_scanned: 0,
files_created: 0,
files_updated: 0,
files_deleted: 0,
files_skipped: 0,
bytes_transferred: 0,
duration_secs: 0,
dry_run: false,
};
let result = executor.execute(HookType::PreSync, &context);
assert!(result.is_err());
}
}