use ignore::WalkBuilder;
use regex::RegexBuilder;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use crate::cm_tools::tool_result::{SearchInFilesOutputFields, ToolError, prepend_crabmate_tool_output};
struct SearchParams {
pattern: String,
sub_path: Option<String>,
max_results: usize,
case_insensitive: bool,
ignore_hidden: bool,
context_before: usize,
context_after: usize,
file_glob: Option<String>,
exclude_glob: Option<String>,
}
const DEFAULT_MAX_RESULTS: usize = 200;
const MAX_FILE_SIZE_BYTES: usize = 2 * 1024 * 1024;
fn workspace_err(kind: &'static str, msg: String) -> ToolError {
ToolError::external_code(kind, msg)
}
#[allow(clippy::result_large_err)]
fn resolve_search_root(base: &Path, sub: Option<&str>) -> Result<PathBuf, ToolError> {
match sub {
None => Ok(base.to_path_buf()),
Some(s) => {
let sub_path = Path::new(s);
if sub_path.is_absolute() {
return Err(workspace_err(
"search_in_files_path_absolute_not_allowed",
"路径必须为相对于工作区的相对路径,不能使用绝对路径".to_string(),
));
}
let joined = base.join(sub_path);
let canon_base = base.canonicalize().map_err(|e| {
workspace_err(
"search_in_files_workspace_base_resolve_failed",
format!("工作区根目录无法解析: {}", e),
)
})?;
let canon_joined = joined.canonicalize().map_err(|e| {
workspace_err(
"search_in_files_workspace_subpath_resolve_failed",
format!("搜索路径无法解析: {}", e),
)
})?;
if !canon_joined.starts_with(&canon_base) {
return Err(workspace_err(
"search_in_files_workspace_outside_root",
"搜索路径不能超出工作区根目录".to_string(),
));
}
Ok(canon_joined)
}
}
}
#[allow(clippy::result_large_err)]
fn parse_params(args_json: &str) -> Result<SearchParams, ToolError> {
let v: serde_json::Value =
crate::cm_tools::tools::parse_args_json(args_json).map_err(ToolError::invalid_args)?;
let pattern = v
.get("pattern")
.and_then(|p| p.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::invalid_args("缺少 pattern 参数".to_string()))?;
let sub_path = v
.get("path")
.and_then(|p| p.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let max_results = v
.get("max_results")
.and_then(|m| m.as_u64())
.map(|n| n.max(1) as usize)
.unwrap_or(DEFAULT_MAX_RESULTS);
let case_insensitive = v
.get("case_insensitive")
.and_then(|b| b.as_bool())
.unwrap_or(true);
let ignore_hidden = v
.get("ignore_hidden")
.and_then(|b| b.as_bool())
.unwrap_or(true);
let context_before = v
.get("context_before")
.and_then(|n| n.as_u64())
.map(|n| n.min(10) as usize)
.unwrap_or(0);
let context_after = v
.get("context_after")
.and_then(|n| n.as_u64())
.map(|n| n.min(10) as usize)
.unwrap_or(0);
let file_glob = v
.get("file_glob")
.and_then(|g| g.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let exclude_glob = v
.get("exclude_glob")
.and_then(|g| g.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(SearchParams {
pattern,
sub_path,
max_results,
case_insensitive,
ignore_hidden,
context_before,
context_after,
file_glob,
exclude_glob,
})
}
fn load_searchable_file_text(path: &Path) -> Option<String> {
let mut f = fs::File::open(path).ok()?;
let mut buf = String::new();
f.read_to_string(&mut buf).ok()?;
if buf.len() > MAX_FILE_SIZE_BYTES {
buf = super::output_util::truncate_to_char_boundary(&buf, MAX_FILE_SIZE_BYTES);
}
Some(buf)
}
fn push_plain_match(
path: &Path,
line_no: usize,
line: &str,
results: &mut Vec<(PathBuf, usize, String)>,
max_results: usize,
) -> bool {
results.push((path.to_path_buf(), line_no, line.to_string()));
results.len() >= max_results
}
#[allow(clippy::too_many_arguments)] fn push_match_with_context(
path: &Path,
lines: &[&str],
match_idx: usize,
ctx_before: usize,
ctx_after: usize,
last_ctx_end: &mut usize,
results: &mut Vec<(PathBuf, usize, String)>,
max_results: usize,
) -> bool {
let ctx_start = match_idx.saturating_sub(ctx_before);
let ctx_end = (match_idx + ctx_after + 1).min(lines.len());
if ctx_start > *last_ctx_end && *last_ctx_end > 0 {
results.push((path.to_path_buf(), 0, "---".to_string()));
}
for (ci, ctx_line) in lines.iter().enumerate().take(ctx_end).skip(ctx_start) {
if ci < *last_ctx_end {
continue;
}
let prefix = if ci == match_idx { ">" } else { " " };
results.push((
path.to_path_buf(),
ci + 1,
format!("{} {}", prefix, ctx_line),
));
if results.len() >= max_results {
return true;
}
}
*last_ctx_end = ctx_end;
false
}
fn search_in_file(
path: &Path,
re: ®ex::Regex,
results: &mut Vec<(PathBuf, usize, String)>,
visited_files: &mut usize,
max_results: usize,
ctx_before: usize,
ctx_after: usize,
) {
*visited_files += 1;
let Some(buf) = load_searchable_file_text(path) else {
return;
};
let lines: Vec<&str> = buf.lines().collect();
let has_context = ctx_before > 0 || ctx_after > 0;
let mut last_ctx_end: usize = 0;
for (idx, line) in lines.iter().enumerate() {
if !re.is_match(line) {
continue;
}
let full = if has_context {
push_match_with_context(
path,
&lines,
idx,
ctx_before,
ctx_after,
&mut last_ctx_end,
results,
max_results,
)
} else {
push_plain_match(path, idx + 1, line, results, max_results)
};
if full {
return;
}
}
}
fn path_under_workspace_display(working_dir: &Path, abs: &Path) -> String {
let Ok(base) = crate::cm_tools::tools::file::canonical_workspace_root(working_dir) else {
return abs.display().to_string();
};
match abs.strip_prefix(&base) {
Ok(rel) => {
let s = rel.to_string_lossy().replace('\\', "/");
if s.is_empty() { ".".to_string() } else { s }
}
Err(_) => abs.display().to_string(),
}
}
struct SearchOutputHeader<'a> {
pattern: &'a str,
working_dir: &'a Path,
root: &'a Path,
match_count: usize,
files_visited: usize,
max_results: usize,
truncated: bool,
}
fn prepend_search_header(body: &str, h: SearchOutputHeader<'_>) -> String {
prepend_crabmate_tool_output(
"search_in_files",
SearchInFilesOutputFields {
pattern: h.pattern.to_string(),
root: path_under_workspace_display(h.working_dir, h.root),
match_count: h.match_count,
files_visited: h.files_visited,
max_results: h.max_results,
truncated: h.truncated,
},
body,
)
}
#[allow(clippy::result_large_err)]
fn compile_regex_for_search(params: &SearchParams) -> Result<regex::Regex, ToolError> {
RegexBuilder::new(¶ms.pattern)
.case_insensitive(params.case_insensitive)
.build()
.map_err(|e| {
ToolError::external_code(
"search_in_files_invalid_regex",
format!("错误:无效的正则表达式:{}", e),
)
})
}
#[allow(clippy::result_large_err)]
fn compile_optional_glob_pat(
raw: Option<&str>,
label: &'static str,
) -> Result<Option<glob::Pattern>, ToolError> {
let Some(g) = raw.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
glob::Pattern::new(g).map(Some).map_err(|e| {
ToolError::external_code(
"search_in_files_invalid_glob",
format!("错误:{label} 不是合法 glob 模式: {e}"),
)
})
}
struct WalkSearchOutcome {
results: Vec<(PathBuf, usize, String)>,
visited: usize,
}
fn walk_search_matches(
root: &Path,
params: &SearchParams,
re: ®ex::Regex,
file_glob_pat: Option<&glob::Pattern>,
exclude_glob_pat: Option<&glob::Pattern>,
) -> WalkSearchOutcome {
let mut results: Vec<(PathBuf, usize, String)> = Vec::new();
let mut visited = 0usize;
let walker = WalkBuilder::new(root)
.hidden(!params.ignore_hidden)
.git_ignore(true)
.git_global(false)
.git_exclude(true)
.build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let path = entry.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(pat) = file_glob_pat
&& !pat.matches(&name)
{
continue;
}
if let Some(pat) = exclude_glob_pat
&& pat.matches(&name)
{
continue;
}
search_in_file(
path,
re,
&mut results,
&mut visited,
params.max_results,
params.context_before,
params.context_after,
);
if results.len() >= params.max_results {
break;
}
}
WalkSearchOutcome { results, visited }
}
fn format_search_body_empty(
params: &SearchParams,
workspace_root: &Path,
root: &Path,
visited: usize,
) -> String {
let rel = path_under_workspace_display(workspace_root, root);
format!(
"搜索:\"{}\"\n范围:{}\n未找到匹配(共遍历 {} 个文件)",
params.pattern, rel, visited,
)
}
fn format_search_body_matches(
params: &SearchParams,
workspace_root: &Path,
root: &Path,
results: &[(PathBuf, usize, String)],
) -> String {
let rel = path_under_workspace_display(workspace_root, root);
let mut out = String::new();
out.push_str(&format!(
"搜索:\"{}\"\n范围:{}\n匹配结果(最多 {} 条,实际 {} 条):\n\n",
params.pattern,
rel,
params.max_results,
results.len()
));
for (path, line_no, line) in results.iter() {
out.push_str(&format!("{}:{}: {}\n", path.display(), line_no, line));
}
out
}
#[allow(clippy::result_large_err)]
pub fn search_in_files_try(args_json: &str, workspace_root: &Path) -> Result<String, ToolError> {
let params = parse_params(args_json)?;
let re = compile_regex_for_search(¶ms)?;
let file_glob_pat = compile_optional_glob_pat(params.file_glob.as_deref(), "file_glob")?;
let exclude_glob_pat =
compile_optional_glob_pat(params.exclude_glob.as_deref(), "exclude_glob")?;
let root = resolve_search_root(workspace_root, params.sub_path.as_deref())?;
let WalkSearchOutcome { results, visited } = walk_search_matches(
&root,
¶ms,
&re,
file_glob_pat.as_ref(),
exclude_glob_pat.as_ref(),
);
let truncated = results.len() >= params.max_results;
let match_count = results.len();
if results.is_empty() {
let body = format_search_body_empty(¶ms, workspace_root, &root, visited);
return Ok(prepend_search_header(
&body,
SearchOutputHeader {
pattern: params.pattern.as_str(),
working_dir: workspace_root,
root: &root,
match_count: 0,
files_visited: visited,
max_results: params.max_results,
truncated: false,
},
));
}
let body = format_search_body_matches(¶ms, workspace_root, &root, &results)
.trim_end()
.to_string();
Ok(prepend_search_header(
&body,
SearchOutputHeader {
pattern: params.pattern.as_str(),
working_dir: workspace_root,
root: &root,
match_count,
files_visited: visited,
max_results: params.max_results,
truncated,
},
))
}
#[cfg(test)]
mod tests {
use super::search_in_files_try;
#[test]
fn search_in_files_context_marks_match_and_neighbors() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("sample.txt"),
"alpha\nbeta\nTARGET\ngamma\ndelta\n",
)
.expect("write");
let out = search_in_files_try(
r#"{"pattern":"TARGET","context_before":1,"context_after":1,"file_glob":"*.txt"}"#,
dir.path(),
)
.expect("search");
assert!(out.contains("> TARGET"), "命中行应带 > 前缀: {out}");
assert!(out.contains(" beta"), "应含 context_before: {out}");
assert!(out.contains(" gamma"), "应含 context_after: {out}");
assert!(
!out.contains("alpha") && !out.contains("delta"),
"窗口外行不应出现: {out}"
);
}
#[test]
fn search_in_files_context_respects_max_results_and_separator() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("sample.txt"),
"a\nHIT_ONE\nb\nc\nd\ne\nf\ng\nHIT_TWO\nh\n",
)
.expect("write");
let out = search_in_files_try(
r#"{"pattern":"HIT_","context_before":1,"context_after":1,"max_results":5,"file_glob":"*.txt"}"#,
dir.path(),
)
.expect("search");
let header = out.lines().next().expect("header");
let v: serde_json::Value = serde_json::from_str(header).expect("header json");
assert_eq!(v["tool"], "search_in_files");
assert_eq!(v["truncated"], true);
assert_eq!(v["max_results"], 5);
assert!(
v["match_count"].as_u64().unwrap_or(0) <= 5,
"match_count 不应超过 max_results: {v}"
);
assert!(out.contains("---"), "不相邻上下文块之间应有分隔符: {out}");
assert!(out.contains("> HIT_ONE"), "应含第一处命中: {out}");
}
}