j-cli 12.9.73

A fast CLI tool for alias management, daily reports, and productivity
use crate::command::chat::infra::hook::types::*;
use crate::command::chat::permission::JcliConfig;
use crate::config::YamlConfig;
use crate::util::log::{write_error_log, write_info_log};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;

// ========== HookKind 枚举 ==========

/// Hook 种类:Shell 命令(子进程)、LLM(prompt 模板调 LLM)、内置 Rust 闭包(进程内)
#[derive(Clone)]
pub enum HookKind {
    /// Shell 命令,通过 `sh -c` 子进程执行(现有行为)
    Shell(ShellHook),
    /// LLM hook,通过 prompt 模板调用 LLM API,返回 HookResult JSON
    Llm(LlmHook),
    /// 内置 Rust 闭包,进程内零开销执行
    Builtin(BuiltinHook),
}

/// Shell hook:一条命令 + 超时 + 失败策略 + 条件过滤
#[derive(Debug, Clone)]
pub struct ShellHook {
    /// Hook 目录名(目录布局下有值,session hook 为 None)
    pub name: Option<String>,
    pub command: String,
    pub timeout: u64,
    pub retry: u32,
    pub on_error: OnError,
    pub filter: HookFilter,
    /// Hook 目录路径(目录布局下有值,session hook 为 None)
    pub dir_path: Option<PathBuf>,
}

/// LLM hook:prompt 模板 + 模型覆盖 + 超时 + 重试 + 失败策略 + 条件过滤
#[derive(Debug, Clone)]
pub struct LlmHook {
    /// Hook 目录名(目录布局下有值,session hook 为 None)
    pub name: Option<String>,
    /// Prompt 模板,支持 {{variable}} 模板变量
    pub prompt: String,
    /// 模型名覆盖(空则使用当前活跃 provider 的模型)
    pub model: Option<String>,
    /// 超时秒数
    pub timeout: u64,
    /// 重试次数(仅 Err 路径生效)
    pub retry: u32,
    /// 失败策略
    pub on_error: OnError,
    /// 条件过滤
    pub filter: HookFilter,
    /// Hook 目录路径(目录布局下有值,session hook 为 None)
    #[allow(dead_code)]
    pub dir_path: Option<PathBuf>,
}

/// 内置 hook 的处理函数类型
pub type BuiltinHookFn = Arc<dyn Fn(&HookContext) -> Option<HookResult> + Send + Sync>;

/// 内置 hook:一个命名的 Rust 闭包
pub struct BuiltinHook {
    /// 唯一名称,用于列出/调试(如 "tasks_status"、"todo_nag")
    pub name: String,
    /// 实际执行的 Rust 闭包
    pub handler: BuiltinHookFn,
}

impl Clone for BuiltinHook {
    fn clone(&self) -> Self {
        BuiltinHook {
            name: self.name.clone(),
            handler: Arc::clone(&self.handler),
        }
    }
}

impl std::fmt::Debug for HookKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HookKind::Shell(shell) => f
                .debug_struct("HookKind::Shell")
                .field("name", &shell.name)
                .field("command", &shell.command)
                .field("timeout", &shell.timeout)
                .field("on_error", &shell.on_error)
                .finish(),
            HookKind::Llm(llm) => f
                .debug_struct("HookKind::Llm")
                .field("name", &llm.name)
                .field("prompt", &llm.prompt.len())
                .field("model", &llm.model)
                .field("timeout", &llm.timeout)
                .field("retry", &llm.retry)
                .finish(),
            HookKind::Builtin(builtin) => f
                .debug_struct("HookKind::Builtin")
                .field("name", &builtin.name)
                .finish(),
        }
    }
}

// ========== HookDef(YAML 兼容格式)==========

/// Hook 定义(YAML 兼容):支持 bash 和 llm 两种类型
///
/// YAML 示例(bash):
/// ```yaml
/// - command: "echo '{\"user_input\": \"hooked\"}'"
///   timeout: 10
///   on_error: skip
/// ```
///
/// YAML 示例(llm):
/// ```yaml
/// - type: llm
///   prompt: |
///     检查以下用户输入是否包含敏感信息:
///     {{user_input}}
///     如果包含,返回 action=stop + retry_feedback。
///   timeout: 30
///   retry: 1
///   on_error: skip
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookDef {
    /// Hook 类型:bash(默认)或 llm
    #[serde(default)]
    pub r#type: HookType,
    /// Shell 命令(type=bash 时必填,通过 `sh -c` 执行)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// LLM prompt 模板(type=llm 时必填,支持 {{variable}} 模板变量)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
    /// LLM 模型名覆盖(type=llm 时可选,空则使用当前活跃 provider 的模型)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// 超时秒数(bash 默认 10,llm 默认 30)
    #[serde(default = "default_timeout")]
    pub timeout: u64,
    /// 重试次数(仅 Err 路径生效,默认 0 即不重试)
    #[serde(default)]
    pub retry: u32,
    /// 脚本/LLM 失败时的处理策略(默认 skip)
    #[serde(default)]
    pub on_error: OnError,
    /// 条件过滤:仅当条件匹配时执行(默认无过滤)
    #[serde(default, skip_serializing_if = "HookFilter::is_empty")]
    pub filter: HookFilter,
}

impl HookDef {
    /// 转换为 HookKind(根据 type 字段分派)
    pub fn into_hook_kind(self) -> Result<HookKind, String> {
        match self.r#type {
            HookType::Bash => {
                let command = self.command.unwrap_or_default();
                if command.is_empty() {
                    return Err("bash hook 缺少 command 字段".to_string());
                }
                Ok(HookKind::Shell(ShellHook {
                    name: None,
                    command,
                    timeout: self.timeout,
                    retry: self.retry,
                    on_error: self.on_error,
                    filter: self.filter,
                    dir_path: None,
                }))
            }
            HookType::Llm => {
                let prompt = self.prompt.unwrap_or_default();
                if prompt.is_empty() {
                    return Err("llm hook 缺少 prompt 字段".to_string());
                }
                Ok(HookKind::Llm(LlmHook {
                    name: None,
                    prompt,
                    model: self.model,
                    timeout: if self.timeout == default_timeout() {
                        default_llm_timeout()
                    } else {
                        self.timeout
                    },
                    retry: if self.retry == 0 { 1 } else { self.retry },
                    on_error: self.on_error,
                    filter: self.filter,
                    dir_path: None,
                }))
            }
        }
    }
}

impl From<HookDef> for HookKind {
    fn from(def: HookDef) -> Self {
        def.into_hook_kind().unwrap_or_else(|e| {
            write_error_log("HookDef::into_hook_kind", &e);
            // 回退到空 Shell hook(不会执行有效操作,但不会 panic)
            HookKind::Shell(ShellHook {
                name: None,
                command: String::new(),
                timeout: 0,
                retry: 0,
                on_error: OnError::Skip,
                filter: HookFilter::default(),
                dir_path: None,
            })
        })
    }
}

// ========== HookDirDef(目录布局下的 HOOK.yaml / HOOK.yml 格式)==========

/// HOOK.yaml / HOOK.yml 定义(目录布局下的格式)
///
/// 与 `HookDef` 的区别:`events` 为列表(一个 hook 可绑定多个事件),无 `command`/`prompt` 以外的不必要字段。
/// 目录布局下 `command` 中的相对路径以 hook 目录为 cwd 解析。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookDirDef {
    /// 绑定的事件列表
    pub events: Vec<HookEvent>,
    /// Hook 类型
    #[serde(default)]
    pub r#type: HookType,
    /// Shell 命令(type=bash 时必填,通过 `sh -c` 执行,cwd 为 hook 目录)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    /// LLM prompt 模板(type=llm 时必填,支持 {{variable}} 模板变量)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
    /// LLM 模型名覆盖
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// 超时秒数(bash 默认 10,llm 默认 30)
    #[serde(default = "default_timeout")]
    pub timeout: u64,
    /// 重试次数(仅 Err 路径生效)
    #[serde(default)]
    pub retry: u32,
    /// 失败策略
    #[serde(default)]
    pub on_error: OnError,
    /// 条件过滤
    #[serde(default, skip_serializing_if = "HookFilter::is_empty")]
    pub filter: HookFilter,
}

impl HookDirDef {
    /// 转换为 `Vec<(HookEvent, HookKind)>`(每个 event 一个条目)
    pub fn into_hook_kinds(
        self,
        name: &str,
        dir_path: &Path,
    ) -> Result<Vec<(HookEvent, HookKind)>, String> {
        if self.events.is_empty() {
            return Err(format!("hook '{}' 的 events 为空", name));
        }
        let kind = match self.r#type {
            HookType::Bash => {
                let command = self.command.unwrap_or_default();
                if command.is_empty() {
                    return Err(format!("bash hook '{}' 缺少 command 字段", name));
                }
                HookKind::Shell(ShellHook {
                    name: Some(name.to_string()),
                    command,
                    timeout: self.timeout,
                    retry: self.retry,
                    on_error: self.on_error,
                    filter: self.filter,
                    dir_path: Some(dir_path.to_path_buf()),
                })
            }
            HookType::Llm => {
                let prompt = self.prompt.unwrap_or_default();
                if prompt.is_empty() {
                    return Err(format!("llm hook '{}' 缺少 prompt 字段", name));
                }
                HookKind::Llm(LlmHook {
                    name: Some(name.to_string()),
                    prompt,
                    model: self.model,
                    timeout: if self.timeout == default_timeout() {
                        default_llm_timeout()
                    } else {
                        self.timeout
                    },
                    retry: if self.retry == 0 { 1 } else { self.retry },
                    on_error: self.on_error,
                    filter: self.filter,
                    dir_path: Some(dir_path.to_path_buf()),
                })
            }
        };
        Ok(self.events.into_iter().map(|e| (e, kind.clone())).collect())
    }
}

// ========== 目录加载函数 ==========

/// 返回用户级 hooks 目录: ~/.jdata/agent/hooks/
pub fn hooks_dir() -> PathBuf {
    let dir = YamlConfig::data_dir().join("agent").join("hooks");
    let _ = std::fs::create_dir_all(&dir);
    dir
}

/// 返回项目级 hooks 目录: .jcli/hooks/(如果存在)
pub fn project_hooks_dir() -> Option<PathBuf> {
    let config_dir = JcliConfig::find_config_dir()?;
    let dir = config_dir.join("hooks");
    if dir.is_dir() { Some(dir) } else { None }
}

/// 从指定目录加载 hooks(遍历子目录,解析 HOOK.yaml 或 HOOK.yml)
pub(crate) fn load_hooks_from_dir(
    dir: &Path,
    source_name: &str,
) -> Vec<(String, HookDirDef, PathBuf)> {
    let mut hooks = Vec::new();
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e,
        Err(_) => return hooks,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        let hook_name = path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();

        // 跳过 example 目录(模板示例,不是实际可执行的 hook)
        if hook_name == "example" {
            continue;
        }

        // 优先 HOOK.yaml,其次 HOOK.yml;两者共存时取 HOOK.yaml
        let hook_yaml = if path.join("HOOK.yaml").exists() {
            path.join("HOOK.yaml")
        } else if path.join("HOOK.yml").exists() {
            path.join("HOOK.yml")
        } else {
            continue;
        };
        let yaml_file_name = hook_yaml
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        match std::fs::read_to_string(&hook_yaml) {
            Ok(content) => match serde_yaml::from_str::<HookDirDef>(&content) {
                Ok(def) => {
                    if def.events.is_empty() {
                        write_error_log(
                            "load_hooks_from_dir",
                            &format!("hook '{}' 的 events 为空,跳过", hook_name),
                        );
                        continue;
                    }
                    hooks.push((hook_name, def, path));
                }
                Err(e) => write_error_log(
                    "load_hooks_from_dir",
                    &format!("解析 {}/{} 失败: {}", hook_name, yaml_file_name, e),
                ),
            },
            Err(e) => write_error_log(
                "load_hooks_from_dir",
                &format!("读取 {}/{} 失败: {}", hook_name, yaml_file_name, e),
            ),
        }
    }
    write_info_log(
        "load_hooks_from_dir",
        &format!("{} 加载了 {} 个 hook", source_name, hooks.len()),
    );
    hooks
}