//! AI 工具注册表构建 + 文件路径校验

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};

use tokio::sync::Mutex as TokioMutex;

use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};

use df_types::types::new_id;

use crate::commands::now_millis;

/// CRUD list 工具的默认返回上限（防 LLM context 膨胀）
/// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50;

/// run_command 默认超时（秒）。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源（F-260616-04）。
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;

/// 生成行级 unified diff（无外部依赖，基于 LCS）。
/// 仅标 +/- 前缀，不做 hunk header（足够审批卡/审计留痕可读）。
/// 文件改动通常集中在 old_text/new_text 局部，整体行对比可直观呈现。
///
/// AE-2025-03（路径 B）：audit.rs 挂起审批前预读旧文件复用此函数生成 diff，
/// 供前端审批卡即时预览（write_file 审批不再只看裸 content）。故 pub(crate)。
///
/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB
/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码
/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为
/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。
/// (patch_file 调用方传入的 old/new 是局部替换,通常远小于全文,正常路径仍走 LCS)
pub(crate) fn generate_diff(old: &str, new: &str) -> String {
    let a: Vec<&str> = old.lines().collect();
    let b: Vec<&str> = new.lines().collect();
    let (n, m) = (a.len(), b.len());

    // 超长输入降级:两文件均>1000 行跳过 LCS DP(防 200MB+ 内存峰值),
    // 退化为朴素全删全增 diff(配 300 行截断),足够审批卡看出"大范围改动"语义。
    const LCS_MAX_LINES: usize = 1000;
    if n > LCS_MAX_LINES && m > LCS_MAX_LINES {
        let mut out = String::new();
        let mut changes = 0usize;
        for line in &a { out.push_str("-"); out.push_str(line); out.push('\n'); changes += 1; }
        for line in &b { out.push_str("+"); out.push_str(line); out.push('\n'); changes += 1; }
        if changes > 300 {
            let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
            return format!(
                "{}\n... (输入过长({}/{} 行)已跳过 LCS 退化对比,diff 已截断,共 {} 处变更行)",
                kept, n, m, changes
            );
        }
        return out.trim_end_matches('\n').to_string() + "\n";
    }

    // LCS 动态规划表（usize 即可；已由 LCS_MAX_LINES 挡住超大输入，行数有限）
    let mut dp = vec![vec![0usize; m + 1]; n + 1];
    for i in (0..n).rev() {
        for j in (0..m).rev() {
            dp[i][j] = if a[i] == b[j] {
                dp[i + 1][j + 1] + 1
            } else {
                dp[i + 1][j].max(dp[i][j + 1])
            };
        }
    }

    // 回溯：相同行原样输出(空格前缀)，旧独有加 '-'，新独有加 '+'
    let mut out = String::new();
    let (mut i, mut j) = (0usize, 0usize);
    let mut changes = 0usize;
    while i < n && j < m {
        if a[i] == b[j] {
            out.push_str(" "); out.push_str(a[i]); out.push('\n');
            i += 1; j += 1;
        } else if dp[i + 1][j] >= dp[i][j + 1] {
            out.push_str("-"); out.push_str(a[i]); out.push('\n');
            i += 1; changes += 1;
        } else {
            out.push_str("+"); out.push_str(b[j]); out.push('\n');
            j += 1; changes += 1;
        }
    }
    while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
    while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }

    // 极端情况：差异行过多（>300），截断防 LLM context 爆炸
    if changes > 300 {
        let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
        return format!("{}\n... (diff 已截断，共 {} 处变更行)", kept, changes);
    }
    // 末尾多余换行清理（保持单尾换行）
    out.trim_end_matches('\n').to_string() + "\n"
}

/// 验证文件路径：禁止访问系统敏感目录 + 防路径遍历绕过
///
/// 安全要点(BUG-260617-03):
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
///    LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
///    解码后再走分段归一化校验。
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
///    合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
///    解码后 `..%2f` / `..\` 等变体。
/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。
fn validate_path(path: &str) -> anyhow::Result<()> {
    // 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串)
    //    percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。
    use percent_encoding::percent_decode_str;
    let decoded = percent_decode_str(path).decode_utf8_lossy().into_owned();

    // 2) 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
    let normalized = decoded.replace('/', "\\");
    let lower = normalized.to_lowercase();

    // 3) 分段归一化检查:按分隔符切分,任何一段 == ".." 视为路径遍历。
    //    比纯 contains("..") 更精准(不误伤 my..file 这类合法名),且能识别解码后的 `..`。
    let has_traversal = lower
        .split(|c| c == '\\' || c == '/')
        .any(|seg| seg == "..");
    if has_traversal {
        anyhow::bail!("禁止路径遍历 (..)");
    }

    if lower.contains("\\.ssh")
        || lower.contains("\\.aws")
        || lower.contains("\\.gnupg")
        || lower.contains("\\appdata\\")
        || lower.contains("\\programdata\\")
        || lower.contains("\\windows\\")
        || lower.contains("\\system32\\")
    {
        anyhow::bail!("禁止访问敏感系统目录");
    }
    Ok(())
}

/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
fn truncate_output(s: &str, max: usize) -> (String, bool) {
    if s.len() <= max {
        return (s.to_string(), false);
    }
    let end = s.len();
    let mut start = end.saturating_sub(max);
    // 推进到 char 边界,避免从多字节 UTF-8 中间切开
    while start < end && !s.is_char_boundary(start) {
        start += 1;
    }
    let tail = &s[start..];
    (
        format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
        true,
    )
}

// ============================================================
// patch_file 三模式共用底层（F-260617-01）
//
// 三模式互斥（old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点），
// 后两者均归约为「按行号区间 [start, end]（1-based 含首尾）替换为 new_text」，
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
// 提取为纯函数便于直接单测（不依赖 async/fs/锁），handler 闭包内复用。
// ============================================================

/// 按行号区间替换：将 content 第 [start, end] 行（1-based, 含首尾）替换为 new_text。
///
/// - 与 search_files 一致用 `\n` 切分；末尾尾随 `\n` 保留（避免误删文件末换行）。
/// - 行号 1-based，含首尾：replace_lines(2,3) 替换第 2、3 两行。
/// - 越界（start<1 或 end>lines.len() 或 start>end）返 Err。
/// - new_text 可为多行（含 `\n`），整体作为替换块原样插入。
pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text: &str) -> anyhow::Result<String> {
    if start < 1 {
        anyhow::bail!("start 行号必须 >= 1（got {}）", start);
    }
    if start > end {
        anyhow::bail!("start({}) > end({})", start, end);
    }
    // 末尾尾随换行的文件：lines() 会丢掉末尾空段，用 split 保留语义更稳，
    // 但为与 search_files/read_file 行计数一致，这里用 lines() 并单独处理尾换行。
    let trailing_newline = content.ends_with('\n');
    let lines: Vec<&str> = content.lines().collect();
    if end > lines.len() {
        anyhow::bail!("end 行号 {} 超出文件总行数 {}（1-based 含尾）", end, lines.len());
    }
    // splice: 前 start-1 行 + new_text + 第 end 行之后
    let mut out = String::new();
    for (i, line) in lines.iter().enumerate() {
        let lineno = i + 1;
        if lineno < start {
            out.push_str(line);
            out.push('\n');
        } else if lineno == start {
            out.push_str(new_text);
            // 若 new_text 未以换行结尾且其后还有保留行，需补一个换行分隔
            if !new_text.ends_with('\n') {
                out.push('\n');
            }
        } else if lineno > end {
            out.push_str(line);
            out.push('\n');
        }
        // start < lineno <= end 的行被丢弃（落入区间内）
    }
    // 还原尾换行语义：原文件以 \n 结尾则补回（splice 循环每行均加 \n，
    // 仅当 new_text 区间恰好覆盖到文件末尾时可能少一个尾换行，统一处理）
    // 注意：若 new_text 以 \n 结尾，splice 已带尾换行；否则原文件尾换行由末行 push('\n') 提供。
    // 仅当文件完全不以换行结尾时，需去掉末尾多余 \n。
    if !trailing_newline && out.ends_with('\n') {
        out.pop();
    }
    Ok(out)
}

/// 锚点解析为行号区间：在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
///
/// - 子串匹配（大小写敏感，与 search_files 一致）。
/// - start/end 任一找不到返 Err；start 行号 > end 行号返 Err（即 end 必须在 start 之后或同行）。
/// - 同一行同时命中 start/end：start==end，区间退化为单行替换。
/// - 返回 (start_line, end_line)（1-based 含首尾）。
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
    if start.is_empty() {
        anyhow::bail!("anchor.start 不能为空");
    }
    if end.is_empty() {
        anyhow::bail!("anchor.end 不能为空");
    }
    let mut start_line: Option<usize> = None;
    for (i, line) in content.lines().enumerate() {
        if line.contains(start) {
            start_line = Some(i + 1);
            break;
        }
    }
    let start_line = start_line
        .ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;

    // end 在 start_line 之后（含 start_line 同行）第一个命中的行
    let mut end_line: Option<usize> = None;
    for (i, line) in content.lines().enumerate() {
        let lineno = i + 1;
        if lineno < start_line {
            continue;
        }
        if line.contains(end) {
            end_line = Some(lineno);
            break;
        }
    }
    let end_line = end_line
        .ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行（第 {} 行）之后找到", end, start_line))?;

    // start_line <= end_line 已由搜索顺序保证，此处断言兜底
    debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
    Ok((start_line, end_line))
}

/// 全局文件锁表：每个路径一把互斥锁（L1 防护，防同文件并发读写冲突）
///
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
/// 单用户桌面应用，Mutex 够用且简单（~15 行），不需要全局文件队列。
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
    LazyLock::new(|| TokioMutex::new(HashMap::new()));

/// workspace 根目录（项目根 = src-tauri 上两级，编译期固定）
fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(|p| p.parent())
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("."))
}

/// 解析文件工具路径：相对路径锚定 workspace_root，禁止越出项目目录
///
/// 双层校验：
/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
///    仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
    resolve_workspace_path_impl(path)
}

/// AE-2025-04：mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径（去 .. / symlink 逃逸）。pub(crate) wrapper 转调私有 impl，保持原私有函数
/// 可见性边界（不暴露给 crate 外，但允许同 crate ai 模块 mod.rs 调用）。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
    resolve_workspace_path_impl(path)
}

fn resolve_workspace_path_impl(path: &str) -> anyhow::Result<PathBuf> {
    validate_path(path)?;
    let root = workspace_root();
    let resolved = if Path::new(path).is_absolute() {
        PathBuf::from(path)
    } else {
        root.join(path)
    };
    // 词法层:防明显越界(不存在路径的兜底)
    if !resolved.starts_with(&root) {
        anyhow::bail!("禁止访问项目目录之外: {}", path);
    }
    // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace
    if resolved.exists() {
        let canon_root = root.canonicalize()?;
        let canon_resolved = resolved.canonicalize()?;
        if !canon_resolved.starts_with(&canon_root) {
            anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path);
        }
    }
    Ok(resolved)
}

/// 为项目绑定代码目录的公共逻辑(FR-D7 抽取:create_project 与 bind_directory 共用,消除逐行重复)
///
/// 步骤:① 目录存在性校验 ② normalize_path 比较防重复绑定(跨其他项目)
///      ③ stack 由调用方提供则用,否则 spawn_blocking 探测(detect_stack 内含多次同步 fs IO)
///      ④ 写回 path / stack 字段
///
/// - `stack_opt`: 调用方解析好的逗号分隔技术栈原始串(None=让本函数自动探测)
/// - 返回最终采用的 stack(供调用方回填响应,保持原 bind_directory 返回结构)
async fn bind_dir_to_project(
    repo: &df_storage::crud::ProjectRepo,
    id: &str,
    path: &str,
    stack_opt: Option<&str>,
) -> anyhow::Result<Vec<String>> {
    let dir = std::path::Path::new(path);
    if !dir.is_dir() {
        anyhow::bail!("目录不存在: {path}");
    }
    // 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict
    // 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。
    let target = df_project::scan::normalize_path(path);
    if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? {
        anyhow::bail!("目录已被项目「{}」绑定", conflict.name);
    }
    // stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime)
    let stack: Vec<String> = if let Some(s) = stack_opt {
        s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
    } else {
        let dir_buf = std::path::PathBuf::from(path);
        tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf))
            .await
            .map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))??
    };
    let stack_json = serde_json::to_string(&stack)?;
    repo.update_field(id, "path", path).await?;
    repo.update_field(id, "stack", &stack_json).await?;
    Ok(stack)
}

/// 构建 AI 工具注册表 — handler 即唯一执行路径（schema+risk+实现同源，消除双轨）
///
/// SMELL-P0-2 拆分:原 1091 行单函数拆为两层注册。
/// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。
/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。
/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
    let mut registry = AiToolRegistry::new();
    register_data_tools(&mut registry, db);
    register_file_tools(&mut registry);
    register_http_tools(&mut registry);
    registry
}

/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
///
/// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。
// (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。)
// 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。
fn register_http_tools(registry: &mut AiToolRegistry) {
    registry.register(
        "http_request", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
        // schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼
        {
            let mut props = serde_json::Map::new();
            props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
            props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
            props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
            props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
            props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
            props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
            serde_json::json!({
                "type": "object",
                "properties": props,
                "required": ["url"],
            })
        },
        RiskLevel::High,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            // 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
            super::http::execute_http_request(args).await
        })),
    );
}

/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
/// ai_approve → run_workflow_inner 另走完整 State 路径。
///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
///
/// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数:
/// - register_project_tools(8):list/update/create/bind/delete/restore/purge/get_count
/// - register_task_tools(6):list/create/update/advance/delete/get_count
/// - register_workflow_tools(1):run_workflow(High,单独,handler 防御兜底)
/// - register_idea_tools(2):list/create
/// - register_trash_tools(1):list_trash
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致,
/// 行为零变更(基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    register_project_tools(registry, db);
    register_task_tools(registry, db);
    register_workflow_tools(registry);
    register_idea_tools(registry, db);
    register_trash_tools(registry, db);
}

/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    registry.register(
        "list_projects", "列出所有项目，支持 offset/limit 分页。返回 items（项目列表）、total（总量）、has_more（是否有更多页）。默认 limit=50",
        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let items = repo.list_active().await?; // list_active 排除回收站(deleted_at)，防 LLM 看到已软删项目
                let total = items.len();
                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
                let has_more = (offset + page_items.len()) < total;
                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
            })
        })},
    );
    registry.register(
        "update_project", "更新项目的指定字段（name/status/description/path/stack），需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
        df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
                let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
                // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
                if !df_storage::crud::is_allowed_column("projects", field) {
                    anyhow::bail!("不允许更新字段 '{}'", field);
                }
                let repo = df_storage::crud::ProjectRepo::new(&db);
                repo.update_field(id, field, value).await?;
                Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
            })
        })},
    );
    registry.register(
        "create_project", "创建新项目，可选传 path/stack 一步完成创建+绑定目录（无需再调 bind_directory）",
        df_ai::ai_tools::object_schema(vec![
            ("name", "string", true), ("description", "string", false),
            ("path", "string", false), ("stack", "string", false),
        ]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
                let description = args["description"].as_str().unwrap_or("");
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let record = ProjectRecord {
                    id: new_id(), name: name.to_string(), description: description.to_string(),
                    status: "planning".to_string(), idea_id: None,
                    path: None, stack: None,
                    created_at: now_millis(), updated_at: now_millis(),
                };
                let id = record.id.clone();
                repo.insert(record).await?;
                // path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
                let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
                if let Some(path) = path_opt {
                    let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
                    let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
                    Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
                } else {
                    Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
                }
            })
        })},
    );
    registry.register(
        "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
        df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
                let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
                let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
                Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
            })
        })},
    );
    registry.register(
        "delete_project", "删除项目（移入回收站，可恢复。永久删除用 purge_project）",
        df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let deleted = repo.soft_delete(id).await?;
                Ok(serde_json::json!({ "deleted": deleted, "id": id }))
            })
        })},
    );
    registry.register(
        "restore_project", "从回收站恢复已删除项目",
        df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let restored = repo.restore(id).await?;
                Ok(serde_json::json!({ "restored": restored, "id": id }))
            })
        })},
    );
    registry.register(
        "purge_project", "永久删除项目及关联数据（不可恢复）",
        df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let purged = repo.purge_with_descendants(id).await?;
                Ok(serde_json::json!({ "purged": purged, "id": id }))
            })
        })},
    );
    // ── 探总量工具 (Low risk, F-260616-08) ──
    registry.register(
        "get_project_count", "获取项目总数（未删除项目），用于分页策略判断。返回 { total: usize }",
        df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |_args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let items = repo.list_active().await?;
                Ok(serde_json::json!({ "total": items.len() }))
            })
        })},
    );
}

/// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    registry.register(
        "list_tasks", "列出任务，可按 project_id 筛选，支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
        df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::TaskRepo::new(&db);
                let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
                    repo.query("project_id", pid).await?
                } else {
                    repo.list_all().await?
                };
                let total = tasks.len();
                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
                let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
                let has_more = (offset + page_items.len()) < total;
                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
            })
        })},
    );
    registry.register(
        "create_task", "在指定项目下创建新任务",
        df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
                let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
                let repo = df_storage::crud::TaskRepo::new(&db);
                let record = TaskRecord {
                    id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
                    description: args["description"].as_str().unwrap_or("").to_string(),
                    // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
                    status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
                    branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
                    review_rounds: 0,
                    output_json: None,
                    created_at: now_millis(), updated_at: now_millis(),
                };
                let id = record.id.clone();
                repo.insert(record).await?;
                Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
            })
        })},
    );
    registry.register(
        "update_task", "更新任务的指定字段（title/description/priority/assignee 等），需要提供任务 ID、字段名和新值。注意：status 改动须走 advance_task 工具（状态机推进，7 态 todo/in_progress/in_review/testing/blocked/done/cancelled，按 target_status 推进不可跳跃），本工具不接受 status 字段（防绕过状态机）",
        df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
                let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
                // F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进),
                // 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。
                // 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
                if field == "status" {
                    anyhow::bail!(
                        "status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
                    );
                }
                // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
                if !df_storage::crud::is_allowed_column("tasks", field) {
                    anyhow::bail!("不允许更新字段 '{}'", field);
                }
                let repo = df_storage::crud::TaskRepo::new(&db);
                repo.update_field(id, field, value).await?;
                Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
            })
        })},
    );
    // F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
    // D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
    // df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
    // commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
    // 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
    registry.register(
        "advance_task", "推进任务状态，必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled)，按 target_status 推进，不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
        df_ai::ai_tools::object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let target_status = args["target_status"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
                // 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
                // 与 commands::task::advance_task IPC 同源,避免双轨。
                let repo = df_storage::crud::TaskRepo::new(&db);
                let updated = df_nodes::task_advance_node::advance_task_atomic(
                    &repo, id, target_status,
                ).await?;
                // 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
                Ok(serde_json::to_value(&updated)?)
            })
        })},
    );
    // FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
    // tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
    registry.register(
        "delete_task", "删除任务（移入回收站，可恢复）",
        df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
                let repo = df_storage::crud::TaskRepo::new(&db);
                let deleted = repo.soft_delete(id).await?;
                Ok(serde_json::json!({ "deleted": deleted, "id": id }))
            })
        })},
    );
    // ── 探总量工具 (Low risk, F-260616-08) ──
    registry.register(
        "get_task_count", "获取任务总数（未删除任务），用于分页策略判断。返回 { total: usize }",
        df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::TaskRepo::new(&db);
                let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
                    repo.query("project_id", pid).await?
                } else {
                    repo.list_all().await?
                };
                Ok(serde_json::json!({ "total": tasks.len() }))
            })
        })},
    );
}

/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_workflow_tools(registry: &mut AiToolRegistry) {
    // F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。
    // 实施路径文档 §三 列为阶段3 必做项(tool_registry.rs 此前无此工具连空壳都没有)。
    // 描述明确按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。
    //
    // handler 约束说明(B-260617-01 更新):run_workflow 真正执行需要 AppHandle(转发 workflow-event
    // 到前端) + AppState(registry 构建 DAG / event_bus 订阅 / workflows Repo 落库 /
    // workflow_state_registry 注销),这些在 tool handler(仅持 db: Arc<Database>)中无法构造。
    //
    // **执行路径(单一)**:run_workflow 是 High risk → 始终经 audit.rs:process_tool_calls 进 pending
    // → ai_approve 审批 → commands.rs ai_approve 内识别 run_workflow 分支调
    // execute_run_workflow_for_tool(mod.rs)→ workflow.rs::run_workflow_inner(持完整 State)真正执行。
    // 故本 handler 经 ai_tools.execute 调用的路径在正常流程下不可达(dead code 防御):
    // 仅当未来出现"不经 ai_approve 直接 execute run_workflow"的异常调用方时,本 Err 作为防御兜底
    // 返回明确错误,而非静默 panic。LLM 误判重试同 tool_call 时,High risk 去重缓存
    // (audit.rs:find_cached_high_risk_result)会复用旧 tool_result 跳过审批,断重试循环。
    registry.register(
        "run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
        df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
        RiskLevel::High,
        // CR-52: 删死代码 `let _db = db.clone();`(完全未用 — 防御 handler 仅返回 Err,
        // 不持 db;run_workflow 真正执行经 ai_approve → execute_run_workflow_for_tool
        // → workflow.rs::run_workflow_inner,此处 handler 在正常流程不可达)。
        { Box::new(move |args: serde_json::Value| {
            Box::pin(async move {
                let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
                let target_status = args["target_status"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
                // 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
                // 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
                // (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
                Err(anyhow::anyhow!(
                    "run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
                     持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
                     勿盲目重试同调用(task_id={}, target_status={});\
                     若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
                    task_id, target_status
                ))
            })
        })},
    );
}

/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    registry.register(
        "list_ideas", "列出所有灵感，支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::IdeaRepo::new(&db);
                let items = repo.list_all().await?;
                let total = items.len();
                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
                let has_more = (offset + page_items.len()) < total;
                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
            })
        })},
    );
    registry.register(
        "create_idea", "捕获一个新灵感",
        df_ai::ai_tools::object_schema(vec![
            ("title", "string", true), ("description", "string", false),
            ("tags", "string", false), ("source", "string", false),
            // priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
            ("priority", "integer", false),
        ]),
        RiskLevel::Medium,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
                let repo = df_storage::crud::IdeaRepo::new(&db);
                let record = IdeaRecord {
                    id: new_id(), title: title.to_string(),
                    description: args["description"].as_str().unwrap_or("").to_string(),
                    // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
                    status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
                    score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
                    source: args["source"].as_str().map(|s| s.to_string()),
                    promoted_to: None, ai_analysis: None, scores: None,
                    created_at: now_millis(), updated_at: now_millis(),
                };
                let id = record.id.clone();
                repo.insert(record).await?;
                Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
            })
        })},
    );
}

/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    registry.register(
        "list_trash", "列出回收站已删除项目，支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
        df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
        { let db = db.clone(); Box::new(move |args: serde_json::Value| {
            let db = db.clone();
            Box::pin(async move {
                let repo = df_storage::crud::ProjectRepo::new(&db);
                let items = repo.list_deleted().await?;
                let total = items.len();
                let offset = args["offset"].as_u64().unwrap_or(0) as usize;
                let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
                let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
                let has_more = (offset + page_items.len()) < total;
                Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
            })
        })},
    );
}

/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
fn register_file_tools(registry: &mut AiToolRegistry) {
    registry.register(
        "run_command", "在指定工作目录执行 shell 命令（跑测试/构建/查看运行结果），返回 stdout/stderr/exit_code。高风险，须人工批准。命令需自包含（非交互式，避免需用户输入的程序）。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
        df_ai::ai_tools::object_schema(vec![
            ("command", "string", true),
            ("working_dir", "string", false),
            ("timeout_secs", "integer", false),
        ]),
        RiskLevel::High,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let command = args["command"].as_str()
                .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
            // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
            // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
            // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
            // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
            let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
                Some(d) => {
                    validate_path(d)?;
                    d.to_string()
                }
                None => workspace_root().to_string_lossy().to_string(),
            };
            // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
            // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
            let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);

            let request = ShellRequest {
                command: command.to_string(),
                working_dir: Some(working_dir.clone()),
                env: HashMap::new(),
                timeout_secs: Some(timeout_secs),
                shell_type: Default::default(),
            };
            // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
            // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
            // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
            // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
            // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
            // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
            let result = execute(request).await.map_err(|e| {
                let msg = e.to_string();
                if msg.contains("命令执行超时") {
                    anyhow::anyhow!(
                        "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
                        timeout_secs
                    )
                } else {
                    e
                }
            })?;

            // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
            const MAX_OUT: usize = 10_000;
            let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
            let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);

            Ok(serde_json::json!({
                "command": command,
                "working_dir": working_dir,
                "exit_code": result.exit_code,
                "duration_ms": result.duration_ms,
                "stdout": stdout,
                "stderr": stderr,
                "truncated": stdout_trunc || stderr_trunc,
            }))
        })),
    );

    // ── 文件系统 ──
    registry.register(
        "read_file", "读取文件内容，返回文本内容。支持 offset/limit 分页；传入 search 则在文件内容中搜索匹配行（大小写敏感，字符串包含匹配），返回 matches 数组（限50条）",
        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
        RiskLevel::Low,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
            use tokio::fs::File;
            use tokio::io::AsyncReadExt;
            let mut file = File::open(path).await
                .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
            let metadata = file.metadata().await
                .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
            if metadata.len() > 1_048_576 {
                anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
            }
            // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
            let mut content = String::new();
            if let Err(e) = file.read_to_string(&mut content).await {
                if e.kind() == std::io::ErrorKind::InvalidData {
                    return Ok(serde_json::json!({
                        "path": path, "content": null, "binary": true,
                        "size": metadata.len(),
                        "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
                    }));
                }
                anyhow::bail!("读取文件失败: {}", e);
            }
            // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
            if let Some(search) = args["search"].as_str() {
                const SEARCH_MAX: usize = 50;
                let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
                let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
                // 先收集全部匹配行用于 total 计数，再做 skip/take 分页
                let mut all_matches: Vec<serde_json::Value> = Vec::new();
                for (idx, line) in content.lines().enumerate() {
                    if line.contains(search) {
                        all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
                    }
                }
                let total = all_matches.len();
                let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
                let has_more = (search_offset + page_matches.len()) < total;
                return Ok(serde_json::json!({
                    "path": path, "size": metadata.len(),
                    "search": search,
                    "matches": page_matches,
                    "total": total,
                    "has_more": has_more,
                }));
            }
            // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
            // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
            // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
            // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
            let line_count = content.lines().count();
            let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() {
                let lines: Vec<&str> = content.lines().collect();
                let skip = offset as usize;
                let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
                let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
                let more = (skip + page.len()) < line_count;
                (page.join("\n"), Some(skip), more)
            } else {
                // 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量)
                const DEFAULT_PREVIEW_LINES: usize = 500;
                let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect();
                let more = line_count > page.len();
                (page.join("\n"), None, more)
            };
            Ok(serde_json::json!({
                "path": path, "content": result, "size": metadata.len(), "lines": line_count,
                "offset": offset_used,
                "returned_lines": result.lines().count(),
                "has_more": has_more,
            }))
        })),
    );
    registry.register(
        "list_directory", "列出目录内容，返回文件和子目录列表（名称、类型、大小）",
        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
        RiskLevel::Low,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let recursive = args["recursive"].as_bool().unwrap_or(false);
            let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
            // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
            // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
            let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
            let mut entries = Vec::new();
            let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
            Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
        })),
    );
    registry.register(
        "write_file", "写入或创建文件，自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容（图片/PDF/Excel 等），content 传 base64 编码字符串；默认 encoding=\"utf-8\" 传纯文本",
        df_ai::ai_tools::object_schema(vec![
            ("path", "string", true),
            ("content", "string", true),
            ("encoding", "string", false),
        ]),
        RiskLevel::Medium,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
            // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
            // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
            let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
            let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
                use base64::{engine::general_purpose::STANDARD, Engine as _};
                STANDARD.decode(raw)
                    .map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
            } else if encoding.eq_ignore_ascii_case("utf-8") {
                raw.as_bytes().to_vec()
            } else {
                anyhow::bail!("不支持的 encoding: {}（仅 utf-8 / base64）", encoding);
            };
            // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
            if bytes.len() > 1_048_576 {
                anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
            }
            let target = std::path::Path::new(path);
            // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
            // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
            let old_size: Option<u64> = match tokio::fs::metadata(target).await {
                Ok(m) if m.len() > 0 => {
                    let bak = format!("{}.bak", path);
                    tokio::fs::copy(path, &bak).await
                        .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
                    Some(m.len())
                }
                Ok(_) => Some(0), // 空文件(无需备份)
                Err(_) => None,   // 不存在(新建)
            };
            if let Some(parent) = target.parent() {
                // FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
                if !parent.starts_with(&workspace_root()) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
            }
            // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
            let tmp = format!("{}.tmp-write", path);
            if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
            }
            if let Err(e) = tokio::fs::rename(&tmp, path).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                return Err(anyhow::anyhow!("原子替换失败: {}", e));
            }
            // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
            // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
            // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
            if old_size.map(|s| s > 0).unwrap_or(false) {
                let bak = format!("{}.bak", path);
                let _ = tokio::fs::remove_file(&bak).await;
            }
            // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
            if let Some(old) = old_size {
                if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
                    tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
                }
            }
            Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
        })),
    );

    // ── 局部文件编辑 (Medium risk) ──
    // F-260617-01: 三模式互斥（old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点）。
    // object_schema 只支持扁平标量三元组，故 replace_lines/anchor 嵌套对象手工拼 schema。
    // 三模式均需 path + new_text；old_text/replace_lines/anchor 三选一（互斥）；expected_hash 可选通用。
    let patch_file_schema = {
        let mut props = serde_json::Map::new();
        props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径（必填）" }));
        props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容（三模式通用，必填）" }));
        props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配：要替换的原文（必须精确匹配含空格/缩进），三选一互斥" }));
        props.insert("replace_lines".into(), serde_json::json!({
            "type": "object",
            "description": "模式2 行号区间：{ start: 1-based 起始行(含), end: 1-based 结束行(含) }，三选一互斥。配 expected_hash 防并发行号漂移",
            "properties": {
                "start": { "type": "integer", "description": "起始行号（1-based，含）" },
                "end": { "type": "integer", "description": "结束行号（1-based，含）" }
            },
            "required": ["start", "end"]
        }));
        props.insert("anchor".into(), serde_json::json!({
            "type": "object",
            "description": "模式3 锚点：{ start: 首行子串标记, end: 尾行子串标记 }，内部定位首尾行号转区间替换，三选一互斥。不需完整原文",
            "properties": {
                "start": { "type": "string", "description": "首行子串标记（大小写敏感）" },
                "end": { "type": "string", "description": "尾行子串标记（大小写敏感，在 start 行之后）" }
            },
            "required": ["start", "end"]
        }));
        props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "（old_text 模式）行号辅助定位，可选" }));
        props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹（read_file 返回的 file_hash），三模式通用，防并发修改" }));
        serde_json::json!({
            "type": "object",
            "properties": props,
            "required": ["path", "new_text"],
        })
    };
    registry.register(
        "patch_file", "局部更新文件内容（三模式互斥）。模式1 old_text：精确匹配原文替换（含空格/缩进，CAS 语义）。模式2 replace_lines：按行号区间 {start,end}（1-based 含首尾）替换，不需原文，配 expected_hash 防行号漂移。模式3 anchor：按首尾子串锚点 {start,end}（大小写敏感，子串匹配）定位行号区间替换，不需完整原文。三模式均需 path+new_text，expected_hash 可选通用。属 Medium 风险操作（修改已有文件），需人工审批。注意：若文件已被外部修改，请先重新 read_file 获取最新内容",
        patch_file_schema,
        RiskLevel::Medium,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let new_text = args["new_text"].as_str()
                .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;

            // F-260617-01: 三模式互斥校验（old_text / replace_lines / anchor）
            // 三选一：统计传入的模式参数数，>1 报错，0 报错（缺定位方式）
            let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
            let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
            let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
            let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
            if mode_count == 0 {
                anyhow::bail!("缺少定位方式：必须提供 old_text / replace_lines / anchor 之一");
            }
            if mode_count > 1 {
                anyhow::bail!(
                    "模式互斥冲突：old_text / replace_lines / anchor 仅可传一个（检测到 {} 个）",
                    mode_count
                );
            }

            // 文件存在性 / 大小限制（三模式通用，先于内容读取）
            let target = std::path::Path::new(path);
            if !target.exists() {
                anyhow::bail!("文件不存在: {}", path);
            }
            let file_meta = tokio::fs::metadata(target).await
                .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
            if file_meta.len() > 1_048_576 {
                anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
            }

            // 阶段一：读文件内容 + 校验（无锁，纯读操作）
            use tokio::io::AsyncReadExt;
            let mut file = tokio::fs::File::open(path).await
                .map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
            let mut content = String::new();
            file.read_to_string(&mut content).await
                .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;

            // 二进制检测
            if content.contains('\0') {
                anyhow::bail!("不支持二进制文件");
            }

            // L3: expected_hash 指纹校验（防外部修改）
            if let Some(expected) = args["expected_hash"].as_str() {
                let modified = file_meta.modified()
                    .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_secs());
                let current_hash = format!("{}_{}", modified.unwrap_or(0), file_meta.len());
                if current_hash != expected {
                    anyhow::bail!(
                        "文件已被外部修改(hash 不匹配): 期望={} 实际={}，请重新 read_file 获取最新内容",
                        expected, current_hash
                    );
                }
            }

            // F-260617-01: 三模式分派计算 new_content + match_count + warning
            // old_text 模式：精确匹配（CAS）+ 多匹配警告（仅替换第 1 处）
            // replace_lines 模式：行号区间 splice（越界 Err）
            // anchor 模式：子串锚点定位 → 行号区间 splice（找不到/start 在 end 后 Err）
            let new_content: String;
            let match_count: usize;
            let warning: Option<String>;

            if has_old_text {
                let old_text = args["old_text"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
                if old_text.is_empty() {
                    anyhow::bail!("old_text 不能为空");
                }
                if old_text == new_text {
                    return Ok(serde_json::json!({
                        "path": path, "changed": false, "warning": "new_text 与 old_text 相同，无实际更改"
                    }));
                }
                // L2: old_text 精确匹配（CAS 语义）
                if !content.contains(old_text) {
                    anyhow::bail!("未找到目标文本，文件可能已被修改");
                }
                let mc = content.matches(old_text).count();
                match_count = mc;
                warning = if mc > 1 {
                    Some(format!("匹配到 {} 处，仅替换第 1 处", mc))
                } else { None };
                new_content = content.replacen(old_text, new_text, 1);
            } else if has_replace_lines {
                let rl = args.get("replace_lines")
                    .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
                let start = rl["start"].as_u64()
                    .ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
                let end = rl["end"].as_u64()
                    .ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
                new_content = apply_line_range(&content, start, end, new_text)?;
                match_count = 1;
                warning = None;
            } else {
                // has_anchor
                let an = args.get("anchor")
                    .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
                let a_start = an["start"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
                let a_end = an["end"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
                let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
                new_content = apply_line_range(&content, start_line, end_line, new_text)?;
                match_count = 1;
                warning = None;
            }

            // 阶段二：L1 tokio::Mutex 保护写序列（backup → tmp write → rename → cleanup）
            // tokio::sync::Mutex 的 MutexGuard 是 Send，可安全跨 await
            let abs_path = target.canonicalize()
                .map_err(|e| anyhow::anyhow!("路径解析失败: {}", e))?;
            {
                let mut locks = FILE_LOCKS.lock().await;
                locks.entry(abs_path.clone()).or_insert(());

                // .bak 备份
                let bak = format!("{}.bak", path);
                tokio::fs::copy(path, &bak).await
                    .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;

                // 原子写: tmp → rename
                let tmp = format!("{}.tmp-write", path);
                if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
                    let _ = tokio::fs::remove_file(&tmp).await;
                    let _ = tokio::fs::remove_file(&bak).await;
                    return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
                }
                if let Err(e) = tokio::fs::rename(&tmp, path).await {
                    let _ = tokio::fs::remove_file(&tmp).await;
                    return Err(anyhow::anyhow!("原子替换失败: {}，备份保留在 {}", e, bak));
                }
                // 成功：清理 .bak
                let _ = tokio::fs::remove_file(&bak).await;
                // _locks 在此 drop，释放锁
            }

            let size_diff = new_content.len() as i64 - content.len() as i64;
            // 生成 unified diff 供前端审批卡/审计留痕展示
            // content（旧）与 new_content（新）作用域内可用，无需重读文件
            let diff = generate_diff(&content, &new_content);
            let mut result = serde_json::json!({
                "path": path,
                "changed": true,
                "size_diff": size_diff,
                "matches_found": match_count,
                "diff": diff,
            });
            if let Some(w) = warning {
                result["warning"] = serde_json::Value::String(w);
            }
            Ok(result)
        })),
    );

    // ── 文件元信息 (Low risk) ──
    registry.register(
        "file_info", "获取文件或目录的元信息（是否存在、大小、行数、修改时间、是否二进制、是否目录），不读取文件内容",
        df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
        RiskLevel::Low,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let p = std::path::Path::new(path);
            if !p.exists() {
                return Ok(serde_json::json!({ "path": path, "exists": false }));
            }
            let metadata = tokio::fs::metadata(path).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
            let is_dir = metadata.is_dir();
            let size = metadata.len();
            let modified = metadata.modified()
                .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_millis() as i64);
            // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02)
            // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
            // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
            // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
            // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
            let is_binary = if !is_dir && size > 0 {
                use tokio::io::AsyncReadExt;
                let file = match tokio::fs::File::open(path).await {
                    Ok(f) => f,
                    Err(_) => return Ok(serde_json::json!({
                        "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
                        "modified": modified, "is_binary": false, "is_dir": is_dir,
                        "error": "读取文件头失败"
                    })),
                };
                let mut reader = tokio::io::BufReader::new(file);
                let mut sample = vec![0u8; 8192];
                let n = reader.read(&mut sample).await.unwrap_or(0);
                sample[..n].contains(&0x00)
            } else { false };
            // lines: 文本文件 \n 计数（>2MB 跳过避免全量读）
            let lines = if !is_dir && !is_binary && size <= 2_097_152 {
                tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
            } else { None };
            Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
        })),
    );

    // ── 追加写入 (Medium risk) ──
    registry.register(
        "append_file", "向文件末尾追加内容，文件不存在则自动创建。返回写入字数和新文件大小",
        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
        RiskLevel::Medium,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
            if let Some(parent) = std::path::Path::new(path).parent() {
                if !parent.starts_with(&workspace_root()) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
            }
            use tokio::io::AsyncWriteExt;
            let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
                .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
            let bytes = content.as_bytes();
            file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
            file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
            let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
            Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
        })),
    );

    // ── 删除文件 (High risk, F-260615-16) ──
    registry.register(
        "delete_file", "删除文件。默认（permanent=false）软删除：移到工作区 .trash/ 目录保留可恢复；permanent=true 硬删除不可恢复。删除前备份，回收站机制对齐 list_trash。高风险，须人工批准",
        df_ai::ai_tools::object_schema(vec![
            ("path", "string", true),
            ("permanent", "boolean", false),
        ]),
        RiskLevel::High,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            // validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
            let target = std::path::Path::new(path);
            if !target.exists() {
                anyhow::bail!("文件不存在: {}", path);
            }
            // 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
            let metadata = tokio::fs::metadata(target).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
            if metadata.is_dir() {
                anyhow::bail!("delete_file 仅支持删除文件，不支持目录: {}", path);
            }
            let file_name = target.file_name()
                .ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
                .to_string_lossy().to_string();

            if permanent {
                // 硬删除:不可逆,对齐 delete_project purge 语义
                tokio::fs::remove_file(path).await
                    .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
                Ok(serde_json::json!({
                    "path": path,
                    "deleted": true,
                    "permanent": true,
                    "backed_up": false,
                }))
            } else {
                // 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
                // .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
                let trash_dir = workspace_root().join(".trash");
                tokio::fs::create_dir_all(&trash_dir).await
                    .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
                let backup_name = format!("{}-{}", new_id(), file_name);
                let backup_path = trash_dir.join(&backup_name);
                let backup_path_str = backup_path.to_string_lossy().to_string();
                tokio::fs::rename(path, &backup_path).await
                    .map_err(|e| anyhow::anyhow!("移入回收站失败: {}", e))?;
                Ok(serde_json::json!({
                    "path": path,
                    "deleted": true,
                    "permanent": false,
                    "backed_up": true,
                    "backup_path": backup_path_str,
                }))
            }
        })),
    );

    // ── 重命名/移动文件 (Medium risk, F-260615-15) ──
    // 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
    // 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
    registry.register(
        "rename_file", "重命名或移动文件（同一工具覆盖 rename 和 move）。参数：from（源路径）+ to（目标路径）+ overwrite（默认 false，目标存在则拒绝）。同卷走 tokio::fs::rename（原子），跨卷自动降级 copy+remove（非原子，失败回滚删 to 保 from 完整）。须人工批准",
        df_ai::ai_tools::object_schema(vec![
            ("from", "string", true),
            ("to", "string", true),
            ("overwrite", "boolean", false),
        ]),
        RiskLevel::Medium,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path,
            // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
            let from_resolved = resolve_workspace_path(
                args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
            )?;
            let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
            let to_resolved = resolve_workspace_path(
                args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
            )?;
            let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
            let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);

            // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
            let from_target = std::path::Path::new(from_path);
            if !from_target.exists() {
                anyhow::bail!("源文件不存在: {}", from_path);
            }
            let from_metadata = tokio::fs::metadata(from_target).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
            if from_metadata.is_dir() {
                anyhow::bail!("rename_file 仅支持文件，不支持目录: {}", from_path);
            }
            let bytes_moved = from_metadata.len();

            // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
            let to_target = std::path::Path::new(to_path);
            if let Some(parent) = to_target.parent() {
                if !parent.starts_with(&workspace_root()) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
            }

            // 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
            if to_target.exists() {
                if !overwrite {
                    anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
                }
                tokio::fs::remove_file(to_path).await
                    .map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
            }

            // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
            let rename_err = tokio::fs::rename(from_path, to_path).await.err();
            if rename_err.is_none() {
                return Ok(serde_json::json!({
                    "from": from_path,
                    "to": to_path,
                    "renamed": true,
                    "bytes_moved": bytes_moved,
                    "cross_volume": false,
                }));
            }
            // rename 失败:跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove
            // 其他错误(权限/占用)直接抛,不降级
            let err = rename_err.unwrap();
            let cross_volume = err.raw_os_error() == Some(17);
            if !cross_volume {
                anyhow::bail!("重命名/移动失败: {}", err);
            }
            // 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
            // 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
            if let Err(e) = tokio::fs::copy(from_path, to_path).await {
                anyhow::bail!("跨卷复制失败（from 未改动）: {}", e);
            }
            if let Err(e) = tokio::fs::remove_file(from_path).await {
                // remove 失败:回滚删 to,保 from 完整(用户可重试)
                let _ = tokio::fs::remove_file(to_path).await;
                anyhow::bail!("跨卷移动删除源失败已回滚（from 完整,可重试）: {}", e);
            }
            Ok(serde_json::json!({
                "from": from_path,
                "to": to_path,
                "renamed": true,
                "bytes_moved": bytes_moved,
                "cross_volume": true,
            }))
        })),
    );

    // ── 文件搜索 (Low risk) ──
    registry.register(
        "search_files", "在指定目录下搜索匹配模式（字符串包含匹配）的文件名，支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
        df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
        RiskLevel::Low,
        Box::new(|args: serde_json::Value| Box::pin(async move {
            let resolved = resolve_workspace_path(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
            let recursive = args["recursive"].as_bool().unwrap_or(false);
            let pattern_lower = pattern.to_lowercase();
            const MAX_RESULTS: usize = 50;
            let offset = args["offset"].as_u64().unwrap_or(0) as usize;
            let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
            // 先收集全部结果用于 total 计数，再做 skip/take 分页
            let mut all_results = Vec::new();
            let mut total = 0u64;
            // 用较大上限收集全量（分页由内存 skip/take 控制）
            search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
            let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
            let has_more = (offset + page_results.len()) < total as usize;
            Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
        })),
    );
}

/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
fn list_dir_recursive<'a>(
    path: &'a str,
    recursive: bool,
    depth: usize,
    max_depth: usize,
    max_entries: usize,
    skip_noise: bool,
    result: &'a mut Vec<serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
    Box::pin(async move {
        let mut dir = tokio::fs::read_dir(path).await
            .map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
        while let Some(entry) = dir.next_entry().await? {
            if result.len() >= max_entries {
                return Ok(true); // 达上限截断
            }
            let name = entry.file_name().to_string_lossy().to_string();
            // 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
            if skip_noise && is_noise_dir(&name) {
                continue;
            }
            // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
            // 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
            if skip_noise && is_noise_file(&name) {
                continue;
            }
            // FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
            // 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
            let file_type = entry.file_type().await?;
            let is_symlink = file_type.is_symlink();
            let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
            result.push(serde_json::json!({
                "name": name,
                "type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
                "size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
                "depth": depth,
            }));
            // 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
            if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
                let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
                let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
                if truncated {
                    return Ok(true);
                }
            }
        }
        Ok(false)
    })
}

/// 工具名 → 审批文案模板（供 build_approval_reason 使用）。
///
/// 返回 Option<(&str, &[&str])>：(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None，由调用方 fallback 到通用风险提示。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
    let (template, keys): (&str, &[&str]) = match name {
        "delete_project" => ("删除项目{}", &["id"]),
        "restore_project" => ("从回收站恢复项目{}", &["id"]),
        "purge_project" => ("永久删除项目及关联数据，不可恢复{}", &["id"]),
        "update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
        "bind_directory" => ("绑定目录：{}（项目{}）", &["path", "id"]),
        "create_task" => ("创建任务：{}（项目{}）", &["title", "project_id"]),
        "create_project" => ("创建项目：「{}」", &["name"]),
        "create_idea" => ("捕获灵感：{}", &["title"]),
        "patch_file" => ("修改文件：{}", &["path"]),
        "delete_file" => ("删除文件：{}（{}）", &["path", "permanent"]),
        "rename_file" => ("重命名/移动：{} → {}", &["from", "to"]),
        // F-03 收口:推进链工具审批文案(advance_task/run_workflow)
        "advance_task" => ("推进任务状态：{} → {}", &["id", "target_status"]),
        "run_workflow" => ("触发工作流：任务{} 推进到 {}", &["task_id", "target_status"]),
        // http_request 审批文案:method + url(看是什么请求打到哪)
        "http_request" => ("HTTP 请求：{} {}", &["method", "url"]),
        _ => return None,
    };
    Some((template, keys))
}

/// 工具名 → 中文动作前缀（轻量映射，供 build_approval_reason 快速取可读标签）。
///
/// 仅返回动作动词+对象名（如 "创建任务"、"写入文件"），不含参数细节。
/// 未注册的工具返回 None，由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
    match name {
        "create_task" => Some("创建任务"),
        "write_file" => Some("写入文件"),
        "read_file" => Some("读取文件"),
        "list_directory" => Some("列出目录"),
        "search_files" => Some("搜索文件"),
        "create_project" => Some("创建项目"),
        "update_project" => Some("修改项目"),
        "delete_project" => Some("删除项目"),
        "restore_project" => Some("恢复项目"),
        "purge_project" => Some("永久删除项目"),
        "bind_directory" => Some("绑定目录"),
        "create_idea" => Some("捕获灵感"),
        "update_task" => Some("修改任务"),
        // advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
        // 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息(UX-260618-14)
        "run_workflow" => Some("触发工作流"),
        "delete_task" => Some("删除任务"),
        "update_idea" => Some("修改灵感"),
        "delete_idea" => Some("删除灵感"),
        "patch_file" => Some("修改文件"),
        "append_file" => Some("追加写入"),
        "delete_file" => Some("删除文件"),
        "rename_file" => Some("重命名/移动"),
        "http_request" => Some("发起 HTTP 请求"),
        _ => None,
    }
}

/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
fn is_noise_dir(name: &str) -> bool {
    const NOISE_DIRS: &[&str] = &[
        ".git", "node_modules", "target", "dist", "build",
        ".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
        ".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
    ];
    NOISE_DIRS.contains(&name)
}

/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
///
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
fn is_noise_file(name: &str) -> bool {
    const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
    NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}

/// 递归搜索文件（字符串包含匹配，大小写不敏感）
fn search_files_recursive<'a>(
    path: &'a str,
    pattern: &'a str,
    recursive: bool,
    depth: usize,
    max_depth: usize,
    max_results: usize,
    results: &'a mut Vec<serde_json::Value>,
    total: &'a mut u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
    Box::pin(async move {
        let mut dir = tokio::fs::read_dir(path).await
            .map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
        while let Some(entry) = dir.next_entry().await? {
            let name = entry.file_name().to_string_lossy().to_string();
            let metadata = entry.metadata().await?;
            let is_dir = metadata.is_dir();
            if !is_dir {
                // 字符串包含匹配（大小写不敏感）
                if name.to_lowercase().contains(pattern) {
                    *total += 1;
                    if results.len() < max_results {
                        let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
                        results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
                    }
                }
            } else if recursive && depth < max_depth {
                let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
                search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
            }
        }
        Ok(())
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    // ============================================================
    // 工具注册基线测试（SMELL-P0-2 拆分防护）
    //
    // 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
    // build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。
    // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
    // ============================================================

    /// build_ai_tool_registry 应注册恰好 29 个工具(18 data + 10 file + 1 http),且工具名集合稳定。
    ///
    /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
    //  不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
    #[tokio::test]
    async fn test_build_ai_tool_registry_baseline_tool_count() {
        let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
        let db = Arc::new(db);
        let registry = build_ai_tool_registry(&db);

        // 总量基线:29(18 data + 10 file + 1 http)。拆分前后必须一致。
        assert_eq!(
            registry.len(),
            29,
            "工具总数应为 29(18 data + 10 file + 1 http),实际 {}", registry.len()
        );

        // 工具名集合基线:防 rename / 漏注册 / 误删除。
        // data 层 18 个(持 db):CRUD/状态机/工作流
        // file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜
        // http 层 1 个(不持 db):http_request
        let mut expected: Vec<&str> = vec![
            // ── data 层 (18) ──
            "list_projects", "list_tasks", "list_ideas",
            "update_project", "create_project", "bind_directory",
            "create_task", "update_task", "advance_task",
            "run_workflow", "delete_task", "create_idea",
            "delete_project", "restore_project", "purge_project",
            "list_trash", "get_project_count", "get_task_count",
            // ── file 层 (10) ──
            "run_command", "read_file", "list_directory",
            "write_file", "patch_file", "file_info",
            "append_file", "delete_file", "rename_file", "search_files",
            // ── http 层 (1) ──
            "http_request",
        ];
        expected.sort_unstable();

        let mut actual: Vec<String> = registry.tool_names();
        actual.sort();

        assert_eq!(
            actual,
            expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
            "工具名集合与基线不一致(可能漏注册或 rename)"
        );
    }

    /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
    #[test]
    fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
        // 噪音目录命中
        assert!(is_noise_dir(".git"));
        assert!(is_noise_dir("node_modules"));
        assert!(is_noise_dir("target"));
        assert!(is_noise_dir("dist"));
        assert!(is_noise_dir("build"));
        // .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
        assert!(!is_noise_dir(".gitignore"));
        assert!(!is_noise_dir(".gitattributes"));
        assert!(!is_noise_dir(".gitkeep"));
    }

    /// is_noise_dir 纯函数:普通目录不命中
    #[test]
    fn test_is_noise_dir_normal_dirs_not_matched() {
        assert!(!is_noise_dir("src"));
        assert!(!is_noise_dir("tests"));
        assert!(!is_noise_dir("docs"));
        assert!(!is_noise_dir("main.rs"));
        assert!(!is_noise_dir(""));
    }

    /// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
    #[test]
    fn test_is_noise_dir_case_sensitive() {
        // 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
        // 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
        assert!(!is_noise_dir("Target"));
        assert!(!is_noise_dir("DIST"));
        assert!(!is_noise_dir("NODE_MODULES"));
    }

    /// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
    #[test]
    fn test_is_noise_file_matches_temp_artifacts() {
        // write_file 原子写副产物
        assert!(is_noise_file("PROGRESS.md.bak"));
        assert!(is_noise_file("PROGRESS.md.tmp-write"));
        // 编辑器临时文件
        assert!(is_noise_file(".main.rs.swp"));
        assert!(is_noise_file("main.rs~"));
        // 路径含但非后缀的应不命中(避免误杀)
        assert!(!is_noise_file("backup.bak.md"));
        assert!(!is_noise_file("main.rs"));
        assert!(!is_noise_file(".gitignore"));
        assert!(!is_noise_file(""));
    }

    /// 造一个临时目录树用于 list_dir_recursive 测试
    fn build_noise_tree(root: &Path) {
        // .git/HEAD (噪音目录,内部文件不应出现)
        fs::create_dir_all(root.join(".git")).unwrap();
        fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
        // node_modules/x/index.js (噪音目录,内部文件不应出现)
        fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
        fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
        // target/debug/bin (噪音目录,内部文件不应出现)
        fs::create_dir_all(root.join("target").join("debug")).unwrap();
        fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
        // 普通 src/main.rs (应出现)
        fs::create_dir_all(root.join("src")).unwrap();
        fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
        // .gitignore 文件 (文件名不是噪音目录,应出现)
        fs::write(root.join(".gitignore"), "/target\n").unwrap();
    }

    /// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
    #[tokio::test]
    async fn test_list_dir_recursive_filters_noise_dirs() {
        let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        build_noise_tree(&tmp);
        let root = tmp.to_string_lossy().to_string();

        let mut entries = Vec::new();
        let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
        assert!(!truncated);

        let names: Vec<String> = entries.iter()
            .filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
            .collect();

        // 噪音目录本身及其内部文件均不应出现
        assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
        assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
        assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
        // 噪音目录内部文件也不应被递归带入
        assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
        assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
        assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");

        // 普通目录与 .gitignore 文件应正常返回
        assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
        assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
        assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");

        fs::remove_dir_all(&tmp).ok();
    }

    /// list_dir_recursive:skip_noise=false 时噪音目录正常返回
    #[tokio::test]
    async fn test_list_dir_recursive_keeps_noise_when_disabled() {
        let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        build_noise_tree(&tmp);
        let root = tmp.to_string_lossy().to_string();

        let mut entries = Vec::new();
        list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();

        let names: Vec<String> = entries.iter()
            .filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
            .collect();

        // 关闭过滤后噪音目录应作为 entry 出现
        assert!(names.contains(&".git".to_string()));
        assert!(names.contains(&"node_modules".to_string()));
        assert!(names.contains(&"target".to_string()));

        fs::remove_dir_all(&tmp).ok();
    }

    /// list_dir_recursive:max_depth 控制递归深度
    #[tokio::test]
    async fn test_list_dir_recursive_max_depth() {
        // 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
        let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
        fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
        let root = tmp.to_string_lossy().to_string();

        // max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
        let mut entries = Vec::new();
        list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
        let names: Vec<String> = entries.iter()
            .filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
            .collect();
        assert!(names.contains(&"a".to_string()), "depth1 应含 a");
        assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
        assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
        assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");

        // max_depth=3: 应能到 depth3 的 main.rs
        let mut entries = Vec::new();
        list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
        let names: Vec<String> = entries.iter()
            .filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
            .collect();
        assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");

        fs::remove_dir_all(&tmp).ok();
    }

    // ============================================================
    // patch_file 三模式共用底层测试（F-260617-01）
    // 纯函数 apply_line_range / resolve_anchor_to_lines，不依赖 fs/async/锁。
    // handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成，
    // 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心；old_text 模式逻辑
    // 沿用既有实现（contains + replacen），未改零回归，不在此重复。
    // ============================================================

    /// apply_line_range: 区间替换含首尾行（1-based）
    #[test]
    fn test_apply_line_range_inclusive() {
        // 3 行文件（末尾无尾换行）
        let content = "line1\nline2\nline3";
        let out = apply_line_range(content, 2, 3, "REPLACED").unwrap();
        // 第 2、3 行被替换为 REPLACED；REPLACED 不以 \n 结尾且其后无保留行 → 不补分隔 \n
        assert_eq!(out, "line1\nREPLACED");
    }

    /// apply_line_range: 区间仅替换中间行，保留前后行
    #[test]
    fn test_apply_line_range_middle() {
        let content = "a\nb\nc\nd\ne";
        let out = apply_line_range(content, 2, 4, "X\nY").unwrap();
        // 替换 2-4 行(b,c,d)为 X\nY；Y 后还有 e，需补 \n 分隔
        assert_eq!(out, "a\nX\nY\ne");
    }

    /// apply_line_range: 区间替换到文件末尾，保留尾换行语义
    #[test]
    fn test_apply_line_range_tail_newline_preserved() {
        // 文件以 \n 结尾
        let content = "a\nb\nc\n";
        let out = apply_line_range(content, 2, 3, "NEW").unwrap();
        // 替换 b,c 两行为 NEW；原文件尾随 \n，NEW 不带 \n，其后无保留行
        // splice 循环对 NEW（作为 start 行）push 后因不以 \n 结尾且无后续保留行 → 不补；
        // 但原文件 trailing_newline=true，末尾应保留 \n
        assert_eq!(out, "a\nNEW\n");
    }

    /// apply_line_range: 单行区间（start==end）
    #[test]
    fn test_apply_line_range_single_line() {
        let content = "a\nb\nc";
        let out = apply_line_range(content, 2, 2, "X").unwrap();
        assert_eq!(out, "a\nX\nc");
    }

    /// apply_line_range: 多行 new_text 原样插入
    #[test]
    fn test_apply_line_range_multiline_newtext() {
        let content = "h\nOLD\nt";
        let out = apply_line_range(content, 2, 2, "n1\nn2\nn3").unwrap();
        assert_eq!(out, "h\nn1\nn2\nn3\nt");
    }

    /// apply_line_range: start<1 越界 Err
    #[test]
    fn test_apply_line_range_start_below_one() {
        let content = "a\nb";
        let err = apply_line_range(content, 0, 1, "X").unwrap_err();
        assert!(format!("{}", err).contains("start 行号必须 >= 1"), "got: {}", err);
    }

    /// apply_line_range: end>lines.len() 越界 Err
    #[test]
    fn test_apply_line_range_end_overflow() {
        let content = "a\nb"; // 2 行
        let err = apply_line_range(content, 1, 5, "X").unwrap_err();
        assert!(format!("{}", err).contains("end 行号"), "got: {}", err);
    }

    /// apply_line_range: start>end Err
    #[test]
    fn test_apply_line_range_start_gt_end() {
        let content = "a\nb\nc";
        let err = apply_line_range(content, 3, 2, "X").unwrap_err();
        assert!(format!("{}", err).contains("start"), "got: {}", err);
    }

    /// resolve_anchor_to_lines: start/end 子串命中（基本路径）
    #[test]
    fn test_resolve_anchor_basic() {
        // 注意 end 锚取子串；此处用 END-FN 唯一标记第 4 行(避免 println! 行内 {} 干扰)
        let content = "fn foo() {\n    let x = 1;\n    println!(\"{}\");\n} // END-FN\n";
        // start 锚 "fn foo" 在第 1 行，end 锚 "END-FN" 在第 4 行
        let (s, e) = resolve_anchor_to_lines(content, "fn foo", "END-FN").unwrap();
        assert_eq!((s, e), (1, 4));
    }

    /// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
    /// （含行内出现，如 println!("{}") 内的 }）——锁定此行为语义，避免后续误改成「整行相等」
    #[test]
    fn test_resolve_anchor_substring_matches_inside_line() {
        // end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 }，而非第 4 行的 }
        let content = "fn foo() {\n    let x = 1;\n    println!(\"{}\");\n}\n";
        let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
        assert_eq!((s, e), (1, 3));
    }

    /// resolve_anchor_to_lines: 同行命中（start==end，单行区间）
    #[test]
    fn test_resolve_anchor_same_line() {
        let content = "header marker end here\nother";
        // start/end 均在第 1 行命中
        let (s, e) = resolve_anchor_to_lines(content, "header", "end").unwrap();
        assert_eq!((s, e), (1, 1));
    }

    /// resolve_anchor_to_lines: end 在 start 之后第一个命中行（跳过中间同子串）
    #[test]
    fn test_resolve_anchor_picks_first_end_after_start() {
        // 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
        let content = "START\n}\nx\n}";
        let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
        assert_eq!((s, e), (1, 2));
    }

    /// resolve_anchor_to_lines: 大小写敏感（与 search_files 一致）
    #[test]
    fn test_resolve_anchor_case_sensitive() {
        let content = "Hello\nWorld";
        // 小写 "hello" 不命中第 1 行的大写 "Hello" → Err
        let err = resolve_anchor_to_lines(content, "hello", "World").unwrap_err();
        assert!(format!("{}", err).contains("anchor.start 子串"), "got: {}", err);
    }

    /// resolve_anchor_to_lines: start 子串找不到 Err
    #[test]
    fn test_resolve_anchor_start_not_found() {
        let content = "a\nb\nc";
        let err = resolve_anchor_to_lines(content, "NOPE", "c").unwrap_err();
        assert!(format!("{}", err).contains("anchor.start 子串「NOPE」"), "got: {}", err);
    }

    /// resolve_anchor_to_lines: end 子串在 start 之后找不到 Err
    #[test]
    fn test_resolve_anchor_end_not_found_after_start() {
        // start 命中第 1 行，但 end 子串 "z" 在第 1 行之后不存在
        let content = "START\nb\nc";
        let err = resolve_anchor_to_lines(content, "START", "z").unwrap_err();
        assert!(format!("{}", err).contains("anchor.end 子串「z」"), "got: {}", err);
    }

    /// resolve_anchor_to_lines: start/end 空串 Err
    #[test]
    fn test_resolve_anchor_empty_marker() {
        let content = "a\nb";
        assert!(resolve_anchor_to_lines(content, "", "b").is_err());
        assert!(resolve_anchor_to_lines(content, "a", "").is_err());
    }

    /// 三模式组合案例：anchor 解析为行号 → apply_line_range 执行替换（端到端纯函数链）
    #[test]
    fn test_anchor_then_apply_line_range_e2e() {
        let content = "use std::fs;\nuse std::io;\n\nfn main() {}\n";
        // 锚点定位 import 区块（第 1-2 行），替换为新 import
        let (s, e) = resolve_anchor_to_lines(content, "use std::fs", "use std::io").unwrap();
        assert_eq!((s, e), (1, 2));
        let out = apply_line_range(content, s, e, "use std::path;").unwrap();
        assert_eq!(out, "use std::path;\n\nfn main() {}\n");
    }
}
