新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11932 additions and 1034 deletions

View File

@@ -136,10 +136,11 @@ fn validate_path(path: &str) -> anyhow::Result<()> {
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
anyhow::bail!("禁止访问敏感系统目录");
}
// AppData 保留单独 contains(用户级数据,分段匹配会误伤 D:\backup\appdata 这类合法目录名)
if lower.contains("\\appdata\\") {
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(())
}
@@ -271,6 +272,20 @@ pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) ->
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"))
@@ -280,6 +295,50 @@ fn workspace_root() -> PathBuf {
.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禁止越出项目目录
///
/// 双层校验:
@@ -839,6 +898,7 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
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();
@@ -910,13 +970,16 @@ fn register_file_tools(
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(),
"size": metadata.len(), "file_hash": file_hash,
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
}));
}
@@ -938,7 +1001,7 @@ fn register_file_tools(
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(),
"path": path, "size": metadata.len(), "file_hash": file_hash,
"search": search,
"matches": page_matches,
"total": total,
@@ -965,7 +1028,7 @@ fn register_file_tools(
(page.join("\n"), None, more)
};
Ok(serde_json::json!({
"path": path, "content": result, "size": metadata.len(), "lines": line_count,
"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,
@@ -1158,7 +1221,14 @@ fn register_file_tools(
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))?;
@@ -1171,12 +1241,9 @@ fn register_file_tools(
anyhow::bail!("不支持二进制文件");
}
// L3: expected_hash 指纹校验(防外部修改)
// L3: expected_hash 指纹校验(防外部修改TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式
if let Some(expected) = args["expected_hash"].as_str() {
let modified = file_meta.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let current_hash = format!("{}_{}", modified.unwrap_or(0), file_meta.len());
let current_hash = compute_file_hash(&file_meta);
if current_hash != expected {
anyhow::bail!(
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
@@ -1238,34 +1305,26 @@ fn register_file_tools(
warning = None;
}
// 阶段二L1 tokio::Mutex 保护写序列backup → tmp write → rename → cleanup
// tokio::sync::Mutex 的 MutexGuard 是 Send可安全跨 await
let abs_path = target.canonicalize()
.map_err(|e| anyhow::anyhow!("路径解析失败: {}", e))?;
{
let mut locks = FILE_LOCKS.lock().await;
locks.entry(abs_path.clone()).or_insert(());
// 写序列(_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))?;
// .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
// 原子写: 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;
// _locks 在此 drop释放锁
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 供前端审批卡/审计留痕展示
@@ -1426,8 +1485,17 @@ fn register_file_tools(
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
tokio::fs::rename(path, &backup_path).await
.map_err(|e| anyhow::anyhow!("移入回收站失败: {}", e))?;
// 软删除跨盘降级(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,
@@ -1501,44 +1569,171 @@ fn register_file_tools(
}
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
let rename_err = tokio::fs::rename(from_path, to_path).await.err();
if rename_err.is_none() {
return Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": false,
}));
}
// rename 失败:跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove
// 其他错误(权限/占用)直接抛,不降级
let err = rename_err.unwrap();
let cross_volume = err.raw_os_error() == Some(17);
if !cross_volume {
anyhow::bail!("重命名/移动失败: {}", err);
}
// 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
// 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
if let Err(e) = tokio::fs::copy(from_path, to_path).await {
anyhow::bail!("跨卷复制失败from 未改动): {}", e);
}
if let Err(e) = tokio::fs::remove_file(from_path).await {
// remove 失败:回滚删 to,保 from 完整(用户可重试)
let _ = tokio::fs::remove_file(to_path).await;
anyhow::bail!("跨卷移动删除源失败已回滚from 完整,可重试): {}", e);
}
// 阶段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": true,
"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",
@@ -1778,6 +1973,240 @@ fn is_noise_file(name: &str) -> bool {
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,
@@ -1833,7 +2262,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 29 个工具(18 data + 10 file + 1 http),且工具名集合稳定。
/// build_ai_tool_registry 应注册恰好 30 个工具(18 data + 11 file + 1 http),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -1846,16 +2275,17 @@ mod tests {
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
// 总量基线:29(18 data + 10 file + 1 http)。拆分前后必须一致。
// 总量基线:30(18 data + 11 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
assert_eq!(
registry.len(),
29,
"工具总数应为 29(18 data + 10 file + 1 http),实际 {}", registry.len()
30,
"工具总数应为 30(18 data + 11 file + 1 http),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 18 个(持 db):CRUD/状态机/工作流
// file 层 10 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜
// file 层 11 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (18) ──
@@ -1865,11 +2295,12 @@ mod tests {
"run_workflow", "delete_task", "create_idea",
"delete_project", "restore_project", "purge_project",
"list_trash", "get_project_count", "get_task_count",
// ── file 层 (10) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移)
// ── file 层 (11) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621
"read_file", "list_directory", "write_file",
"patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command",
"grep",
// ── http 层 (1) ──
"http_request",
];
@@ -2205,4 +2636,286 @@ mod tests {
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");
}
}