修复: read_file/grep 工具结果处理

BUG-260625-01 grep 单文件路径走 grep_one_file(旧 read_dir 报 os error 267 目录无效);BUG-260625-02 read_file search 模式渲染命中行 matches(旧取 content 显 0 行误判读取失败)。
This commit is contained in:
2026-06-25 00:27:02 +08:00
parent 24c3fa08f1
commit 047b3b91fa
3 changed files with 156 additions and 103 deletions

View File

@@ -1680,7 +1680,7 @@ fn register_file_tools(
})
};
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", "跨文件内容搜索(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::Low,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
@@ -1722,11 +1722,27 @@ fn register_file_tools(
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?;
// BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
let root_meta = tokio::fs::metadata(root).await
.map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?;
if root_meta.is_file() {
let file_name = std::path::Path::new(root)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
grep_one_file(
std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode,
context_lines, max_results, &mut matches_out, &mut total, &mut truncated,
).await?;
} else {
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 {
@@ -2304,102 +2320,123 @@ fn grep_recursive<'a>(
}
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,
});
}
// 文件处理(BUG-260625-01:提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
}
Ok(())
})
}
/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
/// 提取自 grep_recursive 循环体(BUG-260625-01),供 grep handler 单文件路径复用——
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
async fn grep_one_file(
path: &std::path::Path,
file_name: &str,
re: &regex::Regex,
glob: Option<&regex::Regex>,
output_mode: &str,
context_lines: usize,
max_results: usize,
out: &mut Vec<FileGrepHit>,
total: &mut usize,
truncated: &mut bool,
) -> anyhow::Result<()> {
// 噪音文件(.bak/.tmp-write 等编辑器产物)跳过
if is_noise_file(file_name) {
return Ok(());
}
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
if let Some(g) = glob {
if !g.is_match(file_name) {
return Ok(());
}
}
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
let metadata = match tokio::fs::metadata(path).await {
Ok(m) => m,
Err(_) => return Ok(()),
};
if metadata.len() > 1_048_576 {
return Ok(()); // >1MB 跳过(对齐 read_file 上限)
}
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(_) => return Ok(()),
};
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
if bytes.iter().take(8192).any(|&b| b == 0) {
return Ok(());
}
// 转 UTF-8(lossy 容错非 UTF-8 残片,二进制已挡多数情况)
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
// files_with_matches 模式:仅记文件级命中,不收集行详情。
// content/count 模式:行级命中,需逐行检查 max_results 截断(单文件可能多行命中,
// 超过 max_results 的行只计入 total 不入 out)。
if output_mode == "files_with_matches" {
// 检查文件数是否已达上限:达则本文件只计入 total 不入 out(设截断)
let mut has_hit = false;
for line in lines.iter() {
if re.is_match(line) {
*total += 1;
has_hit = true;
}
}
if !has_hit {
return Ok(()); // 本文件无命中
}
if out.len() >= max_results {
*truncated = true; // 已达文件数上限,只计入 total
} else {
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches: Vec::new(), // files_with_matches 不收集行详情
});
}
return Ok(());
}
// content/count 模式:逐行收集,达 max_results 行数上限即截断(剩余行只计 total)
let mut line_matches: Vec<LineMatch> = Vec::new();
let mut file_has_hit = false;
// 已收集命中行累计计数(O(1) 维护):初值=前序文件累计收集数(本文件内逐次自增)
// 取代每次命中全量重算 out.iter().map(...).sum() 的 O(n²) 统计。
let mut collected = out.iter().map(|h| h.line_matches.len()).sum::<usize>();
for (idx, line) in lines.iter().enumerate() {
if re.is_match(line) {
*total += 1;
file_has_hit = true;
// 当前已收集行数达上限 → 截断,不再收集
if collected >= max_results {
*truncated = true;
} else {
let context = if context_lines > 0 {
let start = idx.saturating_sub(context_lines);
let end = (idx + context_lines + 1).min(lines.len());
lines[start..end].join("\n")
} else {
String::new()
};
line_matches.push(LineMatch {
line: idx + 1, // 1-based
content: line.to_string(),
context,
});
collected += 1; // 命中收集后累计计数自增(O(1))
}
}
}
if file_has_hit && !line_matches.is_empty() {
// 本文件有命中且有收集(全截断的文件不入 out,避免空 line_matches 污染)
out.push(FileGrepHit {
file: path.to_string_lossy().into_owned(),
line_matches,
});
}
Ok(())
}
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
fn search_files_recursive<'a>(
path: &'a str,
@@ -2695,7 +2732,7 @@ mod tests {
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
@@ -2722,7 +2759,7 @@ mod tests {
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
let mut persistent = std::collections::HashSet::new();
persistent.insert(tmp.clone());
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);