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

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

use tokio::sync::{Mutex as TokioMutex, RwLock};

use df_ai::ai_tools::AiToolRegistry;
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;

// ProjectServiceRecord/IdeaRecord/new_id/IdeaStatus 原 register_task_graph_tools/register_idea_tools 用
// (知识图谱/灵感工具已迁 tools/task_graph.rs、tools/idea.rs,本文件 lib 不再引用),
// 测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。
// RiskLevel 原 register_* 内联手写 register 用(已全迁声明式 tools/*,lib 不再引用)。
// ProjectRecord/ProjectStatus/now_millis 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs,
// lib 不再引用,迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。
#[cfg(test)]
use df_storage::models::ProjectRecord;
#[cfg(test)]
use df_types::types::ProjectStatus;
#[cfg(test)]
use crate::commands::now_millis;

use crate::state::AllowedDirs;

/// CRUD list 工具的默认返回上限（防 LLM context 膨胀）
/// 用于 list_projects / list_tasks / list_ideas / list_trash
///
/// tool_registry 拆分第一步:list_projects 已迁声明式试点(tools/list_projects.rs),
/// 复用本常量保单真相源(避免字面量 50 漂移),故 pub(crate) 暴露给 tools 子模块。
pub(crate) const MAX_LIST_RESULTS: usize = 50;

/// run_command 默认超时（秒）。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源（F-260616-04）。
///
/// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源
/// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。
pub(crate) const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
pub(crate) 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)。
pub(crate) 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!("禁止路径遍历 (..)");
    }

    // 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心):
    // 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup,
    // 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)
    if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
        anyhow::bail!("禁止访问敏感系统目录");
    }
    // F-260620: AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
    // AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
    // → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
    // 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
    // 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
    Ok(())
}

/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
pub(crate) 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 行），不需要全局文件队列。
pub(crate) static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
    LazyLock::new(|| TokioMutex::new(HashMap::new()));

/// 计算文件指纹 `modified_secs_len`(TD-260621-04 闭环)。
///
/// read_file 返回此值 → LLM 回传 patch_file.expected_hash → patch_file 比对，闭环防并发修改。
/// 抽单一真相源避免 read_file 输出与 patch_file 校验两处 format 漂移(modified_secs_len 格式必须严格一致)。
/// modified 取不到(系统不支持)退 0，仍保留 len 维度作弱保护。
pub(crate) fn compute_file_hash(meta: &std::fs::Metadata) -> String {
    let modified = meta
        .modified()
        .ok()
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs());
    format!("{}_{}", modified.unwrap_or(0), meta.len())
}

/// 将原始字节解码为 UTF-8 字符串，优先识别 UTF-16 LE/BE BOM 解码转 UTF-8。
///
/// 背景(P1 修复):PowerShell `Out-File`/`>` 重定向默认产 UTF-16 LE BOM 文件,
/// 每个 ASCII 字符高字节为 0x00(NUL)。旧 read_file 用 read_to_string 直接读 →
/// (a) read_to_string 对含 NUL 字节内容硬失败 InvalidData;或 (b) 即便侥幸读到,
/// 后续 is_binary / patch_file 的 `\0` 检测把 UTF-16 文本误判二进制拒读 →
/// run_command(Out-File) → 文件 → read_file 链路在 Windows 断裂。
///
/// 本 helper 替代 read_to_string 的纯字节→字符串转换:
/// 1. UTF-16 LE BOM(FF FE):strip BOM 后 u16::from_le_bytes + String::from_utf16 解码;
/// 2. UTF-16 BE BOM(FE FF):同上 from_be_bytes;
/// 3. 无 BOM:走 String::from_utf8(等价原 read_to_string 的 UTF-8 校验)。
///    注:UTF-8 BOM(EF BB BF)是合法 UTF-8 序列,read_to_string 原本就能读(内容首字符多 U+FEFF),
///    不在本 helper 误判路径内,无需特判。
///
/// 解码失败(真二进制/非 UTF-8 非 BOM 内容)返 io::Error(InvalidData),与 read_to_string
/// 同语义——调用方保留原 `if e.kind() == InvalidData { 返 binary 标记 }` 分支不变,
/// 真二进制(图片/编译产物)仍被拦截,UTF-16 文本因 BOM 优先识别不再进 \x00 误判路径。
///
/// 奇数字节 UTF-16(尾字节落单):drop 最后一个残字节后解码(对齐 PowerShell 偶发写半字符场景,
/// 不 bail 让多数可读内容通过)。Lone surrogate 交 String::from_utf16 lossy 兜底不 panic。
pub(crate) fn decode_bytes_to_string(bytes: &[u8]) -> std::io::Result<String> {
    // UTF-16 LE BOM: FF FE
    if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
        let body = &bytes[2..];
        let units: Vec<u16> = body
            .chunks_exact(2)
            .map(|c| u16::from_le_bytes([c[0], c[1]]))
            .collect();
        // from_utf16 遇 lone surrogate 返 Err(此处用 lossy 兜底保内容可读,不阻断)
        return Ok(String::from_utf16_lossy(&units));
    }
    // UTF-16 BE BOM: FE FF
    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
        let body = &bytes[2..];
        let units: Vec<u16> = body
            .chunks_exact(2)
            .map(|c| u16::from_be_bytes([c[0], c[1]]))
            .collect();
        return Ok(String::from_utf16_lossy(&units));
    }
    // 无 BOM:走 UTF-8 校验(等价 read_to_string,失败 InvalidData → 调用方判二进制)
    String::from_utf8(bytes.to_vec())
        .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "非 UTF-8 / 非 UTF-16 BOM 文本"))
}

/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
///
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
/// (ERROR_NOT_SAME_DEVICE),同盘同卷 rename 原子。`delete_file`(源→workspace/.trash)
/// 与 `rename_file`(from→to)在跨盘场景都需降级为 copy + remove(非原子)。
///
/// 本 helper 统一两处降级逻辑(对齐 delete_file:1430 与 rename_file:1534 的 copy+remove 模式):
/// 1. 先试 `rename`(原子,同盘成功直接返 Ok);
/// 2. rename 失败且 `raw_os_error == Some(17)`(跨卷)→ 降级 copy + remove,
///    copy 失败源完整(未动,上抛);remove 失败删 target 回滚保源完整(对齐设计:失败回滚删 target);
/// 3. rename 失败但非 17(权限/占用)→ 上抛原错,不降级(非跨卷问题降级无意义)。
///
/// 返回 `cross_volume: bool`(rename 成功=false,降级 copy+remove=true)供调用方回填结果。
///
/// 兜底/回退:flag 关或 helper 内部 panic 不影响——降级是纯增强,失败上抛 anyhow 让调用方
/// 走原 failed tool_result 路径(LLM 据此修参重试,非死循环:重试由阶段4 retry_guard 兜底)。
pub(crate) async fn rename_or_cross_volume_copy(
    from: &str,
    to: &str,
) -> anyhow::Result<bool> {
    // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
    match tokio::fs::rename(from, to).await {
        Ok(()) => Ok(false),
        Err(err) => {
            // 跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove;其他错误(权限/占用)直接抛
            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, to).await {
                anyhow::bail!("跨卷复制失败（from 未改动）: {}", e);
            }
            if let Err(e) = tokio::fs::remove_file(from).await {
                // remove 失败:回滚删 to,保 from 完整(用户可重试)
                let _ = tokio::fs::remove_file(to).await;
                anyhow::bail!("跨卷移动删除源失败已回滚（from 完整,可重试）: {}", e);
            }
            Ok(true)
        }
    }
}

/// 解析文件工具路径：相对路径锚定已授权目录的首个 persistent 目录，禁止越出项目目录。
///
/// 双层校验：
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
///    仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
///
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
pub(crate) fn resolve_workspace_path_with_allowed(
    path: &str,
    allowed: &AllowedDirs,
) -> anyhow::Result<PathBuf> {
    resolve_workspace_path_impl(path, allowed)
}

/// AE-2025-04：mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径（去 .. / symlink 逃逸）。pub(crate) wrapper 转调私有 impl，保持原私有函数
/// 可见性边界（不暴露给 crate 外，但允许同 crate ai 模块 mod.rs 调用）。
///
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
    resolve_workspace_path_impl(path, &AllowedDirs::default())
}

fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
    validate_path(path)?;
    let resolved = if Path::new(path).is_absolute() {
        PathBuf::from(path)
    } else {
        // 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
        // 无授权目录时给出明确引导,避免指向编译期常量。
        let root = allowed.first_persistent_dir()
            .ok_or_else(|| anyhow::anyhow!(
                "请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
            ))?;
        root.join(path)
    };
    // 词法层:防明显越界(不存在路径的兜底)。
    if !allowed.is_authorized(&resolved) {
        anyhow::bail!("路径不在授权目录内: {}", path);
    }
    // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
    if resolved.exists() {
        let canon_resolved = resolved.canonicalize()?;
        if !allowed.is_authorized(&canon_resolved) {
            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 返回结构)
pub(crate) 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_* 一处,定义与实现同源,编译期保证一致。
///
/// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc<RwLock<AllowedDirs>>` 形参,
/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。
/// data/http 工具不涉及文件路径,不传白名单。
///
/// get_app_config(2026-08-02):新增 `get_app_config_ctx: GetAppConfigCtx` 形参,
/// 该工具查 DevFlow 自身当前 AI 配置(provider/agent/settings),需读 AppState 内存原子量
/// (agent_max_iterations 等)+ db。因本函数在 AppState::init 内、AppState 构建中被调,
/// 无法传 &AppState(自引用),故把所需 Arc 句柄打包成 GetAppConfigCtx 传入(state.rs
/// init 先建这些 Arc 再 build registry 再组装 state,顺序调整)。
pub fn build_ai_tool_registry(
    db: &Arc<Database>,
    allowed_dirs: &Arc<RwLock<AllowedDirs>>,
    data_dir: PathBuf,
    get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
) -> AiToolRegistry {
    let mut registry = AiToolRegistry::new();
    register_data_tools(&mut registry, db);
    register_file_tools(&mut registry, allowed_dirs, data_dir);
    register_http_tools(&mut registry, allowed_dirs);
    register_fetch_url_tool(&mut registry);
    register_fetch_search_tool(&mut registry);
    register_generate_image_tool(&mut registry, db, allowed_dirs);
    register_get_app_config_tool(&mut registry, get_app_config_ctx);
    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 两工具。
///
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
///
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
fn register_http_tools(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<AllowedDirs>>) {
    super::tools::http::register(registry, allowed_dirs);
}

/// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。
/// 不持 db,纯 reqwest GET + htmd HTML→markdown 转换。SSRF 防护复用 commands/ai/http.rs
/// (validate_url / resolve_and_check_host / execute_with_redirects,经 pub(crate) 暴露)。
///
/// 风险:Low(只读 GET,无副作用)。与 http_request 分工:fetch_url 输出已清洗 markdown 供 LLM 理解文档,
/// http_request 输出原始响应 body 供 API 调用(POST/鉴权)。
///
/// tool_registry 拆分:声明式注册(tools/fetch_url.rs),handler 在 commands/ai/fetch_url.rs。
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 守护总量 + 工具名集合稳定。
fn register_fetch_url_tool(registry: &mut AiToolRegistry) {
    super::tools::fetch_url::register(registry);
}

/// fetch_search AI 工具注册(1 个:搜索引擎查询)— DuckDuckGo HTML 端点免 key 搜索。
/// 不持 db,纯 reqwest GET + HTML 字符串扫描解析。SSRF 防护复用 commands/ai/http.rs
/// (validate_url / resolve_and_check_host / build_client / execute_with_redirects)。
///
/// 风险:Low(只读 GET 搜索,无副作用,与 fetch_url 同级)。与 fetch_url 分工:fetch_url 输出
/// 已知 URL 的 markdown 正文,fetch_search 输出未知关键词的候选 URL 列表(标题+摘要),
/// 供 LLM 挑选入口再 fetch_url 深读。补「fetch_url 只抓已知 URL,无搜索能力」缺口。
///
/// HTML 解析:手写字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖
/// (html5ever+selectors+cssparser 编译产物大,DDG 单一来源不值)。基线测试守护总量 + 工具名集合。
fn register_fetch_search_tool(registry: &mut AiToolRegistry) {
    super::tools::fetch_search::register(registry);
}

/// generate_image AI 工具注册(1 个:调 provider 图像生成端点 + 下载落地)。
///
/// 工具职责:调 provider 的 OpenAI 兼容 `/v1/images/generations` 端点生成图片(默认 SenseNova
/// U1 Fast,也支持 DALL-E 等),自动下载到工作区 output_path。图像生成模型非 chat,走独立
/// 端点非 chat completions,故不能经主对话链路调用,必须用本工具显式触发。
///
/// 涉网络(provider POST + 图片 URL 下载)+ 付费 API(按张计费)+ 写文件(落盘 output_path),
/// 与 download_file 同源(均持 allowed_dirs 做 output_path 白名单校验 + 默认路径锚定)。
/// risk=High(付费 + 写磁盘 + 外发)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 守护总量 + 工具名集合稳定。
///
/// handler 在 commands/ai/generate_image.rs,声明式注册在 tools/generate_image.rs。
/// declare_tool! 单捕获限制:tools 层把 (db, allowed_dirs) 包进 tuple Arc 在闭包内解构。
fn register_generate_image_tool(
    registry: &mut AiToolRegistry,
    db: &Arc<Database>,
    allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) {
    super::tools::generate_image::register(registry, db, allowed_dirs);
}

/// get_app_config AI 工具注册(1 个:查 DevFlow 自身当前 AI 配置,只读 + 脱敏)—
/// 返 {default_provider, providers, agent, app_settings} JSON。
///
/// 治症状:AI 想看「当前 provider/model/agent 设置」时,因没原生查配置工具,只能 run_command
/// 执行 PowerShell 内联 node/python 脚本查 db,引号三重嵌套必失败。本工具一调即得,根治绕行。
///
/// 风险:Low(纯只读 list/load,无写副作用)。安全:api_key 经 mask_api_key 脱敏
/// (前 4 + •••• + 后 4),绝不返明文;app_settings 走白名单不返敏感 KV。
///
/// handler 在 commands/ai/get_app_config.rs,声明式注册在 tools/get_app_config.rs。
/// declare_tool! 单捕获:把 db + 3 个 agent 配置 Arc + LlmConcurrency 包进 GetAppConfigCtx
/// struct(Clone 廉价,全 Arc 字段),闭包内 &ctx 转调 handler。
///
/// ctx 字段与 AppState 同名字段共享同一底层原子量(经 ai_set_concurrency_config /
/// ai_set_agent_max_iterations 等 IPC 热改后,handler 读到的永远是当前生效值)。
fn register_get_app_config_tool(
    registry: &mut AiToolRegistry,
    ctx: super::get_app_config::GetAppConfigCtx,
) {
    super::tools::get_app_config::register(registry, ctx);
}

/// 数据层 AI 工具注册(25 个持 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 路径。
///
/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
/// data 层 24→25。
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
///
/// 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
/// 知识图谱 Phase 1(2026-06-26)新增第 6 子函数:
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
/// 该子函数 6→7 工具,data 层 24→25。
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置(D10 不存凭证),该子函数 7→9 工具,data 层 25→27。
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增,
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 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_task_graph_tools(registry, db);
    register_workflow_tools(registry);
    register_idea_tools(registry, db);
    register_trash_tools(registry, db);
    register_git_tools(registry, db);
}

/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。
///
/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 +
/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更),
/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::list_projects::register(registry, db);
    super::tools::project::register(registry, db);
}

/// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。
///
/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs),
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::task::register(registry, db);
}

/// 知识图谱 / 工程系统类 AI 工具注册(10 个:task_link CRUD + 父子树 + 跨池移动 + content 更新
/// + 项目事件流查询 + 项目基础设施配置 + 工程列表) —— 持 db:Arc<Database>,对标设计 §五
/// Phase1/Phase2/Phase3 AI 工具表 + §2.1/§2.2/§2.3/§2.4 + 工程系统。
///
/// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo),
/// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验:
/// - link CRUD / 反查:TaskLinkRepo(create_link 内含 link_type 白名单 + depends_on BFS 环检测)。
/// - 父子树:get_children(限 1 级嵌套,设计 D2)。
/// - 跨池移动:queue/status 一致性联动(对齐 commands::task::move_task_queue 语义)。
/// - content 更新:走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验。
///
/// 风险等级对标设计 §五:link 写=Medium / 只读=Low / queue 移动=Medium / content 更新=Medium。
///
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::task_graph::register(registry, db);
}

/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
///
/// AI 据此自主查看工程代码仓库状态(status/diff/log 无需审批),commit/branch/merge 需审批。
/// 均在工程目录(module.path)执行,无 .git 返回空状态。
///
/// tool_registry 拆分:6 个 git 工具全部迁声明式(tools/git.rs),配套私有 helper
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::git::register(registry, db);
}

/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
/// 真正执行经 ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner。
///
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_workflow_tools(registry: &mut AiToolRegistry) {
    super::tools::workflow::register(registry);
}

/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
///
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::idea::register(registry, db);
}

/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
///
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
    super::tools::trash::register(registry, db);
}

/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
///
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
/// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。
///
/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_file_tools(
    registry: &mut AiToolRegistry,
    allowed_dirs: &Arc<RwLock<AllowedDirs>>,
    data_dir: PathBuf,
) {
    super::tools::file::register(registry, allowed_dirs, data_dir);
    // download_file:跨平台 URL→文件流式下载(reqwest bytes_stream + tokio::fs)。
    // 写文件需路径授权(与 write_file 同源),故在 register_file_tools 注册(已有 allowed_dirs)。
    // SSRF 复用 commands/ai/http.rs,大小上限 200MB + 原子写(tmp→rename)。
    super::tools::download_file::register(registry, allowed_dirs);
}

/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
///
/// 执行 `cmd`(如 "which python3" / "where node")取 stdout 首行(trim 后)作为路径。
/// 失败(命令不存在/解释器未装/超时/退出码非 0)记 `error` 到 `errors` 并返回 None。
///
/// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。
/// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop),
/// shell_type 用 default(Windows=PowerShell / Unix=sh)。
pub(crate) async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec<String>) -> Option<String> {
    let request = ShellRequest {
        command: cmd.to_string(),
        working_dir: None,
        env: HashMap::new(),
        // 8s 超时:which/where 通常 <1s,8s 足够且不拖垮会话。超时视为不可用(记错返 None)。
        timeout_secs: Some(8),
        shell_type: Default::default(),
    };
    match execute(request).await {
        Ok(result) => {
            // 退出码非 0:which/where 未找到目标(如 python 未装)→ 正常情况,记错返 None
            if !matches!(result.exit_code, Some(0)) {
                errors.push(format!(
                    "{}: 探测命令退出码 {:?}({})",
                    label, result.exit_code, cmd
                ));
                return None;
            }
            let first_line = result.stdout.lines().next().map(|s| s.trim().to_string());
            match first_line {
                Some(p) if !p.is_empty() => Some(p),
                _ => {
                    errors.push(format!("{}: 探测输出为空({})", label, cmd));
                    None
                }
            }
        }
        Err(e) => {
            errors.push(format!("{}: 探测失败({}): {}", label, cmd, e));
            None
        }
    }
}

/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
pub(crate) 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("搜索文件"),
        "grep" => 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,
    }
}

/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
pub(crate) 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。
pub(crate) fn is_noise_file(name: &str) -> bool {
    const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
    NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}

// ============================================================
// grep 工具底层（F-260621）
//
// FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时:
// - content: 展平 line_matches 为 matches[{file,line,content,context?}]
// - files_with_matches: 仅取 file 字段(顺序保留首次命中)
// - count: 取 line_matches.len() 为每文件命中行数
//
// LineMatch.content 为命中行原文(去尾换行),context 为命中行前后 context_lines 行
// 拼接(前 N 行 + 命中行 + 后 N 行,\n 连接),供前端展开查看上下文。
// ============================================================

/// 单行命中(1-based 行号 + 行内容 + 上下文)
pub(crate) struct LineMatch {
    pub line: usize,
    pub content: String,
    pub context: String,
}

/// 单文件命中聚合(file=绝对/锚定路径,line_matches 按行号升序)
pub(crate) struct FileGrepHit {
    pub file: String,
    pub line_matches: Vec<LineMatch>,
}

/// 编译单段 glob(* / ? / [seq] / 字面字符)为 regex,锚定 ^...$ 整段匹配文件名。
///
/// 转换规则(对齐 Claude Code grep glob 单段语义,不含 /):
/// - `*` → `[^/]*`(任意非分隔符序列)
/// - `?` → `[^/]`(单个非分隔符)
/// - `[seq]` 原样保留为字符集(支持 [a-z] / [!seq] 取反)
/// - 其他字符 regex escape(防 `.+()` 等被当元字符)
///
/// 简化设计:不引入 glob crate(避免新顶层依赖),手写单段 glob→regex 转换。
/// 失败(glob 含非法 regex 构造,如未闭合 `[`)上抛,调用方 map_err 友好提示。
pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex> {
    let mut out = String::with_capacity(glob.len() + 4);
    out.push('^');
    let mut chars = glob.chars().peekable();
    while let Some(c) = chars.next() {
        match c {
            '*' => out.push_str("[^/]*"),
            '?' => out.push_str("[^/]"),
            '[' => {
                // 字符集:原样保留到匹配的 ](支持开头 ] 字面 + ! 取反)
                out.push('[');
                // ] 在首位视为字面(POSIX glob 约定)
                if matches!(chars.peek(), Some(']')) {
                    out.push('\\'); out.push(']');
                    chars.next();
                }
                if matches!(chars.peek(), Some('!')) {
                    out.push('^'); // regex 取反用 ^
                    chars.next();
                }
                while let Some(ch) = chars.next() {
                    if ch == ']' {
                        out.push(']');
                        break;
                    }
                    // 集合内字符 escape regex 元字符(如 ] 已由 break 处理,此处 \ - 等保留)
                    out.push(ch);
                }
            }
            // regex 元字符 escape(. + ( ) | ^ $ { } \ 等)
            '.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '\\' => {
                out.push('\\'); out.push(c);
            }
            _ => out.push(c),
        }
    }
    out.push('$');
    regex::Regex::new(&out).map_err(|e| anyhow::anyhow!(e.to_string()))
}

/// 递归跨文件内容搜索(grep -rn 模式)
///
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
/// - glob 过滤器(可选):命中文件名才读内容
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
/// - 逐行 `re.is_match`,收集命中行(context_lines>0 时附上下文)
/// - 达 `max_results` 命中行数即停止(返 `truncated=true`),`total` 仍累加全部命中数
///
/// 输出聚合到 `out`(按文件分组),`total`/`truncated` 由调用方读后构造响应。
pub(crate) fn grep_recursive<'a>(
    dir: &'a str,
    re: &'a regex::Regex,
    glob: Option<&'a regex::Regex>,
    output_mode: &'a str,
    context_lines: usize,
    max_results: usize,
    depth: usize,
    max_depth: usize,
    out: &'a mut Vec<FileGrepHit>,
    total: &'a mut usize,
    truncated: &'a mut bool,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
    Box::pin(async move {
        let mut entries = tokio::fs::read_dir(dir).await
            .map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", dir, e))?;
        // 收集本层 entry 先排序(确定性输出,便于测试稳定 + LLM 可重现)
        let mut items: Vec<std::path::PathBuf> = Vec::new();
        while let Some(entry) = entries.next_entry().await? {
            items.push(entry.path());
        }
        items.sort();
        for path in items {
            if *truncated {
                return Ok(());
            }
            let file_name = match path.file_name() {
                Some(n) => n.to_string_lossy().to_string(),
                None => continue,
            };
            // file_type 不跟随 symlink(对比 metadata 会跟随);symlink 一律跳过(防逃逸,对齐 list_dir_recursive)
            let file_type = match tokio::fs::symlink_metadata(&path).await {
                Ok(ft) => ft,
                Err(_) => continue, // 元数据读失败跳过(权限/竞态等)
            };
            if file_type.is_symlink() {
                continue;
            }
            if file_type.is_dir() {
                // 噪音目录(.git/node_modules/target/.trash 等)不深入
                if is_noise_dir(&file_name) {
                    continue;
                }
                if depth < max_depth {
                    let child = path.to_string_lossy().into_owned();
                    grep_recursive(
                        &child, re, glob, output_mode, context_lines, max_results,
                        depth + 1, max_depth, out, total, truncated,
                    ).await?;
                }
                continue;
            }
            // 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
            grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
        }
        Ok(())
    })
}

/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
/// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用——
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
pub(crate) async fn grep_one_file(
    path: &std::path::Path,
    file_name: &str,
    re: &regex::Regex,
    glob: Option<&regex::Regex>,
    output_mode: &str,
    context_lines: usize,
    max_results: usize,
    out: &mut Vec<FileGrepHit>,
    total: &mut usize,
    truncated: &mut bool,
) -> anyhow::Result<()> {
    // 噪音文件(.bak/.tmp-write 等编辑器产物)跳过
    if is_noise_file(file_name) {
        return Ok(());
    }
    // glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
    if let Some(g) = glob {
        if !g.is_match(file_name) {
            return Ok(());
        }
    }
    // 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
    let metadata = match tokio::fs::metadata(path).await {
        Ok(m) => m,
        Err(_) => return Ok(()),
    };
    if metadata.len() > 1_048_576 {
        return Ok(()); // >1MB 跳过(对齐 read_file 上限)
    }
    let bytes = match tokio::fs::read(path).await {
        Ok(b) => b,
        Err(_) => return Ok(()),
    };
    // 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
    if bytes.iter().take(8192).any(|&b| b == 0) {
        return Ok(());
    }
    // 转 UTF-8(lossy 容错非 UTF-8 残片,二进制已挡多数情况)
    let content = String::from_utf8_lossy(&bytes);
    let lines: Vec<&str> = content.lines().collect();
    // files_with_matches 模式:仅记文件级命中,不收集行详情。
    // content/count 模式:行级命中,需逐行检查 max_results 截断(单文件可能多行命中,
    //   超过 max_results 的行只计入 total 不入 out)。
    if output_mode == "files_with_matches" {
        // 检查文件数是否已达上限:达则本文件只计入 total 不入 out(设截断)
        let mut has_hit = false;
        for line in lines.iter() {
            if re.is_match(line) {
                *total += 1;
                has_hit = true;
            }
        }
        if !has_hit {
            return Ok(()); // 本文件无命中
        }
        if out.len() >= max_results {
            *truncated = true; // 已达文件数上限,只计入 total
        } else {
            out.push(FileGrepHit {
                file: path.to_string_lossy().into_owned(),
                line_matches: Vec::new(), // files_with_matches 不收集行详情
            });
        }
        return Ok(());
    }
    // content/count 模式:逐行收集,达 max_results 行数上限即截断(剩余行只计 total)
    let mut line_matches: Vec<LineMatch> = Vec::new();
    let mut file_has_hit = false;
    // 已收集命中行累计计数(O(1) 维护):初值=前序文件累计收集数(本文件内逐次自增)
    // 取代每次命中全量重算 out.iter().map(...).sum() 的 O(n²) 统计。
    let mut collected = out.iter().map(|h| h.line_matches.len()).sum::<usize>();
    for (idx, line) in lines.iter().enumerate() {
        if re.is_match(line) {
            *total += 1;
            file_has_hit = true;
            // 当前已收集行数达上限 → 截断,不再收集
            if collected >= max_results {
                *truncated = true;
            } else {
                let context = if context_lines > 0 {
                    let start = idx.saturating_sub(context_lines);
                    let end = (idx + context_lines + 1).min(lines.len());
                    lines[start..end].join("\n")
                } else {
                    String::new()
                };
                line_matches.push(LineMatch {
                    line: idx + 1, // 1-based
                    content: line.to_string(),
                    context,
                });
                collected += 1; // 命中收集后累计计数自增(O(1))
            }
        }
    }
    if file_has_hit && !line_matches.is_empty() {
        // 本文件有命中且有收集(全截断的文件不入 out,避免空 line_matches 污染)
        out.push(FileGrepHit {
            file: path.to_string_lossy().into_owned(),
            line_matches,
        });
    }
    Ok(())
}

/// 递归搜索文件（字符串包含匹配，大小写不敏感）
pub(crate) 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();
            // file_type 不跟随 symlink(对比 entry.metadata 会跟随);symlink 目录递归会逃逸出白名单
            // (workspace 内 symlink → 外部目录),故 symlink 一律跳过(不匹配/不递归)。对齐 list_dir_recursive。
            let file_type = entry.file_type().await?;
            if file_type.is_symlink() {
                continue;
            }
            let is_dir = file_type.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();
                        let metadata = entry.metadata().await?;
                        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;
    // get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
    // 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。
    use std::sync::atomic::{AtomicU64, AtomicUsize};
    use crate::state::LlmConcurrency;

    /// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
    ///
    /// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
    /// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
    fn make_test_get_app_config_ctx(db: &Arc<Database>) -> super::get_app_config::GetAppConfigCtx {
        super::get_app_config::GetAppConfigCtx {
            db: db.clone(),
            agent_max_iterations: Arc::new(AtomicUsize::new(
                crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
            )),
            agent_max_retries: Arc::new(AtomicUsize::new(
                crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
            )),
            approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
            llm_concurrency: LlmConcurrency::new(3, 2),
        }
    }

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

    /// build_ai_tool_registry 应注册恰好 55 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),且工具名集合稳定。
    ///
    /// 用 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);
        // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
        // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));

        // 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
        // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
        // L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
        // AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
        // 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具:
        //   create_task_link/remove_task_link/list_task_links/get_task_tree/move_task_queue/update_content)。
        // 知识图谱 Phase 2(2026-06-26): data 层 24→25(新增 get_project_timeline 项目事件流查询,
        //   register_task_graph_tools 6→7)。
        // 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
        //   项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
        // 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询,
        //   register_task_graph_tools 9→10)
        // Git 只读工具(2026-06-29): data 层 28→31(新增 git_status/git_diff/git_log,
        //   register_git_tools 3 个)
        // Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
        //   register_git_tools 3→6)
        // BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
        //   48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
        // update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具,
        //   idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。
        //   50 = 36 data + 13 file + 1 http。
        // fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET,
        //   htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。
        //   51 = 36 data + 13 file + 1 http + 1 fetch_url。
        // download_file(2026-08-01): file 层 13→14(新增 URL→文件跨平台流式下载,
        //   reqwest bytes_stream + tokio::fs,替代 curl/wget alias 陷阱。写文件需 allowed_dirs
        //   故注册在 register_file_tools;SSRF 复用 http.rs + 路径校验复用 validate_path)。
        //   52 = 36 data + 14 file + 1 http + 1 fetch_url。
        // fetch_search(2026-08-02): fetch_search 层 1→2(新增搜索引擎查询,DuckDuckGo HTML 免 key,
        //   GET + HTML 字符串扫描解析结果列表,补 fetch_url 只抓已知 URL 缺口。SSRF 复用 http.rs)。
        //   53 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search。
        // generate_image(2026-08-02): generate_image 层 1→1(新增调 provider 图像生成端点 +
        //   下载落地,默认 sensenova-u1-fast,也支持 DALL-E。SSRF 复用 http.rs + 路径校验复用
        //   resolve_workspace_path_with_allowed,declare_tool! 单捕获绕法用 tuple Arc)。
        //   54 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image。
        // get_app_config(2026-08-02): get_app_config 层 1→1(新增查 DevFlow 自身当前 AI 配置,
        //   只读 + api_key 脱敏,治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败。
        //   build_ai_tool_registry 加第 4 参 GetAppConfigCtx 绕 AppState 自引用,declare_tool!
        //   单捕获用 struct ctx 包 db + 3 agent Arc + LlmConcurrency,共享 AppState 同名句柄)。
        //   55 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config。
        assert_eq!(
            registry.len(),
            55,
            "工具总数应为 55(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),实际 {}", registry.len()
        );

        // 工具名集合基线:防 rename / 漏注册 / 误删除。
        // data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具
        // file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载
        // http 层 1 个(不持 db):http_request
        let mut expected: Vec<&str> = vec![
            // ── data 层 (36) ──
            "list_projects", "list_tasks", "list_ideas",
            "update_project", "create_project", "bind_directory",
            "create_task", "update_task", "advance_task",
            "run_workflow", "delete_task", "create_idea",
            "update_idea", "delete_idea",
            "delete_project", "restore_project", "purge_project",
            "list_trash", "get_project_count", "get_task_count",
            // 知识图谱 Phase 1 任务关联工具(register_task_graph_tools 6 个)
            "create_task_link", "remove_task_link", "list_task_links",
            "get_task_tree", "move_task_queue", "update_content",
            // 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
            "get_project_timeline",
            // 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
            "add_project_service", "list_project_services",
            // 工程系统:项目工程列表查询
            "list_project_modules",
            // Git 只读工具
            "git_status", "git_diff", "git_log",
            // Git 写工具
            "git_commit", "git_branch", "git_merge",
            // ── file 层 (14) ──（run_command 注册顺序已移至末位降低 LLM 偏好,
            //   集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
            //   detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
            //   download_file 新增跨平台 URL→文件流式下载）
            "read_file", "read_symbol", "list_directory", "write_file",
            "patch_file", "file_info", "append_file",
            "delete_file", "rename_file", "search_files", "run_command",
            "grep", "detect_environment",
            // download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
            "download_file",
            // ── http 层 (1) ──
            "http_request",
            // ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
            "fetch_url",
            // ── fetch_search 层 (1) ──(搜索引擎查询 DDG HTML 免 key,只读 GET,补 fetch_url 缺口)
            "fetch_search",
            // ── generate_image 层 (1) ──(调 provider 图像生成端点 + 下载落地,默认 sensenova-u1-fast)
            "generate_image",
            // ── get_app_config 层 (1) ──(查 DevFlow 自身当前 AI 配置,只读 + api_key 脱敏,
            //   治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
            "get_app_config",
        ];
        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();
    }

    // ============================================================
    // read_file 分页 limit 尊重测试（BUG-260623-01 回归守护）
    //
    // read_file handler 无 offset 分支旧实现固定 take(500),忽略 LLM 传入的 limit。
    // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
    // 修复:无 offset 时也读 args[limit](对齐有 offset 分支语义),未传默认 500。
    //
    // 经完整 AiToolRegistry.execute 调用 read_file handler(端到端覆盖 offset/limit 分支),
    // 而非内联重复分页逻辑——避免测试与实现两处分页代码漂移(实现改测试不红)。
    // ============================================================

    /// 造 600 行文件,无 offset + limit=15 → returned_lines=15(limit 生效,旧 bug 返 500)。
    #[tokio::test]
    async fn test_read_file_no_offset_respects_limit() {
        let tmp = std::env::temp_dir().join(format!("df_readfile_limit_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // 600 行(>500 默认,确保 limit=15 与默认 500 行为可区分;15 < 500 锁定 limit 被读)
        let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
        let file = tmp.join("big.txt");
        fs::write(&file, body).unwrap();
        // persistent 存词法形态(对齐 is_authorized:candidate 经 strip_verbatim 归一词法去 \\?\ 前缀;
        // 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
        let mut persistent = std::collections::HashSet::new();
        persistent.insert(tmp.clone());
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));

        let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
        let db = Arc::new(db);
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
        let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
        let args = serde_json::json!({ "path": canon_file, "limit": 15 });
        let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
        let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
        assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
        assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");

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

    /// 造 600 行文件,无 offset 无 limit → 默认 500 行(DEFAULT_PREVIEW_LINES)。
    #[tokio::test]
    async fn test_read_file_no_offset_no_limit_defaults_500() {
        let tmp = std::env::temp_dir().join(format!("df_readfile_default_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
        let file = tmp.join("big.txt");
        fs::write(&file, body).unwrap();
        // persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
        let mut persistent = std::collections::HashSet::new();
        persistent.insert(tmp.clone());
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));

        let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
        let db = Arc::new(db);
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
        let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
        let args = serde_json::json!({ "path": canon_file });
        let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
        let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
        assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
        assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");

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

    // ============================================================
    // P1 修复:UTF-16 LE/BE BOM 文件解码(PowerShell Out-File/> 产 UTF-16 LE,
    // 旧 read_to_string 因 ASCII 高字节 0x00 判 InvalidData → 误判二进制拒读)
    // decode_bytes_to_string 纯函数单测 + read_file 端到端 + 真 binary 仍拒读
    // ============================================================

    /// decode_bytes_to_string: UTF-16 LE BOM(FF FE) → 可读 UTF-8
    #[test]
    fn test_decode_bytes_utf16_le_bom() {
        // "hello\n" UTF-16 LE 编码
        let text = "hello\n";
        let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; // LE BOM
        for u in text.encode_utf16() {
            bytes.extend_from_slice(&u.to_le_bytes());
        }
        let s = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功");
        assert_eq!(s, text, "UTF-16 LE BOM 文件应解码为原文本");
    }

    /// decode_bytes_to_string: UTF-16 BE BOM(FE FF) → 可读 UTF-8
    #[test]
    fn test_decode_bytes_utf16_be_bom() {
        // 中文 + ASCII,UTF-16 BE 编码(验证非 ASCII 码点 + 大端序)
        let text = "中文 ascii\n";
        let mut bytes: Vec<u8> = vec![0xFE, 0xFF]; // BE BOM
        for u in text.encode_utf16() {
            bytes.extend_from_slice(&u.to_be_bytes());
        }
        let s = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功");
        assert_eq!(s, text, "UTF-16 BE BOM 文件应解码为原文本(含中文码点)");
    }

    /// decode_bytes_to_string: 无 BOM 真 binary(密集 \x00 非 BOM 前缀) → InvalidData
    #[test]
    fn test_decode_bytes_real_binary_rejected() {
        // 真二进制:图片/编译产物常见,前两字节非 BOM 模式且含密集 NUL
        let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0x0D, 0x00, 0x0A]; // PNG 头片段
        let err = decode_bytes_to_string(&bin).expect_err("真二进制应解码失败");
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "真二进制返 InvalidData");
    }

    /// decode_bytes_to_string: 普通 UTF-8 无 BOM → 透传(回归保护)
    #[test]
    fn test_decode_bytes_plain_utf8_passthrough() {
        let bytes = "普通 UTF-8 文本\n".as_bytes();
        let s = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应直接通过");
        assert_eq!(s, "普通 UTF-8 文本\n");
    }

    /// read_file 端到端:UTF-16 LE BOM 文件经 registry.execute 读出正常 content + binary=false
    #[tokio::test]
    async fn test_read_file_utf16_le_bom_decodes() {
        let tmp = std::env::temp_dir().join(format!("df_readfile_utf16le_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // 造 UTF-16 LE BOM 文件(模拟 PowerShell Out-File 产物)
        let text = "line1\nline2\nPowerShell output\n";
        let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
        for u in text.encode_utf16() {
            bytes.extend_from_slice(&u.to_le_bytes());
        }
        let file = tmp.join("ps_out.txt");
        fs::write(&file, &bytes).unwrap();

        let mut persistent = std::collections::HashSet::new();
        persistent.insert(tmp.clone());
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
        let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));

        let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
        let args = serde_json::json!({ "path": canon_file });
        let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
        // 关键断言:不再误判二进制,返正常文本内容
        assert_eq!(res["binary"], serde_json::Value::Null, "UTF-16 LE BOM 不应判二进制");
        let content = res["content"].as_str().expect("应返回 content 字段");
        assert!(content.contains("line1"), "解码内容应含 line1,实际: {}", content);
        assert!(content.contains("PowerShell output"), "解码内容应含原文,实际: {}", content);

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

    /// read_file 端到端:真 binary(密集 \x00 非 BOM)仍返 binary:true(拦截不破)
    #[tokio::test]
    async fn test_read_file_real_binary_still_rejected() {
        let tmp = std::env::temp_dir().join(format!("df_readfile_bin_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // PNG 签名 + 密集 NUL(真二进制,前两字节 89 50 非 BOM)
        let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0xFF, 0xFE];
        let file = tmp.join("image.png");
        fs::write(&file, &bin).unwrap();

        let mut persistent = std::collections::HashSet::new();
        persistent.insert(tmp.clone());
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
        let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));

        let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
        let args = serde_json::json!({ "path": canon_file });
        let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
        assert_eq!(res["binary"], true, "真二进制(非 BOM 前缀)应仍被拦截返 binary:true");
        assert_eq!(res["content"], serde_json::Value::Null, "真二进制 content 应为 null");

        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");
    }

    // ============================================================
    // grep 工具测试（F-260621）
    //
    // compile_glob_to_regex:纯函数,无 fs 依赖。
    // grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。
    // path_auth 未授权触发申请:经 check_file_tool_auth(单路径分支) → NeedsAuth,
    //   非 search_files 盲拒(锁定 grep 走 read_file 同款申请路径)。
    // ============================================================

    /// compile_glob_to_regex:基础 * / ? / 字面字符
    #[test]
    fn test_compile_glob_basic() {
        let g = compile_glob_to_regex("*.rs").unwrap();
        assert!(g.is_match("main.rs"));
        assert!(g.is_match("a.rs"));
        assert!(!g.is_match("main.ts"));
        // * 不跨段(不含 /)
        assert!(!g.is_match("dir/main.rs"), "* 不匹配 / (单段语义)");

        let q = compile_glob_to_regex("?.txt").unwrap();
        assert!(q.is_match("a.txt"));
        assert!(!q.is_match("ab.txt"), "? 仅匹配单字符");

        // 字面字符(含 regex 元字符 escape:. 不当通配)
        let lit = compile_glob_to_regex("v1.0.txt").unwrap();
        assert!(lit.is_match("v1.0.txt"));
        assert!(!lit.is_match("v1X0.txt"), ". 应字面匹配(escape 防 regex 元字符)");
    }

    /// compile_glob_to_regex:字符集 [seq] / [!seq] 取反
    #[test]
    fn test_compile_glob_charset() {
        let set = compile_glob_to_regex("*.[ch]").unwrap();
        assert!(set.is_match("main.c"));
        assert!(set.is_match("main.h"));
        assert!(!set.is_match("main.cpp"));

        let neg = compile_glob_to_regex("*.[!ch]").unwrap();
        assert!(!neg.is_match("main.c"), "[!ch] 取反,c 不命中");
        assert!(!neg.is_match("main.h"), "[!ch] 取反,h 不命中");
        // [!ch] 匹配单字符非 c/h 的扩展名(锚定 ^...$,多字符扩展不命中)
        assert!(neg.is_match("main.s"), "[!ch] 取反,单字符 s 命中");
        assert!(!neg.is_match("main.rs"), "[!ch] 锚定单字符,rs(2 字符)不命中");
    }

    /// grep_recursive:基本字面匹配(无特殊字符等价 contains)
    #[tokio::test]
    async fn test_grep_basic_literal_match() {
        let tmp = std::env::temp_dir().join(format!("df_grep_basic_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join("src")).unwrap();
        fs::write(tmp.join("src").join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
        fs::write(tmp.join("src").join("b.rs"), "struct Foo;\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        // 大小写敏感:只命中 a.rs 第 1 行 "fn foo() {}"(b.rs "struct Foo" 大写不命中)
        assert!(!truncated);
        assert_eq!(total, 1, "foo 大小写敏感仅命中 1 处");
        assert_eq!(out.len(), 1, "命中 1 个文件");
        assert!(out[0].file.ends_with("a.rs"));
        assert_eq!(out[0].line_matches.len(), 1);
        assert_eq!(out[0].line_matches[0].line, 1);
        assert_eq!(out[0].line_matches[0].content, "fn foo() {}");

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

    /// grep_recursive:正则匹配(锚定 ^)
    #[tokio::test]
    async fn test_grep_regex_match() {
        let tmp = std::env::temp_dir().join(format!("df_grep_regex_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        fs::write(tmp.join("a.rs"), "fn main() {}\nasync fn helper() {}\nfn main2() {}\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        // ^fn 匹配行首 fn
        let re = regex::Regex::new("^fn ").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        // 第 1、3 行行首是 "fn ",第 2 行行首是 "async fn " 不命中 ^fn
        assert_eq!(total, 2, "^fn 命中第 1、3 行");
        assert_eq!(out[0].line_matches.len(), 2);

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

    /// grep_recursive:大小写不敏感(-i 语义,经 RegexBuilder case_insensitive)
    #[tokio::test]
    async fn test_grep_case_insensitive() {
        let tmp = std::env::temp_dir().join(format!("df_grep_ci_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        fs::write(tmp.join("a.rs"), "Foo\nfoo\nFOO\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::RegexBuilder::new("foo").case_insensitive(true).build().unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        assert_eq!(total, 3, "大小写不敏感命中 Foo/foo/FOO 三处");

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

    /// grep_recursive:glob 过滤(仅搜 *.rs)
    #[tokio::test]
    async fn test_grep_glob_filter() {
        let tmp = std::env::temp_dir().join(format!("df_grep_glob_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // .rs 与 .ts 都含 "foo",glob *.rs 只搜 .rs
        fs::write(tmp.join("a.rs"), "foo\n").unwrap();
        fs::write(tmp.join("b.ts"), "foo\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let glob_re = Some(compile_glob_to_regex("*.rs").unwrap());
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, glob_re.as_ref(), "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        assert_eq!(out.len(), 1, "glob *.rs 仅命中 1 个文件");
        assert!(out[0].file.ends_with("a.rs"), "应只命中 a.rs,b.ts 被过滤");

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

    /// grep_recursive:噪音目录(.git/node_modules/target)与噪音文件(.bak)跳过
    #[tokio::test]
    async fn test_grep_skips_noise() {
        let tmp = std::env::temp_dir().join(format!("df_grep_noise_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(tmp.join(".git")).unwrap();
        fs::write(tmp.join(".git").join("config"), "foo-in-git\n").unwrap();
        fs::create_dir_all(tmp.join("target")).unwrap();
        fs::write(tmp.join("target").join("app"), "foo-in-target\n").unwrap();
        // 噪音文件 .bak
        fs::write(tmp.join("a.rs.bak"), "foo-in-bak\n").unwrap();
        // 正常文件
        fs::write(tmp.join("a.rs"), "foo\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        // 只命中 a.rs,噪音目录(.git/target)与噪音文件(.bak)被跳过
        assert_eq!(total, 1, "仅 a.rs 命中,噪音被跳过");
        assert_eq!(out.len(), 1);
        assert!(out[0].file.ends_with("a.rs"));

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

    /// grep_recursive:max_results 截断(total 累加全部,out 受限,truncated=true)
    #[tokio::test]
    async fn test_grep_truncation() {
        let tmp = std::env::temp_dir().join(format!("df_grep_trunc_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // 一个文件含 5 行 foo,max_results=2 截断
        fs::write(tmp.join("a.rs"), "foo\nfoo\nfoo\nfoo\nfoo\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 2, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        assert!(truncated, "达 max_results=2 应截断");
        assert_eq!(total, 5, "total 累加全部 5 处命中");
        // out 内行数受 max_results 限制(2 行)
        let collected: usize = out.iter().map(|h| h.line_matches.len()).sum();
        assert_eq!(collected, 2, "out 收集 2 行受 max_results 限制");

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

    /// grep_recursive:context_lines 上下文(命中行前后各 N 行)
    #[tokio::test]
    async fn test_grep_context_lines() {
        let tmp = std::env::temp_dir().join(format!("df_grep_ctx_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // 第 3 行命中 foo,context_lines=1 应含第 2-4 行
        fs::write(tmp.join("a.rs"), "line1\nline2\nfoo\nline4\nline5\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 1, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        assert_eq!(out[0].line_matches[0].line, 3);
        // context = 第 2、3、4 行(命中行前 1 + 命中行 + 后 1)
        assert_eq!(out[0].line_matches[0].context, "line2\nfoo\nline4");

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

    /// grep_recursive:files_with_matches 模式仅返命中文件(不收集行详情)
    #[tokio::test]
    async fn test_grep_files_with_matches_mode() {
        let tmp = std::env::temp_dir().join(format!("df_grep_fwm_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        fs::write(tmp.join("a.rs"), "foo\nfoo\n").unwrap(); // 同文件多命中
        fs::write(tmp.join("b.rs"), "foo\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "files_with_matches", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        // total 累加全部命中行(3),out 每文件 line_matches 空(不收集行详情)
        assert_eq!(total, 3, "total 计全部命中行");
        assert_eq!(out.len(), 2, "命中 2 个文件");
        assert!(out.iter().all(|h| h.line_matches.is_empty()), "files_with_matches 不收集行详情");

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

    /// grep_recursive:二进制文件(\0)跳过
    #[tokio::test]
    async fn test_grep_skips_binary() {
        let tmp = std::env::temp_dir().join(format!("df_grep_bin_{}", std::process::id()));
        let _ = fs::remove_dir_all(&tmp);
        fs::create_dir_all(&tmp).unwrap();
        // 二进制文件(前 8KB 含 \0),内容含 "foo" 但应被跳过
        let mut bin_content = b"foo\n".to_vec();
        bin_content.push(0u8);
        bin_content.extend_from_slice(b"more foo\n");
        fs::write(tmp.join("a.bin"), &bin_content).unwrap();
        // 正常文本文件
        fs::write(tmp.join("a.rs"), "foo\n").unwrap();
        let root = tmp.to_string_lossy().to_string();

        let re = regex::Regex::new("foo").unwrap();
        let mut out: Vec<FileGrepHit> = Vec::new();
        let mut total = 0usize;
        let mut truncated = false;
        grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();

        // 二进制 a.bin 被跳过,只命中 a.rs
        assert_eq!(total, 1, "二进制文件被跳过,仅 a.rs 命中");
        assert_eq!(out.len(), 1);
        assert!(out[0].file.ends_with("a.rs"));

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

    /// path_auth 未授权触发申请:grep 走单路径授权申请路径(非 search_files 盲拒)。
    /// 完整 check_file_tool_auth → NeedsAuth 流程覆盖在 audit/mod.rs 测试模块,
    /// 此处锁定 grep 编译正则 + glob 的预校验路径(pattern 空 bail、非法正则 bail)。
    #[test]
    fn test_grep_pattern_validation() {
        // 非法正则编译失败上抛(锁定 grep handler 对非法 pattern 的明确错误,非 panic)
        let bad = regex::RegexBuilder::new("[unclosed").build();
        assert!(bad.is_err(), "未闭合 [ 应编译失败(锁定 handler map_err 路径)");
        // 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
        assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
    }

    // ============================================================
    // 知识图谱 Phase 3(2026-06-27):add_project_service / list_project_services
    // AI 工具 handler 端到端串联测试(经 registry.execute 验证参数装配 → Repo 落库 → 回读)。
    // service_type 白名单 + D10 凭证审查下沉 Repo 层(已在 project_service_repo.rs 单元测试覆盖),
    // 此处锁定 AI handler 的参数装配 / 调用路径正确(防 handler 拼参错位 / 漏调 insert_validated)。
    // ============================================================

    /// 造占位项目(project_services.project_id FK 要求 projects 存在)+ registry fixture。
    async fn setup_services_registry() -> (Arc<Database>, AiToolRegistry) {
        let db = Database::open_in_memory().await.expect("in-memory db");
        let db = Arc::new(db);
        // 占位 project 满足 FK
        let repo = df_storage::crud::ProjectRepo::new(&db);
        repo.insert(ProjectRecord {
            id: "proj-svc-1".to_string(),
            name: "proj-svc-1".to_string(),
            description: String::new(),
            status: ProjectStatus::Planning,
            idea_id: None,
            path: None,
            stack: None,
            created_at: now_millis(),
            updated_at: now_millis(),
        })
        .await
        .unwrap();
        let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
        let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
        (db, registry)
    }

    /// add_project_service 正常路径:合法 service_type + 无凭证 → 落库 + 回读完整记录。
    #[tokio::test]
    async fn test_add_project_service_tool_inserts() {
        let (_db, registry) = setup_services_registry().await;
        let args = serde_json::json!({
            "project_id": "proj-svc-1",
            "name": "主库",
            "service_type": "mysql",
            "environment": "production",
            "endpoint": "localhost:3306",
            "config_json": "{\"pool_size\":10}",
            "remark": "主库"
        });
        let res = registry
            .execute("add_project_service", args)
            .await
            .expect("add_project_service 执行失败");
        assert_eq!(res["project_id"], "proj-svc-1");
        assert_eq!(res["name"], "主库");
        assert_eq!(res["service_type"], "mysql");
        assert_eq!(res["environment"], "production");
        assert_eq!(res["endpoint"], "localhost:3306");
        assert!(res["id"].as_str().is_some(), "应返回生成的 id");
    }

    /// add_project_service D10 安全边界:config_json 含 password → handler 返 Err(不落库)。
    #[tokio::test]
    async fn test_add_project_service_tool_rejects_credential() {
        let (db, registry) = setup_services_registry().await;
        let args = serde_json::json!({
            "project_id": "proj-svc-1",
            "name": "主库",
            "service_type": "mysql",
            "config_json": "{\"password\":\"secret123\"}"
        });
        let err = registry.execute("add_project_service", args).await;
        assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
        // 未落库
        let repo = df_storage::crud::ProjectServiceRepo::new(&db);
        let all = repo.list_by_project("proj-svc-1").await.unwrap();
        assert!(all.is_empty(), "拒绝后不应有记录");
    }

    /// list_project_services 查询:插 2 条(不同环境)→ 不带 environment 返回全部,
    /// 带 environment 仅返回匹配。
    #[tokio::test]
    async fn test_list_project_services_tool_filters_environment() {
        let (_db, registry) = setup_services_registry().await;
        // 插 development + production 各 1 条
        for (st, env) in [("mysql", "development"), ("redis", "production")] {
            registry
                .execute(
                    "add_project_service",
                    serde_json::json!({
                        "project_id": "proj-svc-1",
                        "name": format!("svc-{env}"),
                        "service_type": st,
                        "environment": env,
                    }),
                )
                .await
                .unwrap();
        }
        // 不带 environment:返回全部 2 条
        let all = registry
            .execute(
                "list_project_services",
                serde_json::json!({ "project_id": "proj-svc-1" }),
            )
            .await
            .unwrap();
        assert_eq!(all["total"], 2);
        assert_eq!(all["items"].as_array().unwrap().len(), 2);

        // 仅 development:1 条
        let dev = registry
            .execute(
                "list_project_services",
                serde_json::json!({ "project_id": "proj-svc-1", "environment": "development" }),
            )
            .await
            .unwrap();
        assert_eq!(dev["total"], 1);
        assert_eq!(dev["items"][0]["environment"], "development");
        assert_eq!(dev["items"][0]["service_type"], "mysql");

        // 不存在的环境:0 条
        let staging = registry
            .execute(
                "list_project_services",
                serde_json::json!({ "project_id": "proj-svc-1", "environment": "staging" }),
            )
            .await
            .unwrap();
        assert_eq!(staging["total"], 0);
    }
}
