checkmate-cli 0.4.1

Checkmate - API Testing Framework CLI
//! Hook system - Run custom scripts at test lifecycle events
//!
//! Hooks are executable scripts in .checkmate/hooks/:
//! - pre_run: Before tests start
//! - post_run: After tests complete (always)
//! - on_pass: When all tests pass
//! - on_fail: When any tests fail
//!
//! Environment variables passed to hooks:
//! - CM_RUN_ID: The run identifier
//! - CM_SPEC: Spec file being run
//! - CM_TOTAL: Total test count
//! - CM_PASSED: Passed test count
//! - CM_FAILED: Failed test count
//! - CM_ERRORS: Error count
//! - CM_DURATION_MS: Run duration in milliseconds

use std::path::Path;
use std::process::Command;

use crate::project::CheckmateProject;

/// Hook event types
#[derive(Debug, Clone, Copy)]
pub enum HookEvent {
    PreRun,
    PostRun,
    OnPass,
    OnFail,
}

impl HookEvent {
    fn script_name(&self) -> &'static str {
        match self {
            HookEvent::PreRun => "pre_run",
            HookEvent::PostRun => "post_run",
            HookEvent::OnPass => "on_pass",
            HookEvent::OnFail => "on_fail",
        }
    }
}

/// Context passed to hooks via environment variables
#[derive(Debug, Default)]
pub struct HookContext {
    pub run_id: Option<String>,
    pub spec: Option<String>,
    pub total: usize,
    pub passed: usize,
    pub failed: usize,
    pub errors: usize,
    pub duration_ms: u64,
}

/// Fire a hook event (non-blocking, fire-and-forget)
pub fn fire_hook(project: &CheckmateProject, event: HookEvent, ctx: &HookContext) {
    let script_name = event.script_name();

    // Look for hook script with common extensions
    let hook_path = find_hook_script(&project.hooks_dir, script_name);

    let Some(hook_path) = hook_path else {
        return; // No hook configured
    };

    // Build environment
    let mut cmd = Command::new(&hook_path);

    if let Some(ref run_id) = ctx.run_id {
        cmd.env("CM_RUN_ID", run_id);
    }
    if let Some(ref spec) = ctx.spec {
        cmd.env("CM_SPEC", spec);
    }
    cmd.env("CM_TOTAL", ctx.total.to_string());
    cmd.env("CM_PASSED", ctx.passed.to_string());
    cmd.env("CM_FAILED", ctx.failed.to_string());
    cmd.env("CM_ERRORS", ctx.errors.to_string());
    cmd.env("CM_DURATION_MS", ctx.duration_ms.to_string());

    // Set working directory to project root
    cmd.current_dir(&project.root);

    // Fire and forget - spawn in background
    match cmd.spawn() {
        Ok(_) => {
            // Hook started successfully
        }
        Err(e) => {
            eprintln!("Warning: Failed to run hook {}: {}", script_name, e);
        }
    }
}

/// Find hook script, trying common extensions
fn find_hook_script(hooks_dir: &Path, name: &str) -> Option<std::path::PathBuf> {
    // Try without extension first (for executable scripts with shebang)
    let base = hooks_dir.join(name);
    if base.exists() && is_executable(&base) {
        return Some(base);
    }

    // Try common extensions
    for ext in &["sh", "bash", "py", "rb", "js"] {
        let with_ext = hooks_dir.join(format!("{}.{}", name, ext));
        if with_ext.exists() {
            return Some(with_ext);
        }
    }

    None
}

/// Check if a file is executable (Unix)
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;
    path.metadata()
        .map(|m| m.permissions().mode() & 0o111 != 0)
        .unwrap_or(false)
}

/// Check if a file is executable (Windows - just check if exists)
#[cfg(not(unix))]
fn is_executable(path: &Path) -> bool {
    path.exists()
}

/// Fire pre_run hook
pub fn fire_pre_run(project: &CheckmateProject, spec: Option<&str>) {
    let ctx = HookContext {
        spec: spec.map(String::from),
        ..Default::default()
    };
    fire_hook(project, HookEvent::PreRun, &ctx);
}

/// Fire post_run hooks (post_run, and on_pass or on_fail)
pub fn fire_post_run(
    project: &CheckmateProject,
    run_id: &str,
    spec: Option<&str>,
    total: usize,
    passed: usize,
    failed: usize,
    errors: usize,
    duration_ms: u64,
) {
    let ctx = HookContext {
        run_id: Some(run_id.to_string()),
        spec: spec.map(String::from),
        total,
        passed,
        failed,
        errors,
        duration_ms,
    };

    // Always fire post_run
    fire_hook(project, HookEvent::PostRun, &ctx);

    // Fire on_pass or on_fail based on results
    if failed == 0 && errors == 0 {
        fire_hook(project, HookEvent::OnPass, &ctx);
    } else {
        fire_hook(project, HookEvent::OnFail, &ctx);
    }
}