Files
DevFlow/src-tauri/src/commands/ai/tool_registry.rs
T

2730 lines
146 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! AI 工具注册表构建 + 文件路径校验
//! 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 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源。
///
/// 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)。
///
/// 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"
}
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
///
/// 安全要点
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
/// 解码后再走分段归一化校验。
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
/// 解码后 `..%2f` / `..\` 等变体。
/// 3. 敏感系统目录黑名单(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32),
/// 「信任所有目录」总开关(df-ai-path-auth-bypass)开启时放行。
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 两套黑名单不一致)。
// 总开关开启时跳过(产品拍板:bypass 语义为完全放行,含敏感目录);标志由 audit 层每轮
// 工具执行前从同一 KV 刷新,与本函数无参签名解耦。.. 遍历检查保留在上:路径合法性校验,非权限边界。
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized))
&& !crate::commands::ai::audit::is_blacklist_bypassed()
{
anyhow::bail!("禁止访问敏感系统目录");
}
// 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 三模式共用底层
//
// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间
// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁
// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 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,区间退化为单行替换。
/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中
/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。
/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。
/// - 返回 (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 all_lines: Vec<&str> = content.lines().collect();
let end_hits: Vec<usize> = all_lines
.iter()
.enumerate()
.filter(|(i, line)| i + 1 >= start_line && line.contains(end))
.map(|(i, _)| i + 1)
.collect();
let end_line = match end_hits.len() {
0 => anyhow::bail!(
"anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到",
end, start_line
),
1 => end_hits[0],
_ => {
// 列出前两处命中行原文(截断防超长行刷屏),供 LLM 对照改锚
let ctx = end_hits
.iter()
.take(2)
.map(|&ln| {
let raw = all_lines[ln - 1];
let shown: String = if raw.chars().count() > 80 {
raw.chars().take(80).collect::<String>() + "…"
} else {
raw.to_string()
};
format!("第 {} 行: {}", ln, shown)
})
.collect::<Vec<_>>()
.join("\n");
anyhow::bail!(
"anchor.end 子串「{}」在 start 行(第 {} 行)之后命中 {} 处,定位歧义。前两处:\n{}\n请缩小 end 锚点(加长子串使其唯一)或改用 old_text 模式",
end, start_line, end_hits.len(), ctx
)
}
};
// 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)
}
/// 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,顺序调整)。
///
/// local_proxy(2026-08-06):新增 `local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx` 形参,
/// 该工具集操作本地 4 个工具代理(mysql/ssh/mongo/redis),经 DevFlow 内嵌统一网关
/// (gateway_url 默认 http://127.0.0.1:8899,DF_LOCAL_PROXY_BASE 可覆盖),无 SSRF 面故直接
/// reqwest,不持 db/路径。声明式注册在 tools/local_proxy.rs(11 个工具)。
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,
local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx,
spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx,
) -> 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);
register_local_proxy_tools(&mut registry, local_proxy_ctx);
register_read_skill_tool(&mut registry);
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
register_uclaw_tool(&mut registry, db);
super::lease::register_lease_tool(&mut registry);
registry
}
/// ask_uclaw AI 工具注册(1 个:委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议)。
///
/// 工具职责:LLM 调 ask_uclaw(prompt) 把任务下发给 u-claw(channel 级单链粘性会话),
/// 经 u-relay /ws/commander 路由,agent_progress 块级流式回传 + agent_result 全文交付。
/// 风险 Medium(委托外部 agent 执行,与 spawn_agent 同档)。持 db:Arc<Database>(配置 KV 读)。
/// 声明式注册在 tools/uclaw.rs。
fn register_uclaw_tool(registry: &mut AiToolRegistry, db: &Arc<Database>) {
super::tools::uclaw::register(registry, db);
}
/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。
///
/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理,
/// 子代理在纯内存子会话跑独立 agentic 循环(agentic/sub_agent.rs),报告经 tool_result
/// 返回值交付。风险 Medium(派发需用户审批,与 create_task 同档)。
///
/// ctx 绕 AppState 自引用(同 GetAppConfigCtx 先例):session/conv_states/llm_concurrency/
/// db/sub_agents 全 Arc 字段,由 state.rs init 在 build registry 前提前构建注入,
/// AppState struct 字段复用同一 Arc。工具注册表自身经进程级 OnceLock
/// (init_global_tool_registry)在 state.rs init 构建完成后回注,供白名单钳制查询。
fn register_spawn_agent_tool(
registry: &mut AiToolRegistry,
ctx: super::tools::spawn_agent::SpawnAgentCtx,
) {
super::tools::spawn_agent::register(registry, ctx);
}
/// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。
///
/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文,
/// 与 / 技能注入链共用 skills.rs 进程内缓存(同一真相源)。未命中返回明确错误文案。
/// 风险:Low(纯只读)。声明式注册在 tools/read_skill.rs。
fn register_read_skill_tool(registry: &mut AiToolRegistry) {
super::tools::read_skill::register(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);
}
/// local_proxy 工具代理注册(11 个:mysql/ssh/mongo/redis 本地代理)— thin 委托。
///
/// 经 DevFlow 内嵌统一网关(local_proxy_gateway:127.0.0.1:8899)路由到本地 4 个 HTTP 代理
/// (mysql→3307 / ssh→3308 / mongo→3309 / redis→3310),固定 gateway_url 无 SSRF 面,
/// 声明式注册在 tools/local_proxy.rs。
///
/// risk 口径对齐 http_request:只读查询(Low)/ 数据写入(High)/ 轻量写(Medium)。
/// declare_tool! 单捕获:仅需 gateway_url,包进 LocalProxyCtx struct(Clone 廉价)。
fn register_local_proxy_tools(
registry: &mut AiToolRegistry,
ctx: super::tools::local_proxy::LocalProxyCtx,
) {
super::tools::local_proxy::register(registry, ctx);
}
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
/// 例外:run_workflow handler 防御返回 Err,不持 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。
///
/// 抽自原 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_knowledge_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 工具注册(3 个:knowledge_search 检索 + knowledge_insert 沉淀 +
/// knowledge_promote 显式转正)——持 db:Arc<Database>。
///
/// 打通 aichat 对话侧与知识库:此前 KnowledgeRepo 的检索/写入仅有 IPC 命令与 MCP 入口,
/// LLM 工具注册表无对应工具,agent 对话中既查不了过往经验也无法沉淀要点(知识库与记忆
/// 走查 2026-09-29 缺口 4)。insert 固定写 status='candidate'(LLM 写入不自动发布,
/// 与自动提炼同信任级),promote 补齐对话内显式转正通道(candidate→published 单向,
/// 与默认关闭的自动晋升互不影响),handler 细节见 tools/knowledge.rs。
///
/// 风险:search/insert 为 Low(检索只读;插入为本地库追加写,无覆盖无删除,落候选不进
/// 检索面);promote 为 Medium(改既有条目状态进检索面,单向候选转正可 UI 归档修正)。
fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
super::tools::knowledge::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 抽出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_entry(&name, NoiseDirTier::AiContext) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文。
if skip_noise && is_noise_file(&name) {
continue;
}
// 用 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_entry(&name, NoiseDirTier::AiContext)) {
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 到通用风险提示。
///
/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度),
/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
ensure_tool_meta_seeded();
df_ai::ai_tools::approval_hint(name)
}
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
///
/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。
/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
ensure_tool_meta_seeded();
df_ai::ai_tools::display_verb(name)
}
/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
///
/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作;
/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等),
/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。
/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。
#[cfg(test)]
pub(crate) fn ensure_tool_meta_seeded() {
static SEED: std::sync::Once = std::sync::Once::new();
SEED.call_once(|| {
if !df_ai::ai_tools::meta_table_is_empty() {
return;
}
let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime");
rt.block_on(async {
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let _ = build_ai_tool_registry(
&db,
&allowed_dirs,
PathBuf::from(""),
make_test_get_app_config_ctx(&db),
make_test_local_proxy_ctx(),
make_test_spawn_agent_ctx(&db),
);
});
});
}
/// 生产恒空(注册先于查询,无种子需求)。
#[cfg(not(test))]
pub(crate) fn ensure_tool_meta_seeded() {}
/// 噪音目录判定档位:不同消费面"哪些目录算噪音"的口径不同,差异用档位在此显式表达,
/// 各消费方不再自持名单(DRY #4:全仓唯一名单入口)。
pub(crate) enum NoiseDirTier {
/// AI 工具上下文(list_directory/grep 递归):全量名单,构建产物/依赖/缓存/IDE 元数据/回收站不进上下文
AiContext,
/// 前端文件树/工程扫描:全量名单放行 `build` 与 `venv`(文件树面向用户浏览要见构建产物;
/// venv 历史上未过滤,行为保持)。点开头目录(.git/.vite 等)由调用方的隐藏文件过滤兜住
FileTree,
/// 技能扫描递归剪枝:最小集(仅依赖/VCS)。扩大名单属行为变更(build/ 下若有 SKILL.md 会漏扫)
SkillScan,
}
/// 判断是否为不应深入递归/展示的噪音条目(目录/文件名皆可传入,构建产物/依赖/缓存等)。
/// 全仓唯一名单入口。命中判定短路返回,技能扫描等热路径可放心逐条目调用。
pub(crate) fn is_noise_entry(name: &str, tier: NoiseDirTier) -> bool {
const FULL: &[&str] = &[
".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站,不污染 list_directory
];
match tier {
NoiseDirTier::AiContext => FULL.contains(&name),
// 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持)
NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"),
// 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致
NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"),
}
}
/// 判断是否为不应返回给 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 工具底层
//
// 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_entry)/噪音文件(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_entry(&file_name, NoiseDirTier::AiContext) {
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;
}
// 文件处理提取为 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 循环体,供 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(())
})
}
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。
// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。
#[cfg(test)]
use std::sync::atomic::{AtomicU64, AtomicUsize};
#[cfg(test)]
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 不执行。
#[cfg(test)]
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
crate::commands::ai::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),
}
}
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
#[cfg(test)]
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
gateway_url: "http://127.0.0.1:8899".to_string(),
}
}
/// build_ai_tool_registry 第 6 参(spawn_agent 派发 ctx),测试调用共用。
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
#[cfg(test)]
fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
session: Arc::new(tokio::sync::Mutex::new(crate::commands::ai::AiSession::new())),
conv_states: Arc::new(
crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
),
llm_concurrency: LlmConcurrency::new(3, 2),
db: db.clone(),
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ============================================================
// 工具注册基线测试(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 应注册恰好 78 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),且工具名集合稳定。
///
/// 用 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), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// 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)
// 修正 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。
// local_proxy(2026-08-06): local_proxy 层 0→11(新增 mysql/ssh/mongo/redis 本地工具代理
// 集成:list_proxy_connections/mysql_query/mysql_execute/ssh_exec/mongo_find/mongo_insert/
// mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs,
// build_ai_tool_registry 加第 5 参 LocalProxyCtx)。
// 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// 工程模块写工具(2026-08-08): data 层 36→42(新增 create_module/update_module/delete_module +
// list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs,
// 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。
// 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// 知识库工具(2026-09-29): data 层 42→44(新增 knowledge_search/knowledge_insert,
// 打通对话侧与知识库双向检索/沉淀;insert 落 candidate 不自动发布,均为 Low)。
// 75 = 44 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
// read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。
// 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。
// spawn_agent(2026-10-01): spawn_agent 层 0→1(派发子代理隔离上下文执行专项任务,
// Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。
// SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。
// 78 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent。
// ask_uclaw(2026-10-05): uclaw 层 0→1(委托 u-claw 常驻 Claude Code 网关,
// u-relay commander 协议,Medium 审批档同 spawn_agent;配置 KV 三键读,
// 声明式注册 tools/uclaw.rs)。
// 79 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw。
// query_leases(2026-10-05): lease 层 0→1(W-h2 感知租约查询,只读 Low;
// W-h1 FileLeaseRegistry 兄弟感知入口,写类文件工具跨会话互斥的可视面,
// 注册在 lease.rs register_lease_tool)。
// 80 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease。
assert_eq!(
registry.len(),
80,
"工具总数应为 80(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent + 1 uclaw + 1 lease),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 45 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具 + 知识库检索/沉淀/转正
// file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (44) ──
"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",
// 工程模块写工具(2026-08-08):create/update/delete 工程 + 依赖关系管理
"create_module", "update_module", "delete_module",
"list_module_dependencies", "add_module_dependency", "remove_module_dependency",
// Git 只读工具
"git_status", "git_diff", "git_log",
// Git 写工具
"git_commit", "git_branch", "git_merge",
// 知识库工具(2026-09-29):检索已发布知识 + 沉淀候选知识,打通对话侧与知识库
"knowledge_search", "knowledge_insert",
// 候选知识显式转正(2026-10-01):candidate→published 单向,对话内审核通道
"knowledge_promote",
// ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
// download_file 新增跨平台 URL→文件流式下载;diff_files 新增任意两文件对比)
"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",
// diff_files(任意两文件内容对比,只读 Low,补 git_diff 仅仓库内缺口)
"diff_files",
// ── 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",
// ── local_proxy 层 (11) ──(本地 4 个工具代理:mysql/ssh/mongo/redis,
// 只读查询 Low / 数据写 High / 轻量写 Medium,声明式注册 tools/local_proxy.rs)
"list_proxy_connections", "mysql_query", "mysql_execute",
"ssh_exec", "mongo_find", "mongo_insert",
"mongo_update", "mongo_delete", "redis_get",
"redis_set", "redis_del",
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
"read_skill",
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
"spawn_agent",
// ── uclaw 层 (1) ──(委托 u-claw 常驻 Claude Code 网关,u-relay commander 协议,
// Medium 审批档,声明式注册 tools/uclaw.rs)
"ask_uclaw",
// ── lease 层 (1) ──(W-h2 文件租约查询,只读 Low,注册在 lease.rs)
"query_leases",
];
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_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_entry(".git", NoiseDirTier::AiContext));
assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
assert!(is_noise_entry("target", NoiseDirTier::AiContext));
assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
assert!(is_noise_entry("build", NoiseDirTier::AiContext));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
}
/// is_noise_entry 纯函数:普通目录不命中
#[test]
fn test_is_noise_entry_normal_dirs_not_matched() {
assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
assert!(!is_noise_entry("", NoiseDirTier::AiContext));
}
/// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_entry_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext));
}
/// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项
#[test]
fn test_is_noise_entry_tier_deltas() {
// FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致
assert!(!is_noise_entry("build", NoiseDirTier::FileTree));
assert!(!is_noise_entry("venv", NoiseDirTier::FileTree));
assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree));
assert!(is_noise_entry("target", NoiseDirTier::FileTree));
assert!(is_noise_entry("dist", NoiseDirTier::FileTree));
assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree));
assert!(!is_noise_entry("src", NoiseDirTier::FileTree));
// SkillScan:最小集,仅依赖/VCS
assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan));
assert!(is_noise_entry(".git", NoiseDirTier::SkillScan));
assert!(!is_noise_entry("target", NoiseDirTier::SkillScan));
assert!(!is_noise_entry("build", NoiseDirTier::SkillScan));
}
/// 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), make_test_local_proxy_ctx(), make_test_spawn_agent_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["truncated"], 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), make_test_local_proxy_ctx(), make_test_spawn_agent_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["truncated"], 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), make_test_local_proxy_ctx(), make_test_spawn_agent_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), make_test_local_proxy_ctx(), make_test_spawn_agent_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 三模式共用底层测试
// 纯函数 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: 子串匹配含行内出现(如 println!("{}") 内的 })——
/// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」)
#[test]
fn test_resolve_anchor_substring_matches_inside_line() {
// end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行)
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\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 子串多处命中 → Err 列前两处命中行 + 修复提示
/// (契约变更:旧实现静默取第一个命中行,实测 ×2 LLM 短 end 锚点多处命中替换错位,
/// 根修改为唯一性校验——多处命中报错,提示缩小 end 锚点或改用 old_text)
#[test]
fn test_resolve_anchor_end_multi_hit_rejected() {
// 第 2、4 行都含 "}",多处命中 → Err
let content = "START\n}\nx\n}";
let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("命中 2 处"), "应报多处命中数,实际: {msg}");
assert!(msg.contains("第 2 行"), "应列第一处命中行,实际: {msg}");
assert!(msg.contains("第 4 行"), "应列第二处命中行,实际: {msg}");
assert!(msg.contains("缩小 end 锚点"), "应含缩小锚点提示,实际: {msg}");
assert!(msg.contains("old_text"), "应含改用 old_text 建议,实际: {msg}");
}
/// 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 工具测试
//
// 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,
archived: false,
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), make_test_local_proxy_ctx(), make_test_spawn_agent_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);
}
// ============================================================
// DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。
//
// 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录):
// path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action /
// tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。
// 任何一维漂移此测试立即红。
// ============================================================
#[tokio::test]
async fn test_tool_meta_parity_with_legacy_tables() {
// 真实构建一次注册表(条目与全局表一致性断言用)
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
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), make_test_local_proxy_ctx(),
make_test_spawn_agent_ctx(&db),
);
ensure_tool_meta_seeded();
// ── 旧 path_auth::extract_file_tool_paths 表(name → 路径参数键)──
let legacy_path_args: &[(&str, &[&str])] = &[
("rename_file", &["from", "to"]),
("read_file", &["path"]), ("write_file", &["path"]), ("list_directory", &["path"]),
("patch_file", &["path"]), ("file_info", &["path"]), ("append_file", &["path"]),
("delete_file", &["path"]), ("search_files", &["path"]), ("grep", &["path"]),
];
for (name, keys) in legacy_path_args {
assert_eq!(df_ai::ai_tools::path_arg_keys(name), *keys, "path_args 漂移: {}", name);
}
// 旧 default:非文件工具返回空(不预校验)
for name in ["run_command", "http_request", "create_task", "git_status", "read_symbol", "download_file", "diff_files"] {
assert!(df_ai::ai_tools::path_arg_keys(name).is_empty(), "非文件工具不应有 path_args: {}", name);
}
// ── 旧 cache.rs READONLY_CACHE_TOOLS 白名单 ──
for name in ["read_file", "read_symbol", "list_directory", "search_files", "grep"] {
assert!(df_ai::ai_tools::is_readonly(name), "readonly 漂移: {}", name);
}
for name in ["write_file", "patch_file", "run_command", "delete_file", "rename_file", "append_file", "file_info", "http_request", "git_commit", "mysql_query"] {
assert!(!df_ai::ai_tools::is_readonly(name), "非只读工具误标 readonly: {}", name);
}
// ── 旧 cache.rs 目录级 key / 目录专用警告判据(旧匹配 list_directory;幽灵 list_dir 已删)──
assert!(df_ai::ai_tools::cache_path_key_only("list_directory"));
for name in ["read_file", "grep", "search_files", "list_dir"] {
assert!(!df_ai::ai_tools::cache_path_key_only(name), "cache_path_key 误标: {}", name);
}
// ── 旧 path_auth search_files 盲搜硬拒 ──
assert!(df_ai::ai_tools::denies_unauthorized("search_files"));
for name in ["grep", "read_file", "list_directory", "run_command"] {
assert!(!df_ai::ai_tools::denies_unauthorized(name), "deny_unauthorized 误标: {}", name);
}
// ── 旧 data_change::data_change_for_tool 表(name, entity, action)──
let legacy_entity_action: &[(&str, &str, &str)] = &[
("create_project", "project", "create"),
("create_task", "task", "create"),
("create_idea", "idea", "create"),
("update_project", "project", "update"),
("update_task", "task", "update"),
("update_idea", "idea", "update"),
("advance_task", "task", "update"),
("delete_project", "project", "delete"),
("purge_project", "project", "delete"),
("delete_task", "task", "delete"),
("delete_idea", "idea", "delete"),
("restore_project", "project", "update"),
("bind_directory", "project", "update"),
("write_file", "file", "update"),
("patch_file", "file", "update"),
];
for (name, entity, action) in legacy_entity_action {
assert_eq!(
df_ai::ai_tools::entity_action(name),
Some((*entity, *action)),
"entity_action 漂移: {}", name
);
}
for name in ["list_tasks", "run_workflow", "read_file", "git_status"] {
assert_eq!(df_ai::ai_tools::entity_action(name), None, "entity_action 应为 None: {}", name);
}
// ── 旧 tool_registry display_hint_for_tool 表(name, 模板, 键)──
let legacy_approval: &[(&str, &str, &[&str])] = &[
("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"]),
("advance_task", "推进任务状态:{} → {}", &["id", "target_status"]),
("run_workflow", "触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
("http_request", "HTTP 请求:{} {}", &["method", "url"]),
];
for (name, template, keys) in legacy_approval {
assert_eq!(
df_ai::ai_tools::approval_hint(name),
Some((*template, *keys)),
"approval_hint 漂移: {}", name
);
}
assert_eq!(df_ai::ai_tools::approval_hint("read_file"), None);
// ── 旧 tool_registry tool_display_hint 表(advance_task 刻意不在表内)──
let legacy_verb: &[(&str, &str)] = &[
("create_task", "创建任务"), ("write_file", "写入文件"), ("read_file", "读取文件"),
("list_directory", "列出目录"), ("search_files", "搜索文件"), ("grep", "搜索代码内容"),
("create_project", "创建项目"), ("update_project", "修改项目"), ("delete_project", "删除项目"),
("restore_project", "恢复项目"), ("purge_project", "永久删除项目"), ("bind_directory", "绑定目录"),
("create_idea", "捕获灵感"), ("update_task", "修改任务"), ("run_workflow", "触发工作流"),
("delete_task", "删除任务"), ("update_idea", "修改灵感"), ("delete_idea", "删除灵感"),
("patch_file", "修改文件"), ("append_file", "追加写入"), ("delete_file", "删除文件"),
("rename_file", "重命名/移动"), ("http_request", "发起 HTTP 请求"),
];
for (name, verb) in legacy_verb {
assert_eq!(df_ai::ai_tools::display_verb(name), Some(*verb), "display_verb 漂移: {}", name);
}
assert_eq!(df_ai::ai_tools::display_verb("advance_task"), None, "advance_task 无轻量标签(走模板),与旧表一致");
assert_eq!(df_ai::ai_tools::display_verb("read_symbol"), None);
// ── 旧 helpers::infer_goal_from_tool_calls(样例 args 逐字比对输出)──
let goal_of = |name: &str, args: Option<serde_json::Value>| -> Option<String> {
df_ai::ai_tools::goal_of(name).and_then(|g| df_ai::ai_tools::goal_text(g, args.as_ref()))
};
// FileName 风格:按旧实现取末个 / 段(纯反斜杠路径旧 or_else 分支恒死,保持原样)
assert_eq!(goal_of("patch_file", Some(serde_json::json!({"path": "a/b/c.rs"}))), Some("修改 c.rs".to_string()));
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "docs/dry.md"}))), Some("修改 dry.md".to_string()));
assert_eq!(goal_of("append_file", Some(serde_json::json!({"path": "log.txt"}))), Some("修改 log.txt".to_string()));
assert_eq!(goal_of("read_file", Some(serde_json::json!({"path": "src/lib.rs"}))), Some("查看 lib.rs".to_string()));
assert_eq!(goal_of("file_info", Some(serde_json::json!({"path": "Cargo.toml"}))), Some("查看 Cargo.toml".to_string()));
assert_eq!(goal_of("delete_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
assert_eq!(goal_of("rename_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
// 反斜杠路径:旧实现 rsplit('/') 取整串(or_else 恒不触发),新实现保持同值
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "x\\y\\n.txt"}))), Some("修改 x\\y\\n.txt".to_string()));
// Raw 风格
assert_eq!(goal_of("list_directory", Some(serde_json::json!({"path": "/tmp/logs"}))), Some("浏览 /tmp/logs".to_string()));
assert_eq!(goal_of("create_project", Some(serde_json::json!({"name": "moyu"}))), Some("创建项目 moyu".to_string()));
assert_eq!(goal_of("create_idea", Some(serde_json::json!({"title": "灵感一闪"}))), Some("捕获灵感 灵感一闪".to_string()));
// Truncate 风格 + 键回退 + 缺参兜底
let long_cmd = "abcdefghijklmnopqrstuvwxyz0123456789";
assert_eq!(goal_of("run_command", Some(serde_json::json!({"command": long_cmd}))), Some(format!("执行 {}", &long_cmd[..25])));
assert_eq!(goal_of("search_files", Some(serde_json::json!({"pattern": long_cmd}))), Some(format!("搜索 {}", &long_cmd[..25])));
assert_eq!(goal_of("grep", Some(serde_json::json!({"query": "todo"}))), Some("搜索 todo".to_string()));
assert_eq!(goal_of("search_files", Some(serde_json::json!({}))), Some("搜索文件".to_string()));
assert_eq!(goal_of("run_command", Some(serde_json::json!({}))), Some("执行命令".to_string()));
// 无键工具恒返兜底(旧 run_workflow 无条件 Some)
assert_eq!(goal_of("run_workflow", Some(serde_json::json!({"task_id": "t1"}))), Some("执行工作流".to_string()));
assert_eq!(goal_of("run_workflow", None), Some("执行工作流".to_string()));
// 键缺失且无兜底返 None(旧表 _ => None 分支)
assert_eq!(goal_of("read_file", Some(serde_json::json!({}))), None);
assert_eq!(goal_of("update_task", Some(serde_json::json!({"id": "t1"}))), None);
// ── 旧 helpers::tool_call_signature(样例 args 逐字比对输出)──
let sig = |name: &str, args: serde_json::Value| df_ai::ai_tools::tool_signature(name, &args);
assert_eq!(sig("grep", serde_json::json!({"pattern": "MAX", "path": "src", "case_sensitive": true})), "grep:pattern=MAX,path=src");
assert_eq!(sig("search_files", serde_json::json!({"query": "TODO", "glob": "**/*.rs"})), "search_files:pattern=TODO,path=**/*.rs");
assert_eq!(sig("read_file", serde_json::json!({"path": "big.log", "offset": 50, "limit": 100})), "read_file:path=big.log,offset=50,limit=100");
assert_eq!(sig("read_file", serde_json::json!({})), "read_file:path=,offset=,limit=");
assert_eq!(sig("run_command", serde_json::json!({"command": "ls -la", "timeout": 5000})), "run_command:command=ls -la");
assert_eq!(sig("list_directory", serde_json::json!({"path": "/tmp"})), "list_directory:path=/tmp");
assert_eq!(sig("http_request", serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}})), "http_request:url=https://x.io,method=GET");
assert_eq!(sig("fetch_url", serde_json::json!({"url": "https://y.io", "raw": false})), "fetch_url:url=https://y.io");
// 未注册/未声明 sig 走旧 default:工具名 + 全参 JSON
let unknown = sig("custom_tool", serde_json::json!({"x": 1, "y": "z"}));
assert!(unknown.starts_with("custom_tool:name=custom_tool,args="), "未知工具应走全参兜底: {}", unknown);
let nosig = sig("write_file", serde_json::json!({"path": "a"}));
assert!(nosig.starts_with("write_file:name=write_file,args="), "未声明 sig 的已注册工具应走全参兜底: {}", nosig);
// ── 注册表条目与全局表一致(条目存储元数据 + 查询函数双面)──
for name in registry.tool_names() {
let entry = registry.meta(&name).expect("条目应有元数据");
assert_eq!(Some(entry), df_ai::ai_tools::tool_meta(&name), "条目与全局表漂移: {}", name);
}
// 抽查条目维度:read_file 只读 + path 路径键;advance_task 有 entity 无轻量标签
let rf = registry.meta("read_file").unwrap();
assert!(rf.readonly && rf.path_args == ["path"]);
let at = registry.meta("advance_task").unwrap();
assert_eq!(at.entity_action, Some(("task", "update")));
assert_eq!(at.display_verb, None);
}
}