修复: 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:
@@ -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: ®ex::Regex,
|
||||
glob: Option<®ex::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);
|
||||
|
||||
Reference in New Issue
Block a user