Files
DevFlow/src-tauri/src/commands/ai/tool_registry.rs
绝尘 d2cada97cd 重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)
L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
2026-06-22 00:04:14 +08:00

3058 lines
167 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 工具注册表构建 + 文件路径校验
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, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
use df_types::types::new_id;
use crate::commands::now_millis;
use crate::state::AllowedDirs;
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
/// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50;
/// run_command 默认超时。LLM 可在 args timeout_secs 覆盖此默认值。
/// 提取为常量便于在超时标注处引用同一来源F-260616-04
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
/// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
///
/// AE-2025-03路径 Baudit.rs 挂起审批前预读旧文件复用此函数生成 diff
/// 供前端审批卡即时预览write_file 审批不再只看裸 content。故 pub(crate)。
///
/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB
/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码
/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为
/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。
/// (patch_file 调用方传入的 old/new 是局部替换,通常远小于全文,正常路径仍走 LCS)
pub(crate) fn generate_diff(old: &str, new: &str) -> String {
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let (n, m) = (a.len(), b.len());
// 超长输入降级:两文件均>1000 行跳过 LCS DP(防 200MB+ 内存峰值),
// 退化为朴素全删全增 diff(配 300 行截断),足够审批卡看出"大范围改动"语义。
const LCS_MAX_LINES: usize = 1000;
if n > LCS_MAX_LINES && m > LCS_MAX_LINES {
let mut out = String::new();
let mut changes = 0usize;
for line in &a { out.push_str("-"); out.push_str(line); out.push('\n'); changes += 1; }
for line in &b { out.push_str("+"); out.push_str(line); out.push('\n'); changes += 1; }
if changes > 300 {
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
return format!(
"{}\n... (输入过长({}/{} 行)已跳过 LCS 退化对比,diff 已截断,共 {} 处变更行)",
kept, n, m, changes
);
}
return out.trim_end_matches('\n').to_string() + "\n";
}
// LCS 动态规划表usize 即可;已由 LCS_MAX_LINES 挡住超大输入,行数有限)
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if a[i] == b[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
// 回溯:相同行原样输出(空格前缀),旧独有加 '-',新独有加 '+'
let mut out = String::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
while i < n && j < m {
if a[i] == b[j] {
out.push_str(" "); out.push_str(a[i]); out.push('\n');
i += 1; j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
out.push_str("-"); out.push_str(a[i]); out.push('\n');
i += 1; changes += 1;
} else {
out.push_str("+"); out.push_str(b[j]); out.push('\n');
j += 1; changes += 1;
}
}
while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }
// 极端情况:差异行过多(>300截断防 LLM context 爆炸
if changes > 300 {
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
return format!("{}\n... (diff 已截断,共 {} 处变更行)", kept, changes);
}
// 末尾多余换行清理(保持单尾换行)
out.trim_end_matches('\n').to_string() + "\n"
}
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
///
/// 安全要点(BUG-260617-03):
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
/// 解码后再走分段归一化校验。
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
/// 解码后 `..%2f` / `..\` 等变体。
/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。
fn validate_path(path: &str) -> anyhow::Result<()> {
// 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串)
// percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。
use percent_encoding::percent_decode_str;
let decoded = percent_decode_str(path).decode_utf8_lossy().into_owned();
// 2) 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
let normalized = decoded.replace('/', "\\");
let lower = normalized.to_lowercase();
// 3) 分段归一化检查:按分隔符切分,任何一段 == ".." 视为路径遍历。
// 比纯 contains("..") 更精准(不误伤 my..file 这类合法名),且能识别解码后的 `..`。
let has_traversal = lower
.split(|c| c == '\\' || c == '/')
.any(|seg| seg == "..");
if has_traversal {
anyhow::bail!("禁止路径遍历 (..)");
}
// 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心):
// 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup,
// 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
anyhow::bail!("禁止访问敏感系统目录");
}
// F-260620: AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
// 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
Ok(())
}
/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
fn truncate_output(s: &str, max: usize) -> (String, bool) {
if s.len() <= max {
return (s.to_string(), false);
}
let end = s.len();
let mut start = end.saturating_sub(max);
// 推进到 char 边界,避免从多字节 UTF-8 中间切开
while start < end && !s.is_char_boundary(start) {
start += 1;
}
let tail = &s[start..];
(
format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
true,
)
}
// ============================================================
// patch_file 三模式共用底层F-260617-01
//
// 三模式互斥old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
// 提取为纯函数便于直接单测(不依赖 async/fs/锁handler 闭包内复用。
// ============================================================
/// 按行号区间替换:将 content 第 [start, end] 行1-based, 含首尾)替换为 new_text。
///
/// - 与 search_files 一致用 `\n` 切分;末尾尾随 `\n` 保留(避免误删文件末换行)。
/// - 行号 1-based含首尾replace_lines(2,3) 替换第 2、3 两行。
/// - 越界start<1 或 end>lines.len() 或 start>end返 Err。
/// - new_text 可为多行(含 `\n`),整体作为替换块原样插入。
pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text: &str) -> anyhow::Result<String> {
if start < 1 {
anyhow::bail!("start 行号必须 >= 1got {}", 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 任一找不到返 Errstart 行号 > end 行号返 Err即 end 必须在 start 之后或同行)。
/// - 同一行同时命中 start/endstart==end区间退化为单行替换。
/// - 返回 (start_line, end_line)1-based 含首尾)。
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
if start.is_empty() {
anyhow::bail!("anchor.start 不能为空");
}
if end.is_empty() {
anyhow::bail!("anchor.end 不能为空");
}
let mut start_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
if line.contains(start) {
start_line = Some(i + 1);
break;
}
}
let start_line = start_line
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
let mut end_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
let lineno = i + 1;
if lineno < start_line {
continue;
}
if line.contains(end) {
end_line = Some(lineno);
break;
}
}
let end_line = end_line
.ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?;
// start_line <= end_line 已由搜索顺序保证,此处断言兜底
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
Ok((start_line, end_line))
}
/// 全局文件锁表每个路径一把互斥锁L1 防护,防同文件并发读写冲突)
///
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
/// 单用户桌面应用Mutex 够用且简单(~15 行),不需要全局文件队列。
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
LazyLock::new(|| TokioMutex::new(HashMap::new()));
/// 计算文件指纹 `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 维度作弱保护。
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())
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 阶段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 兜底)。
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)
}
}
}
/// 解析文件工具路径:相对路径锚定 workspace_root禁止越出项目目录
///
/// 双层校验:
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
///
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
fn resolve_workspace_path_with_allowed(
path: &str,
allowed: &AllowedDirs,
) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, allowed)
}
/// AE-2025-04mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
///
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
}
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
root.join(path)
};
// 词法层:防明显越界(不存在路径的兜底)。
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
if !allowed.is_authorized(&resolved) {
anyhow::bail!("路径不在授权目录内: {}", path);
}
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
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 返回结构)
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 工具不涉及文件路径,不传白名单。
pub fn build_ai_tool_registry(
db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) -> AiToolRegistry {
let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db);
register_file_tools(&mut registry, allowed_dirs);
register_http_tools(&mut registry);
registry
}
/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
///
/// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。
// (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。)
// 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。
fn register_http_tools(registry: &mut AiToolRegistry) {
registry.register(
"http_request", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼
{
let mut props = serde_json::Map::new();
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
},
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
super::http::execute_http_request(args).await
})),
);
}
/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
///
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
/// ai_approve → run_workflow_inner 另走完整 State 路径。
///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
///
/// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数:
/// - register_project_tools(8):list/update/create/bind/delete/restore/purge/get_count
/// - register_task_tools(6):list/create/update/advance/delete/get_count
/// - register_workflow_tools(1):run_workflow(High,单独,handler 防御兜底)
/// - register_idea_tools(2):list/create
/// - register_trash_tools(1):list_trash
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致,
/// 行为零变更(基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
register_project_tools(registry, db);
register_task_tools(registry, db);
register_workflow_tools(registry);
register_idea_tools(registry, db);
register_trash_tools(registry, db);
}
/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items项目列表、total总量、has_more是否有更多页。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
registry.register(
"update_project", "更新项目的指定字段name/status/description/path/stack需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("projects", field) {
anyhow::bail!("不允许更新字段 '{}'", field);
}
let repo = df_storage::crud::ProjectRepo::new(&db);
repo.update_field(id, field, value).await?;
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
})
})},
);
registry.register(
"create_project", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory",
df_ai::ai_tools::object_schema(vec![
("name", "string", true), ("description", "string", false),
("path", "string", false), ("stack", "string", false),
]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
let description = args["description"].as_str().unwrap_or("");
let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(),
status: "planning".to_string(), idea_id: None,
path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
// path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
if let Some(path) = path_opt {
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
} else {
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
}
})
})},
);
registry.register(
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::ProjectRepo::new(&db);
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
})
})},
);
registry.register(
"delete_project", "删除项目(移入回收站,可恢复。永久删除用 purge_project",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
);
registry.register(
"restore_project", "从回收站恢复已删除项目",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let restored = repo.restore(id).await?;
Ok(serde_json::json!({ "restored": restored, "id": id }))
})
})},
);
registry.register(
"purge_project", "永久删除项目及关联数据(不可恢复)",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let purged = repo.purge_with_descendants(id).await?;
Ok(serde_json::json!({ "purged": purged, "id": id }))
})
})},
);
// ── 探总量工具 (Low risk, F-260616-08) ──
registry.register(
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?;
Ok(serde_json::json!({ "total": items.len() }))
})
})},
);
}
/// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
/// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_tasks", "列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status == status);
}
let total = tasks.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
registry.register(
"create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)",
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false), ("idea_id", "string", false)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let record = TaskRecord {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0,
output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
})
})},
);
registry.register(
"update_task", "更新任务的指定字段title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意status 改动须走 advance_task 工具状态机推进7 态 todo/in_progress/in_review/testing/blocked/done/cancelled按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
// F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进),
// 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
if field == "status" {
anyhow::bail!(
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
);
}
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("tasks", field) {
anyhow::bail!("不允许更新字段 '{}'", field);
}
let repo = df_storage::crud::TaskRepo::new(&db);
repo.update_field(id, field, value).await?;
// UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord,
// 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true }))
})
})},
);
// F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
// D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
// df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
// commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
// 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
registry.register(
"advance_task", "推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
// 与 commands::task::advance_task IPC 同源,避免双轨。
let repo = df_storage::crud::TaskRepo::new(&db);
let updated = df_nodes::task_advance_node::advance_task_atomic(
&repo, id, target_status,
).await?;
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
Ok(serde_json::to_value(&updated)?)
})
})},
);
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
registry.register(
"delete_task", "删除任务(移入回收站,可恢复)",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
);
// ── 探总量工具 (Low risk, F-260616-08) ──
registry.register(
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::TaskRepo::new(&db);
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
Ok(serde_json::json!({ "total": tasks.len() }))
})
})},
);
}
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_workflow_tools(registry: &mut AiToolRegistry) {
// F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。
// 实施路径文档 §三 列为阶段3 必做项(tool_registry.rs 此前无此工具连空壳都没有)。
// 描述明确按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。
//
// handler 约束说明(B-260617-01 更新):run_workflow 真正执行需要 AppHandle(转发 workflow-event
// 到前端) + AppState(registry 构建 DAG / event_bus 订阅 / workflows Repo 落库 /
// workflow_state_registry 注销),这些在 tool handler(仅持 db: Arc<Database>)中无法构造。
//
// **执行路径(单一)**:run_workflow 是 High risk → 始终经 audit.rs:process_tool_calls 进 pending
// → ai_approve 审批 → commands.rs ai_approve 内识别 run_workflow 分支调
// execute_run_workflow_for_tool(mod.rs)→ workflow.rs::run_workflow_inner(持完整 State)真正执行。
// 故本 handler 经 ai_tools.execute 调用的路径在正常流程下不可达(dead code 防御):
// 仅当未来出现"不经 ai_approve 直接 execute run_workflow"的异常调用方时,本 Err 作为防御兜底
// 返回明确错误,而非静默 panic。LLM 误判重试同 tool_call 时,High risk 去重缓存
// (audit.rs:find_cached_high_risk_result)会复用旧 tool_result 跳过审批,断重试循环。
registry.register(
"run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
RiskLevel::High,
// CR-52: 删死代码 `let _db = db.clone();`(完全未用 — 防御 handler 仅返回 Err,
// 不持 db;run_workflow 真正执行经 ai_approve → execute_run_workflow_for_tool
// → workflow.rs::run_workflow_inner,此处 handler 在正常流程不可达)。
{ Box::new(move |args: serde_json::Value| {
Box::pin(async move {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
Err(anyhow::anyhow!(
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
勿盲目重试同调用(task_id={}, target_status={});\
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
task_id, target_status
))
})
})},
);
}
/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::IdeaRepo::new(&db);
let items = repo.list_all().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
registry.register(
"create_idea", "捕获一个新灵感",
df_ai::ai_tools::object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
("priority", "integer", false),
]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::IdeaRepo::new(&db);
let record = IdeaRecord {
id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
related_ids: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
})
})},
);
}
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_deleted().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
})
})},
);
}
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
///
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
/// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。
fn register_file_tools(
registry: &mut AiToolRegistry,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) {
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
// 故该工具闭包不捕获 allowed_dirs,维持原行为。
let allowed_dirs = allowed_dirs.clone();
// ── 文件系统 ──
registry.register(
"read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组限50条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
let metadata = file.metadata().await
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
// TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
// 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
let file_hash = compute_file_hash(&metadata);
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
let mut content = String::new();
if let Err(e) = file.read_to_string(&mut content).await {
if e.kind() == std::io::ErrorKind::InvalidData {
return Ok(serde_json::json!({
"path": path, "content": null, "binary": true,
"size": metadata.len(), "file_hash": file_hash,
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
}));
}
anyhow::bail!("读取文件失败: {}", e);
}
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
if let Some(search) = args["search"].as_str() {
const SEARCH_MAX: usize = 50;
let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
// 先收集全部匹配行用于 total 计数,再做 skip/take 分页
let mut all_matches: Vec<serde_json::Value> = Vec::new();
for (idx, line) in content.lines().enumerate() {
if line.contains(search) {
all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
}
}
let total = all_matches.len();
let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
let has_more = (search_offset + page_matches.len()) < total;
return Ok(serde_json::json!({
"path": path, "size": metadata.len(), "file_hash": file_hash,
"search": search,
"matches": page_matches,
"total": total,
"has_more": has_more,
}));
}
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content.lines().count();
let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize;
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
let more = (skip + page.len()) < line_count;
(page.join("\n"), Some(skip), more)
} else {
// 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量)
const DEFAULT_PREVIEW_LINES: usize = 500;
let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect();
let more = line_count > page.len();
(page.join("\n"), None, more)
};
Ok(serde_json::json!({
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
"offset": offset_used,
"returned_lines": result.lines().count(),
"has_more": has_more,
}))
})
})},
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
})
})},
);
registry.register(
"write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("content", "string", true),
("encoding", "string", false),
]),
RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(raw)
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
} else if encoding.eq_ignore_ascii_case("utf-8") {
raw.as_bytes().to_vec()
} else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
};
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
}
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
Ok(m) if m.len() > 0 => {
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
Some(m.len())
}
Ok(_) => Some(0), // 空文件(无需备份)
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) {
let bak = format!("{}.bak", path);
let _ = tokio::fs::remove_file(&bak).await;
}
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size {
if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
})
})},
);
// ── 局部文件编辑 (Medium risk) ──
// F-260617-01: 三模式互斥old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点)。
// object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。
// 三模式均需 path + new_textold_text/replace_lines/anchor 三选一互斥expected_hash 可选通用。
let patch_file_schema = {
let mut props = serde_json::Map::new();
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" }));
props.insert("replace_lines".into(), serde_json::json!({
"type": "object",
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
"properties": {
"start": { "type": "integer", "description": "起始行号1-based" },
"end": { "type": "integer", "description": "结束行号1-based" }
},
"required": ["start", "end"]
}));
props.insert("anchor".into(), serde_json::json!({
"type": "object",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
"properties": {
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
},
"required": ["start", "end"]
}));
props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "old_text 模式)行号辅助定位,可选" }));
props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹read_file 返回的 file_hash三模式通用防并发修改" }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["path", "new_text"],
})
};
registry.register(
"patch_file", "局部更新文件内容三模式互斥。模式1 old_text精确匹配原文替换含空格/缩进CAS 语义。模式2 replace_lines按行号区间 {start,end}1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_textexpected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
patch_file_schema,
RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// F-260617-01: 三模式互斥校验old_text / replace_lines / anchor
// 三选一:统计传入的模式参数数,>1 报错0 报错(缺定位方式)
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
if mode_count == 0 {
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
}
if mode_count > 1 {
anyhow::bail!(
"模式互斥冲突old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
mode_count
);
}
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
let file_meta = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
if file_meta.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
}
// TD-260621-03:读改写整体锁内防 lost update。
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
// 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。
let _patch_guard = FILE_LOCKS.lock().await;
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
let mut content = String::new();
file.read_to_string(&mut content).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
// 二进制检测
if content.contains('\0') {
anyhow::bail!("不支持二进制文件");
}
// L3: expected_hash 指纹校验防外部修改TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式)
if let Some(expected) = args["expected_hash"].as_str() {
let current_hash = compute_file_hash(&file_meta);
if current_hash != expected {
anyhow::bail!(
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
expected, current_hash
);
}
}
// F-260617-01: 三模式分派计算 new_content + match_count + warning
// old_text 模式精确匹配CAS+ 多匹配警告(仅替换第 1 处)
// replace_lines 模式:行号区间 splice越界 Err
// anchor 模式:子串锚点定位 → 行号区间 splice找不到/start 在 end 后 Err
let new_content: String;
let match_count: usize;
let warning: Option<String>;
if has_old_text {
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
if old_text.is_empty() {
anyhow::bail!("old_text 不能为空");
}
if old_text == new_text {
return Ok(serde_json::json!({
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
// L2: old_text 精确匹配CAS 语义)
if !content.contains(old_text) {
anyhow::bail!("未找到目标文本,文件可能已被修改");
}
let mc = content.matches(old_text).count();
match_count = mc;
warning = if mc > 1 {
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
} else { None };
new_content = content.replacen(old_text, new_text, 1);
} else if has_replace_lines {
let rl = args.get("replace_lines")
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
let start = rl["start"].as_u64()
.ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
let end = rl["end"].as_u64()
.ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
new_content = apply_line_range(&content, start, end, new_text)?;
match_count = 1;
warning = None;
} else {
// has_anchor
let an = args.get("anchor")
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
let a_start = an["start"].as_str()
.ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
let a_end = an["end"].as_str()
.ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
new_content = apply_line_range(&content, start_line, end_line, new_text)?;
match_count = 1;
warning = None;
}
// 写序列_patch_guard 持锁中:backup → tmp write → rename → cleanup
// .bak 备份
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
// 原子写: tmp → rename
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
let _ = tokio::fs::remove_file(&tmp).await;
let _ = tokio::fs::remove_file(&bak).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
}
// 成功:清理 .bak
let _ = tokio::fs::remove_file(&bak).await;
drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁)
let size_diff = new_content.len() as i64 - content.len() as i64;
// 生成 unified diff 供前端审批卡/审计留痕展示
// content与 new_content作用域内可用无需重读文件
let diff = generate_diff(&content, &new_content);
let mut result = serde_json::json!({
"path": path,
"changed": true,
"size_diff": size_diff,
"matches_found": match_count,
"diff": diff,
});
if let Some(w) = warning {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
})
})},
);
// ── 文件元信息 (Low risk) ──
registry.register(
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let p = std::path::Path::new(path);
if !p.exists() {
return Ok(serde_json::json!({ "path": path, "exists": false }));
}
let metadata = tokio::fs::metadata(path).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
let is_dir = metadata.is_dir();
let size = metadata.len();
let modified = metadata.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64);
// is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02)
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
// size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
let is_binary = if !is_dir && size > 0 {
use tokio::io::AsyncReadExt;
let file = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(_) => return Ok(serde_json::json!({
"path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
"modified": modified, "is_binary": false, "is_dir": is_dir,
"error": "读取文件头失败"
})),
};
let mut reader = tokio::io::BufReader::new(file);
let mut sample = vec![0u8; 8192];
let n = reader.read(&mut sample).await.unwrap_or(0);
sample[..n].contains(&0x00)
} else { false };
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
} else { None };
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
})
})},
);
// ── 追加写入 (Medium risk) ──
registry.register(
"append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
let bytes = content.as_bytes();
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
})
})},
);
// ── 删除文件 (High risk, F-260615-16) ──
registry.register(
"delete_file", "删除文件。默认permanent=false软删除移到工作区 .trash/ 目录保留可恢复permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险须人工批准",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("permanent", "boolean", false),
]),
RiskLevel::High,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
let metadata = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
if metadata.is_dir() {
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
}
let file_name = target.file_name()
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
.to_string_lossy().to_string();
if permanent {
// 硬删除:不可逆,对齐 delete_project purge 语义
tokio::fs::remove_file(path).await
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": true,
"backed_up": false,
}))
} else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
let trash_dir = workspace_root().join(".trash");
tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
// (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。
// .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。
// helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。
if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await {
anyhow::bail!("移入回收站失败({})", e);
}
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": false,
"backed_up": true,
"backup_path": backup_path_str,
}))
}
})
})},
);
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
registry.register(
"rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move。参数from源路径+ to目标路径+ overwrite默认 false目标存在则拒绝。同卷走 tokio::fs::rename原子跨卷自动降级 copy+remove非原子失败回滚删 to 保 from 完整)。须人工批准",
df_ai::ai_tools::object_schema(vec![
("from", "string", true),
("to", "string", true),
("overwrite", "boolean", false),
]),
RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed,
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
let snap = allowed_dirs.read().await.clone();
let from_resolved = resolve_workspace_path_with_allowed(
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
&snap,
)?;
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
let to_resolved = resolve_workspace_path_with_allowed(
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
&snap,
)?;
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
let from_target = std::path::Path::new(from_path);
if !from_target.exists() {
anyhow::bail!("源文件不存在: {}", from_path);
}
let from_metadata = tokio::fs::metadata(from_target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
if from_metadata.is_dir() {
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
}
let bytes_moved = from_metadata.len();
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
}
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
if to_target.exists() {
if !overwrite {
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
}
tokio::fs::remove_file(to_path).await
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
}
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
// 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级),
// 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。
let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?;
Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": cross_volume,
}))
})
})},
);
// ── 跨文件内容搜索 grep (Medium risk, F-260621) ──
// 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。
// 参考 memory [[devflow-patch-file-design]] 工具落地模式 + Claude Code grep 工具语义。
//
// 参数语义(对齐 Claude Code grep):
// - pattern(必填):正则表达式(默认),大小写敏感。-i 切大小写不敏感。
// 注意:与 search_files 不同(search_files 字面包含 + 大小写不敏感),grep 用 regex 更强表达力,
// 但"无特殊字符的 pattern"等价字面包含(如 "foo" 匹配含 foo 的行)。
// - path(搜索根):锚 workspace + path_auth 授权(resolve_workspace_path_with_allowed)。
// - glob(可选):文件名过滤,如 "*.rs"。支持基础 glob(* / ? / [seq] 单段),复用 PatternBuilder。
// - output_mode:content(默认,返 file:line:content+context) / files_with_matches(只返命中文件名) /
// count(每文件命中行数)。对齐 Claude Code grep 三模式。
// - -n(行号,默认 true)/ -i(大小写不敏感,默认 false)/ -C context_lines(上下文行数,默认 0)。
// - max_results:防撑爆 LLM context,默认 50(对齐 read_file search/search_files 50 条上限)。
//
// 安全:递归遍历跳过噪音目录(.git/node_modules/target/.trash 等 is_noise_dir)+
// 噪音文件(.bak/.tmp-write 等 is_noise_file)+ symlink(对齐 list_dir_recursive 防逃逸)+
// 二进制文件(对齐 read_file/list_directory:\0 检测)。
// path_auth:授权目录内放行;未授权走 AiDirAuthRequired 申请(audit/mod.rs check_file_tool_auth
// 经 extract_file_tool_paths 单路径分支触发,非 search_files 盲拒)。
// risk:Med(读文件内容,授权目录内放行/外申请)。
// 注册顺序:read_file/list_directory 后,search_files 前(高频检索工具靠前)。
let grep_schema = {
let mut props = serde_json::Map::new();
props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。必填" }));
props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" }));
props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 }));
props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["pattern", "path"],
})
};
registry.register(
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
grep_schema,
RiskLevel::Medium,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
if pattern.is_empty() {
anyhow::bail!("pattern 不能为空");
}
let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize;
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
// 编译正则:case_insensitive 开 i flag;失败上抛明确错误(非法正则不是业务错,LLM 据此修参)
let mut re_builder = regex::RegexBuilder::new(pattern);
re_builder.case_insensitive(case_insensitive);
let re = re_builder.build()
.map_err(|e| anyhow::anyhow!("正则编译失败「{}」: {}", pattern, e))?;
// glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
// 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
let glob_re = match glob_opt {
Some(g) => Some(compile_glob_to_regex(g)
.map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?),
None => None,
};
// 递归遍历+逐文件读+行匹配,收集结果
let mut matches_out: Vec<FileGrepHit> = Vec::new();
let mut total: usize = 0;
let mut truncated = false;
grep_recursive(
root, &re, glob_re.as_ref(), output_mode,
context_lines, max_results, 0, 6,
&mut matches_out, &mut total, &mut truncated,
).await?;
// output_mode 分派返回结构
let result = match output_mode {
"files_with_matches" => {
// 仅返命中文件路径列表(去重,顺序保留首次命中)
let files: Vec<String> = matches_out.iter()
.map(|h| h.file.clone())
.collect();
serde_json::json!({
"path": root,
"pattern": pattern,
"output_mode": output_mode,
"files": files,
"total": files.len(),
"truncated": truncated,
})
}
"count" => {
// 每文件命中行数
let counts: Vec<serde_json::Value> = matches_out.iter()
.map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() }))
.collect();
let total_files = counts.len();
serde_json::json!({
"path": root,
"pattern": pattern,
"output_mode": output_mode,
"counts": counts,
"total": total,
"total_files": total_files,
"truncated": truncated,
})
}
_ => {
// content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}]
let mut lines: Vec<serde_json::Value> = Vec::new();
for hit in &matches_out {
for lm in &hit.line_matches {
let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null };
let mut entry = serde_json::json!({
"file": hit.file,
"line": line_no,
"content": lm.content,
});
if context_lines > 0 && !lm.context.is_empty() {
entry["context"] = serde_json::Value::String(lm.context.clone());
}
lines.push(entry);
}
}
serde_json::json!({
"path": root,
"pattern": pattern,
"output_mode": "content",
"matches": lines,
"total": total,
"truncated": truncated,
})
}
};
Ok(result)
})
})},
);
// ── 文件搜索 (Low risk) ──
registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
RiskLevel::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
const MAX_RESULTS: usize = 50;
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
// 先收集全部结果用于 total 计数,再做 skip/take 分页
let mut all_results = Vec::new();
let mut total = 0u64;
// 用较大上限收集全量(分页由内存 skip/take 控制)
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_results.len()) < total as usize;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
})
})},
);
// ── 命令执行run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中,
// 移到末位让 read_file/write_file 等高频文件工具在前run_command 仅命令执行场景才该用) ──
registry.register(
"run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file编辑文件用 patch_file/write_file列目录用 list_directory搜索文件名用 search_files——不要用本工具完成这些操作。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。",
df_ai::ai_tools::object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
};
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
let result = execute(request).await.map_err(|e| {
let msg = e.to_string();
if msg.contains("命令执行超时") {
anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
timeout_secs
)
} else {
e
}
})?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
// ── 环境感知 (Low risk, L1 agent 元能力层) ──
// detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。
// 返回 JSON:{ os, default_shell, python_path, node_path }。
//
// 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!),
// AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。
// 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测,
// 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。
//
// 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后,
// 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。
//
// 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。
// 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。
//
// 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测,
// 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。
// 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。
//
// 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因,
// 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。
registry.register(
"detect_environment",
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
df_ai::ai_tools::object_schema(vec![]),
RiskLevel::Low,
Box::new(|_args: serde_json::Value| Box::pin(async move {
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
let os = if cfg!(target_os = "windows") {
"Windows"
} else if cfg!(target_os = "macos") {
"macOS"
} else if cfg!(target_os = "linux") {
"Linux"
} else {
"Unknown"
};
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
let default_shell = if cfg!(target_os = "windows") {
"PowerShell"
} else {
"bash"
};
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
Err(_) => true, // 未设 = 默认开
};
let mut errors: Vec<String> = Vec::new();
let (python_path, node_path) = if probe_enabled {
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
let py = if cfg!(target_os = "windows") {
probe_executable("python", "where python", &mut errors).await
} else {
let p3 = probe_executable("python3", "which python3", &mut errors).await;
if p3.is_some() {
p3
} else {
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
probe_executable("python", "which python", &mut errors).await
}
};
let node = if cfg!(target_os = "windows") {
probe_executable("node", "where node", &mut errors).await
} else {
probe_executable("node", "which node", &mut errors).await
};
(py, node)
} else {
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
(None, None)
};
Ok(serde_json::json!({
"os": os,
"default_shell": default_shell,
"python_path": python_path,
"node_path": node_path,
"probe_enabled": probe_enabled,
"errors": errors,
}))
})),
);
}
/// 探测可执行文件路径(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)。
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)` 表示被截断
fn list_dir_recursive<'a>(
path: &'a str,
recursive: bool,
depth: usize,
max_depth: usize,
max_entries: usize,
skip_noise: bool,
result: &'a mut Vec<serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
Box::pin(async move {
let mut dir = tokio::fs::read_dir(path).await
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
while let Some(entry) = dir.next_entry().await? {
if result.len() >= max_entries {
return Ok(true); // 达上限截断
}
let name = entry.file_name().to_string_lossy().to_string();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
if skip_noise && is_noise_file(&name) {
continue;
}
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink();
let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
result.push(serde_json::json!({
"name": name,
"type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
"size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
"depth": depth,
}));
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
if truncated {
return Ok(true);
}
}
}
Ok(false)
})
}
/// 工具名 → 审批文案模板(供 build_approval_reason 使用)。
///
/// 返回 Option<(&str, &[&str])>(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None由调用方 fallback 到通用风险提示。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
let (template, keys): (&str, &[&str]) = match name {
"delete_project" => ("删除项目{}", &["id"]),
"restore_project" => ("从回收站恢复项目{}", &["id"]),
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
"bind_directory" => ("绑定目录:{}(项目{}", &["path", "id"]),
"create_task" => ("创建任务:{}(项目{}", &["title", "project_id"]),
"create_project" => ("创建项目:「{}」", &["name"]),
"create_idea" => ("捕获灵感:{}", &["title"]),
"patch_file" => ("修改文件:{}", &["path"]),
"delete_file" => ("删除文件:{}{}", &["path", "permanent"]),
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
// F-03 收口:推进链工具审批文案(advance_task/run_workflow)
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
// http_request 审批文案:method + url(看是什么请求打到哪)
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
_ => return None,
};
Some((template, keys))
}
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
match name {
"create_task" => Some("创建任务"),
"write_file" => Some("写入文件"),
"read_file" => Some("读取文件"),
"list_directory" => Some("列出目录"),
"search_files" => Some("搜索文件"),
"create_project" => Some("创建项目"),
"update_project" => Some("修改项目"),
"delete_project" => Some("删除项目"),
"restore_project" => Some("恢复项目"),
"purge_project" => Some("永久删除项目"),
"bind_directory" => Some("绑定目录"),
"create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"),
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息(UX-260618-14)
"run_workflow" => Some("触发工作流"),
"delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"),
"delete_idea" => Some("删除灵感"),
"patch_file" => Some("修改文件"),
"append_file" => Some("追加写入"),
"delete_file" => Some("删除文件"),
"rename_file" => Some("重命名/移动"),
"http_request" => Some("发起 HTTP 请求"),
_ => None,
}
}
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
];
NOISE_DIRS.contains(&name)
}
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
///
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
fn is_noise_file(name: &str) -> bool {
const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}
// ============================================================
// grep 工具底层F-260621
//
// FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时:
// - content: 展平 line_matches 为 matches[{file,line,content,context?}]
// - files_with_matches: 仅取 file 字段(顺序保留首次命中)
// - count: 取 line_matches.len() 为每文件命中行数
//
// LineMatch.content 为命中行原文(去尾换行),context 为命中行前后 context_lines 行
// 拼接(前 N 行 + 命中行 + 后 N 行,\n 连接),供前端展开查看上下文。
// ============================================================
/// 单行命中(1-based 行号 + 行内容 + 上下文)
struct LineMatch {
line: usize,
content: String,
context: String,
}
/// 单文件命中聚合(file=绝对/锚定路径,line_matches 按行号升序)
struct FileGrepHit {
file: String,
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 友好提示。
fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex> {
let mut out = String::with_capacity(glob.len() + 4);
out.push('^');
let mut chars = glob.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => out.push_str("[^/]*"),
'?' => out.push_str("[^/]"),
'[' => {
// 字符集:原样保留到匹配的 ](支持开头 ] 字面 + ! 取反)
out.push('[');
// ] 在首位视为字面(POSIX glob 约定)
if matches!(chars.peek(), Some(']')) {
out.push('\\'); out.push(']');
chars.next();
}
if matches!(chars.peek(), Some('!')) {
out.push('^'); // regex 取反用 ^
chars.next();
}
while let Some(ch) = chars.next() {
if ch == ']' {
out.push(']');
break;
}
// 集合内字符 escape regex 元字符(如 ] 已由 break 处理,此处 \ - 等保留)
out.push(ch);
}
}
// regex 元字符 escape(. + ( ) | ^ $ { } \ 等)
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '\\' => {
out.push('\\'); out.push(c);
}
_ => out.push(c),
}
}
out.push('$');
regex::Regex::new(&out).map_err(|e| anyhow::anyhow!(e.to_string()))
}
/// 递归跨文件内容搜索(grep -rn 模式)
///
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
/// - glob 过滤器(可选):命中文件名才读内容
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
/// - 逐行 `re.is_match`,收集命中行(context_lines>0 时附上下文)
/// - 达 `max_results` 命中行数即停止(返 `truncated=true`),`total` 仍累加全部命中数
///
/// 输出聚合到 `out`(按文件分组),`total`/`truncated` 由调用方读后构造响应。
fn grep_recursive<'a>(
dir: &'a str,
re: &'a regex::Regex,
glob: Option<&'a regex::Regex>,
output_mode: &'a str,
context_lines: usize,
max_results: usize,
depth: usize,
max_depth: usize,
out: &'a mut Vec<FileGrepHit>,
total: &'a mut usize,
truncated: &'a mut bool,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let mut entries = tokio::fs::read_dir(dir).await
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", dir, e))?;
// 收集本层 entry 先排序(确定性输出,便于测试稳定 + LLM 可重现)
let mut items: Vec<std::path::PathBuf> = Vec::new();
while let Some(entry) = entries.next_entry().await? {
items.push(entry.path());
}
items.sort();
for path in items {
if *truncated {
return Ok(());
}
let file_name = match path.file_name() {
Some(n) => n.to_string_lossy().to_string(),
None => continue,
};
// file_type 不跟随 symlink(对比 metadata 会跟随);symlink 一律跳过(防逃逸,对齐 list_dir_recursive)
let file_type = match tokio::fs::symlink_metadata(&path).await {
Ok(ft) => ft,
Err(_) => continue, // 元数据读失败跳过(权限/竞态等)
};
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
// 噪音目录(.git/node_modules/target/.trash 等)不深入
if is_noise_dir(&file_name) {
continue;
}
if depth < max_depth {
let child = path.to_string_lossy().into_owned();
grep_recursive(
&child, re, glob, output_mode, context_lines, max_results,
depth + 1, max_depth, out, total, truncated,
).await?;
}
continue;
}
// 文件:跳过噪音文件(.bak/.tmp-write 等编辑器产物)
if is_noise_file(&file_name) {
continue;
}
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
if let Some(g) = glob {
if !g.is_match(&file_name) {
continue;
}
}
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
let metadata = match tokio::fs::metadata(&path).await {
Ok(m) => m,
Err(_) => continue,
};
if metadata.len() > 1_048_576 {
continue; // >1MB 跳过(对齐 read_file 上限)
}
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(_) => continue,
};
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
if bytes.iter().take(8192).any(|&b| b == 0) {
continue;
}
// 转 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 {
continue; // 本文件无命中
}
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 不收集行详情
});
}
continue;
}
// 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(())
})
}
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
fn search_files_recursive<'a>(
path: &'a str,
pattern: &'a str,
recursive: bool,
depth: usize,
max_depth: usize,
max_results: usize,
results: &'a mut Vec<serde_json::Value>,
total: &'a mut u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let mut dir = tokio::fs::read_dir(path).await
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
while let Some(entry) = dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
// file_type 不跟随 symlink(对比 entry.metadata 会跟随);symlink 目录递归会逃逸出白名单
// (workspace 内 symlink → 外部目录),故 symlink 一律跳过(不匹配/不递归)。对齐 list_dir_recursive。
let file_type = entry.file_type().await?;
if file_type.is_symlink() {
continue;
}
let is_dir = file_type.is_dir();
if !is_dir {
// 字符串包含匹配(大小写不敏感)
if name.to_lowercase().contains(pattern) {
*total += 1;
if results.len() < max_results {
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let metadata = entry.metadata().await?;
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
}
}
} else if recursive && depth < max_depth {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
// ============================================================
// 工具注册基线测试SMELL-P0-2 拆分防护)
//
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
// build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 31 个工具(18 data + 12 file + 1 http),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
#[tokio::test]
async fn test_build_ai_tool_registry_baseline_tool_count() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
// 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);
// 总量基线:31(18 data + 12 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
assert_eq!(
registry.len(),
31,
"工具总数应为 31(18 data + 12 file + 1 http),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 18 个(持 db):CRUD/状态机/工作流
// file 层 12 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (18) ──
"list_projects", "list_tasks", "list_ideas",
"update_project", "create_project", "bind_directory",
"create_task", "update_task", "advance_task",
"run_workflow", "delete_task", "create_idea",
"delete_project", "restore_project", "purge_project",
"list_trash", "get_project_count", "get_task_count",
// ── file 层 (12) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1
"read_file", "list_directory", "write_file",
"patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command",
"grep", "detect_environment",
// ── http 层 (1) ──
"http_request",
];
expected.sort_unstable();
let mut actual: Vec<String> = registry.tool_names();
actual.sort();
assert_eq!(
actual,
expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
"工具名集合与基线不一致(可能漏注册或 rename)"
);
}
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_dir(".git"));
assert!(is_noise_dir("node_modules"));
assert!(is_noise_dir("target"));
assert!(is_noise_dir("dist"));
assert!(is_noise_dir("build"));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore"));
assert!(!is_noise_dir(".gitattributes"));
assert!(!is_noise_dir(".gitkeep"));
}
/// is_noise_dir 纯函数:普通目录不命中
#[test]
fn test_is_noise_dir_normal_dirs_not_matched() {
assert!(!is_noise_dir("src"));
assert!(!is_noise_dir("tests"));
assert!(!is_noise_dir("docs"));
assert!(!is_noise_dir("main.rs"));
assert!(!is_noise_dir(""));
}
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_dir_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target"));
assert!(!is_noise_dir("DIST"));
assert!(!is_noise_dir("NODE_MODULES"));
}
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
#[test]
fn test_is_noise_file_matches_temp_artifacts() {
// write_file 原子写副产物
assert!(is_noise_file("PROGRESS.md.bak"));
assert!(is_noise_file("PROGRESS.md.tmp-write"));
// 编辑器临时文件
assert!(is_noise_file(".main.rs.swp"));
assert!(is_noise_file("main.rs~"));
// 路径含但非后缀的应不命中(避免误杀)
assert!(!is_noise_file("backup.bak.md"));
assert!(!is_noise_file("main.rs"));
assert!(!is_noise_file(".gitignore"));
assert!(!is_noise_file(""));
}
/// 造一个临时目录树用于 list_dir_recursive 测试
fn build_noise_tree(root: &Path) {
// .git/HEAD (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
// node_modules/x/index.js (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
// target/debug/bin (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("target").join("debug")).unwrap();
fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
// 普通 src/main.rs (应出现)
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
// .gitignore 文件 (文件名不是噪音目录,应出现)
fs::write(root.join(".gitignore"), "/target\n").unwrap();
}
/// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
#[tokio::test]
async fn test_list_dir_recursive_filters_noise_dirs() {
let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
assert!(!truncated);
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 噪音目录本身及其内部文件均不应出现
assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
// 噪音目录内部文件也不应被递归带入
assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");
// 普通目录与 .gitignore 文件应正常返回
assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:skip_noise=false 时噪音目录正常返回
#[tokio::test]
async fn test_list_dir_recursive_keeps_noise_when_disabled() {
let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 关闭过滤后噪音目录应作为 entry 出现
assert!(names.contains(&".git".to_string()));
assert!(names.contains(&"node_modules".to_string()));
assert!(names.contains(&"target".to_string()));
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:max_depth 控制递归深度
#[tokio::test]
async fn test_list_dir_recursive_max_depth() {
// 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
let root = tmp.to_string_lossy().to_string();
// max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"a".to_string()), "depth1 应含 a");
assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");
// max_depth=3: 应能到 depth3 的 main.rs
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");
fs::remove_dir_all(&tmp).ok();
}
// ============================================================
// patch_file 三模式共用底层测试F-260617-01
// 纯函数 apply_line_range / resolve_anchor_to_lines不依赖 fs/async/锁。
// handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成,
// 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心old_text 模式逻辑
// 沿用既有实现contains + replacen未改零回归不在此重复。
// ============================================================
/// apply_line_range: 区间替换含首尾行1-based
#[test]
fn test_apply_line_range_inclusive() {
// 3 行文件(末尾无尾换行)
let content = "line1\nline2\nline3";
let out = apply_line_range(content, 2, 3, "REPLACED").unwrap();
// 第 2、3 行被替换为 REPLACEDREPLACED 不以 \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\nYY 后还有 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原文件尾随 \nNEW 不带 \n其后无保留行
// splice 循环对 NEW作为 start 行push 后因不以 \n 结尾且无后续保留行 → 不补;
// 但原文件 trailing_newline=true末尾应保留 \n
assert_eq!(out, "a\nNEW\n");
}
/// apply_line_range: 单行区间start==end
#[test]
fn test_apply_line_range_single_line() {
let content = "a\nb\nc";
let out = apply_line_range(content, 2, 2, "X").unwrap();
assert_eq!(out, "a\nX\nc");
}
/// apply_line_range: 多行 new_text 原样插入
#[test]
fn test_apply_line_range_multiline_newtext() {
let content = "h\nOLD\nt";
let out = apply_line_range(content, 2, 2, "n1\nn2\nn3").unwrap();
assert_eq!(out, "h\nn1\nn2\nn3\nt");
}
/// apply_line_range: start<1 越界 Err
#[test]
fn test_apply_line_range_start_below_one() {
let content = "a\nb";
let err = apply_line_range(content, 0, 1, "X").unwrap_err();
assert!(format!("{}", err).contains("start 行号必须 >= 1"), "got: {}", err);
}
/// apply_line_range: end>lines.len() 越界 Err
#[test]
fn test_apply_line_range_end_overflow() {
let content = "a\nb"; // 2 行
let err = apply_line_range(content, 1, 5, "X").unwrap_err();
assert!(format!("{}", err).contains("end 行号"), "got: {}", err);
}
/// apply_line_range: start>end Err
#[test]
fn test_apply_line_range_start_gt_end() {
let content = "a\nb\nc";
let err = apply_line_range(content, 3, 2, "X").unwrap_err();
assert!(format!("{}", err).contains("start"), "got: {}", err);
}
/// resolve_anchor_to_lines: start/end 子串命中(基本路径)
#[test]
fn test_resolve_anchor_basic() {
// 注意 end 锚取子串;此处用 END-FN 唯一标记第 4 行(避免 println! 行内 {} 干扰)
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n} // END-FN\n";
// start 锚 "fn foo" 在第 1 行end 锚 "END-FN" 在第 4 行
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "END-FN").unwrap();
assert_eq!((s, e), (1, 4));
}
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
#[test]
fn test_resolve_anchor_substring_matches_inside_line() {
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
assert_eq!((s, e), (1, 3));
}
/// resolve_anchor_to_lines: 同行命中start==end单行区间
#[test]
fn test_resolve_anchor_same_line() {
let content = "header marker end here\nother";
// start/end 均在第 1 行命中
let (s, e) = resolve_anchor_to_lines(content, "header", "end").unwrap();
assert_eq!((s, e), (1, 1));
}
/// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串)
#[test]
fn test_resolve_anchor_picks_first_end_after_start() {
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
let content = "START\n}\nx\n}";
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
assert_eq!((s, e), (1, 2));
}
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
#[test]
fn test_resolve_anchor_case_sensitive() {
let content = "Hello\nWorld";
// 小写 "hello" 不命中第 1 行的大写 "Hello" → Err
let err = resolve_anchor_to_lines(content, "hello", "World").unwrap_err();
assert!(format!("{}", err).contains("anchor.start 子串"), "got: {}", err);
}
/// resolve_anchor_to_lines: start 子串找不到 Err
#[test]
fn test_resolve_anchor_start_not_found() {
let content = "a\nb\nc";
let err = resolve_anchor_to_lines(content, "NOPE", "c").unwrap_err();
assert!(format!("{}", err).contains("anchor.start 子串「NOPE」"), "got: {}", err);
}
/// resolve_anchor_to_lines: end 子串在 start 之后找不到 Err
#[test]
fn test_resolve_anchor_end_not_found_after_start() {
// start 命中第 1 行,但 end 子串 "z" 在第 1 行之后不存在
let content = "START\nb\nc";
let err = resolve_anchor_to_lines(content, "START", "z").unwrap_err();
assert!(format!("{}", err).contains("anchor.end 子串「z」"), "got: {}", err);
}
/// resolve_anchor_to_lines: start/end 空串 Err
#[test]
fn test_resolve_anchor_empty_marker() {
let content = "a\nb";
assert!(resolve_anchor_to_lines(content, "", "b").is_err());
assert!(resolve_anchor_to_lines(content, "a", "").is_err());
}
/// 三模式组合案例anchor 解析为行号 → apply_line_range 执行替换(端到端纯函数链)
#[test]
fn test_anchor_then_apply_line_range_e2e() {
let content = "use std::fs;\nuse std::io;\n\nfn main() {}\n";
// 锚点定位 import 区块(第 1-2 行),替换为新 import
let (s, e) = resolve_anchor_to_lines(content, "use std::fs", "use std::io").unwrap();
assert_eq!((s, e), (1, 2));
let out = apply_line_range(content, s, e, "use std::path;").unwrap();
assert_eq!(out, "use std::path;\n\nfn main() {}\n");
}
// ============================================================
// grep 工具测试F-260621
//
// compile_glob_to_regex:纯函数,无 fs 依赖。
// grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。
// path_auth 未授权触发申请:经 check_file_tool_auth(单路径分支) → NeedsAuth,
// 非 search_files 盲拒(锁定 grep 走 read_file 同款申请路径)。
// ============================================================
/// compile_glob_to_regex:基础 * / ? / 字面字符
#[test]
fn test_compile_glob_basic() {
let g = compile_glob_to_regex("*.rs").unwrap();
assert!(g.is_match("main.rs"));
assert!(g.is_match("a.rs"));
assert!(!g.is_match("main.ts"));
// * 不跨段(不含 /)
assert!(!g.is_match("dir/main.rs"), "* 不匹配 / (单段语义)");
let q = compile_glob_to_regex("?.txt").unwrap();
assert!(q.is_match("a.txt"));
assert!(!q.is_match("ab.txt"), "? 仅匹配单字符");
// 字面字符(含 regex 元字符 escape:. 不当通配)
let lit = compile_glob_to_regex("v1.0.txt").unwrap();
assert!(lit.is_match("v1.0.txt"));
assert!(!lit.is_match("v1X0.txt"), ". 应字面匹配(escape 防 regex 元字符)");
}
/// compile_glob_to_regex:字符集 [seq] / [!seq] 取反
#[test]
fn test_compile_glob_charset() {
let set = compile_glob_to_regex("*.[ch]").unwrap();
assert!(set.is_match("main.c"));
assert!(set.is_match("main.h"));
assert!(!set.is_match("main.cpp"));
let neg = compile_glob_to_regex("*.[!ch]").unwrap();
assert!(!neg.is_match("main.c"), "[!ch] 取反,c 不命中");
assert!(!neg.is_match("main.h"), "[!ch] 取反,h 不命中");
// [!ch] 匹配单字符非 c/h 的扩展名(锚定 ^...$,多字符扩展不命中)
assert!(neg.is_match("main.s"), "[!ch] 取反,单字符 s 命中");
assert!(!neg.is_match("main.rs"), "[!ch] 锚定单字符,rs(2 字符)不命中");
}
/// grep_recursive:基本字面匹配(无特殊字符等价 contains)
#[tokio::test]
async fn test_grep_basic_literal_match() {
let tmp = std::env::temp_dir().join(format!("df_grep_basic_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("src")).unwrap();
fs::write(tmp.join("src").join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
fs::write(tmp.join("src").join("b.rs"), "struct Foo;\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
// 大小写敏感:只命中 a.rs 第 1 行 "fn foo() {}"(b.rs "struct Foo" 大写不命中)
assert!(!truncated);
assert_eq!(total, 1, "foo 大小写敏感仅命中 1 处");
assert_eq!(out.len(), 1, "命中 1 个文件");
assert!(out[0].file.ends_with("a.rs"));
assert_eq!(out[0].line_matches.len(), 1);
assert_eq!(out[0].line_matches[0].line, 1);
assert_eq!(out[0].line_matches[0].content, "fn foo() {}");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:正则匹配(锚定 ^)
#[tokio::test]
async fn test_grep_regex_match() {
let tmp = std::env::temp_dir().join(format!("df_grep_regex_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("a.rs"), "fn main() {}\nasync fn helper() {}\nfn main2() {}\n").unwrap();
let root = tmp.to_string_lossy().to_string();
// ^fn 匹配行首 fn
let re = regex::Regex::new("^fn ").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
// 第 1、3 行行首是 "fn ",第 2 行行首是 "async fn " 不命中 ^fn
assert_eq!(total, 2, "^fn 命中第 1、3 行");
assert_eq!(out[0].line_matches.len(), 2);
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:大小写不敏感(-i 语义,经 RegexBuilder case_insensitive)
#[tokio::test]
async fn test_grep_case_insensitive() {
let tmp = std::env::temp_dir().join(format!("df_grep_ci_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("a.rs"), "Foo\nfoo\nFOO\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::RegexBuilder::new("foo").case_insensitive(true).build().unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
assert_eq!(total, 3, "大小写不敏感命中 Foo/foo/FOO 三处");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:glob 过滤(仅搜 *.rs)
#[tokio::test]
async fn test_grep_glob_filter() {
let tmp = std::env::temp_dir().join(format!("df_grep_glob_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// .rs 与 .ts 都含 "foo",glob *.rs 只搜 .rs
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
fs::write(tmp.join("b.ts"), "foo\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let glob_re = Some(compile_glob_to_regex("*.rs").unwrap());
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, glob_re.as_ref(), "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
assert_eq!(out.len(), 1, "glob *.rs 仅命中 1 个文件");
assert!(out[0].file.ends_with("a.rs"), "应只命中 a.rs,b.ts 被过滤");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:噪音目录(.git/node_modules/target)与噪音文件(.bak)跳过
#[tokio::test]
async fn test_grep_skips_noise() {
let tmp = std::env::temp_dir().join(format!("df_grep_noise_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join(".git")).unwrap();
fs::write(tmp.join(".git").join("config"), "foo-in-git\n").unwrap();
fs::create_dir_all(tmp.join("target")).unwrap();
fs::write(tmp.join("target").join("app"), "foo-in-target\n").unwrap();
// 噪音文件 .bak
fs::write(tmp.join("a.rs.bak"), "foo-in-bak\n").unwrap();
// 正常文件
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
// 只命中 a.rs,噪音目录(.git/target)与噪音文件(.bak)被跳过
assert_eq!(total, 1, "仅 a.rs 命中,噪音被跳过");
assert_eq!(out.len(), 1);
assert!(out[0].file.ends_with("a.rs"));
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:max_results 截断(total 累加全部,out 受限,truncated=true)
#[tokio::test]
async fn test_grep_truncation() {
let tmp = std::env::temp_dir().join(format!("df_grep_trunc_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// 一个文件含 5 行 foo,max_results=2 截断
fs::write(tmp.join("a.rs"), "foo\nfoo\nfoo\nfoo\nfoo\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 2, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
assert!(truncated, "达 max_results=2 应截断");
assert_eq!(total, 5, "total 累加全部 5 处命中");
// out 内行数受 max_results 限制(2 行)
let collected: usize = out.iter().map(|h| h.line_matches.len()).sum();
assert_eq!(collected, 2, "out 收集 2 行受 max_results 限制");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:context_lines 上下文(命中行前后各 N 行)
#[tokio::test]
async fn test_grep_context_lines() {
let tmp = std::env::temp_dir().join(format!("df_grep_ctx_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// 第 3 行命中 foo,context_lines=1 应含第 2-4 行
fs::write(tmp.join("a.rs"), "line1\nline2\nfoo\nline4\nline5\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 1, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
assert_eq!(out[0].line_matches[0].line, 3);
// context = 第 2、3、4 行(命中行前 1 + 命中行 + 后 1)
assert_eq!(out[0].line_matches[0].context, "line2\nfoo\nline4");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:files_with_matches 模式仅返命中文件(不收集行详情)
#[tokio::test]
async fn test_grep_files_with_matches_mode() {
let tmp = std::env::temp_dir().join(format!("df_grep_fwm_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("a.rs"), "foo\nfoo\n").unwrap(); // 同文件多命中
fs::write(tmp.join("b.rs"), "foo\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "files_with_matches", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
// total 累加全部命中行(3),out 每文件 line_matches 空(不收集行详情)
assert_eq!(total, 3, "total 计全部命中行");
assert_eq!(out.len(), 2, "命中 2 个文件");
assert!(out.iter().all(|h| h.line_matches.is_empty()), "files_with_matches 不收集行详情");
fs::remove_dir_all(&tmp).ok();
}
/// grep_recursive:二进制文件(\0)跳过
#[tokio::test]
async fn test_grep_skips_binary() {
let tmp = std::env::temp_dir().join(format!("df_grep_bin_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
// 二进制文件(前 8KB 含 \0),内容含 "foo" 但应被跳过
let mut bin_content = b"foo\n".to_vec();
bin_content.push(0u8);
bin_content.extend_from_slice(b"more foo\n");
fs::write(tmp.join("a.bin"), &bin_content).unwrap();
// 正常文本文件
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
let root = tmp.to_string_lossy().to_string();
let re = regex::Regex::new("foo").unwrap();
let mut out: Vec<FileGrepHit> = Vec::new();
let mut total = 0usize;
let mut truncated = false;
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
// 二进制 a.bin 被跳过,只命中 a.rs
assert_eq!(total, 1, "二进制文件被跳过,仅 a.rs 命中");
assert_eq!(out.len(), 1);
assert!(out[0].file.ends_with("a.rs"));
fs::remove_dir_all(&tmp).ok();
}
/// path_auth 未授权触发申请:grep 走单路径授权申请路径(非 search_files 盲拒)。
/// 完整 check_file_tool_auth → NeedsAuth 流程覆盖在 audit/mod.rs 测试模块,
/// 此处锁定 grep 编译正则 + glob 的预校验路径(pattern 空 bail、非法正则 bail)。
#[test]
fn test_grep_pattern_validation() {
// 非法正则编译失败上抛(锁定 grep handler 对非法 pattern 的明确错误,非 panic)
let bad = regex::RegexBuilder::new("[unclosed").build();
assert!(bad.is_err(), "未闭合 [ 应编译失败(锁定 handler map_err 路径)");
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
}
}