1use async_trait::async_trait;
4use regex::Regex;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7use walkdir::WalkDir;
8
9use super::{resolve_within, Tool};
10use crate::error::{Error, Result};
11use crate::llm::ToolSpec;
12
13const DEFAULT_MAX_RESULTS: usize = 50;
14const DEFAULT_MAX_LINE_LEN: usize = 240;
15
16#[derive(Debug, Clone)]
17pub struct SearchFiles {
18 pub root: PathBuf,
19 pub max_results: usize,
20}
21
22impl SearchFiles {
23 pub fn new(root: impl Into<PathBuf>) -> Self {
24 Self {
25 root: root.into(),
26 max_results: DEFAULT_MAX_RESULTS,
27 }
28 }
29}
30
31#[async_trait]
32impl Tool for SearchFiles {
33 fn spec(&self) -> ToolSpec {
34 ToolSpec {
35 name: "search_files".into(),
36 description:
37 "Find lines containing a pattern (literal substring or regex) across files in the workspace. Returns up to N matches as 'path:line: text'."
38 .into(),
39 parameters: json!({
40 "type": "object",
41 "properties": {
42 "pattern": { "type": "string", "description": "Pattern to search for. Literal substring by default; use `regex: true` for regex mode." },
43 "path": { "type": "string", "description": "Optional subdirectory (workspace-relative) to scope the search to. Defaults to workspace root." },
44 "max_results": { "type": "integer", "description": "Cap on results (default 50, max 200)." },
45 "regex": { "type": "boolean", "description": "If true, interpret `pattern` as a regular expression (Rust regex crate syntax). Default false (literal substring)." },
46 "case_insensitive": { "type": "boolean", "description": "If true, matching ignores ASCII case. Works in both literal and regex modes; in regex mode this is equivalent to wrapping the pattern in `(?i:...)`. Default false." }
47 },
48 "required": ["pattern"]
49 }),
50 }
51 }
52
53 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
54 crate::tools::ToolSideEffect::ReadOnly
55 }
56
57 async fn execute(&self, args: Value) -> Result<String> {
58 let pattern = args["pattern"].as_str().ok_or_else(|| Error::BadToolArgs {
59 name: "search_files".into(),
60 message: "missing `pattern`".into(),
61 })?;
62 if pattern.is_empty() {
63 return Err(Error::BadToolArgs {
64 name: "search_files".into(),
65 message: "`pattern` must not be empty".into(),
66 });
67 }
68
69 let use_regex = args.get("regex").and_then(|v| v.as_bool()).unwrap_or(false);
70 let case_insensitive = args
71 .get("case_insensitive")
72 .and_then(|v| v.as_bool())
73 .unwrap_or(false);
74 let re_opt: Option<Regex> = if use_regex {
75 let regex = if case_insensitive {
76 regex::RegexBuilder::new(pattern)
77 .case_insensitive(true)
78 .build()
79 } else {
80 Regex::new(pattern)
81 };
82 Some(regex.map_err(|e| Error::BadToolArgs {
83 name: "search_files".into(),
84 message: format!("invalid regex: {e}"),
85 })?)
86 } else {
87 None
88 };
89
90 let scope = match args.get("path").and_then(|v| v.as_str()) {
91 Some(p) => resolve_within(&self.root, p).map_err(|e| Error::BadToolArgs {
92 name: "search_files".into(),
93 message: format!("path: {e}"),
94 })?,
95 None => self.root.clone(),
96 };
97
98 let cap = args
99 .get("max_results")
100 .and_then(|v| v.as_u64())
101 .map(|n| (n as usize).min(200))
102 .unwrap_or(self.max_results);
103
104 let mut hits: Vec<String> = Vec::new();
105 'outer: for entry in WalkDir::new(&scope)
106 .into_iter()
107 .filter_map(|e| e.ok())
108 .filter(|e| e.file_type().is_file())
109 {
110 let path = entry.path();
111 if path
113 .extension()
114 .and_then(|e| e.to_str())
115 .map(|e| {
116 matches!(
117 e,
118 "png" | "jpg" | "jpeg" | "gif" | "pdf" | "zip" | "gz" | "tar" | "bin"
119 )
120 })
121 .unwrap_or(false)
122 {
123 continue;
124 }
125 let Ok(contents) = std::fs::read_to_string(path) else {
126 continue;
127 };
128 let rel = path.strip_prefix(&self.root).unwrap_or(path);
129 for (line_no, line) in contents.lines().enumerate() {
130 let is_match = match &re_opt {
131 Some(re) => re.is_match(line),
132 None => {
133 if case_insensitive {
134 line.to_ascii_lowercase()
135 .contains(&pattern.to_ascii_lowercase())
136 } else {
137 line.contains(pattern)
138 }
139 }
140 };
141 if is_match {
142 let truncated = if line.len() > DEFAULT_MAX_LINE_LEN {
143 let mut end = DEFAULT_MAX_LINE_LEN;
144 while !line.is_char_boundary(end) {
145 end -= 1;
146 }
147 format!("{}…", &line[..end])
148 } else {
149 line.to_string()
150 };
151 hits.push(format!("{}:{}: {}", rel.display(), line_no + 1, truncated));
152 if hits.len() >= cap {
153 break 'outer;
154 }
155 }
156 }
157 }
158
159 if hits.is_empty() {
160 Ok(format!("no matches for `{pattern}`"))
161 } else {
162 Ok(hits.join("\n"))
163 }
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::TempDir;
171
172 fn write(dir: &TempDir, name: &str, body: &str) {
173 std::fs::write(dir.path().join(name), body).unwrap();
174 }
175
176 #[tokio::test]
177 async fn finds_matches_with_path_and_line_number() {
178 let tmp = TempDir::new().unwrap();
179 write(&tmp, "a.txt", "foo\nbar\nbaz");
180 write(&tmp, "b.txt", "bar quux");
181 let out = SearchFiles::new(tmp.path())
182 .execute(json!({"pattern": "bar"}))
183 .await
184 .unwrap();
185 assert!(out.contains("a.txt:2: bar"));
186 assert!(out.contains("b.txt:1: bar quux"));
187 }
188
189 #[tokio::test]
190 async fn empty_pattern_is_rejected() {
191 let tmp = TempDir::new().unwrap();
192 let err = SearchFiles::new(tmp.path())
193 .execute(json!({"pattern": ""}))
194 .await
195 .unwrap_err();
196 assert!(matches!(err, Error::BadToolArgs { .. }));
197 }
198
199 #[tokio::test]
200 async fn returns_no_match_message_when_empty() {
201 let tmp = TempDir::new().unwrap();
202 write(&tmp, "a.txt", "hello world");
203 let out = SearchFiles::new(tmp.path())
204 .execute(json!({"pattern": "zzzz"}))
205 .await
206 .unwrap();
207 assert!(out.contains("no matches"));
208 }
209
210 #[tokio::test]
211 async fn respects_max_results_cap() {
212 let tmp = TempDir::new().unwrap();
213 let body: String = (0..10).map(|_| "needle\n").collect();
214 write(&tmp, "many.txt", &body);
215 let out = SearchFiles::new(tmp.path())
216 .execute(json!({"pattern": "needle", "max_results": 3}))
217 .await
218 .unwrap();
219 assert_eq!(out.lines().count(), 3);
220 }
221
222 #[tokio::test]
223 async fn path_argument_scopes_search() {
224 let tmp = TempDir::new().unwrap();
225 std::fs::create_dir(tmp.path().join("sub")).unwrap();
226 write(&tmp, "outside.txt", "hit");
227 std::fs::write(tmp.path().join("sub/inside.txt"), "hit").unwrap();
228 let out = SearchFiles::new(tmp.path())
229 .execute(json!({"pattern": "hit", "path": "sub"}))
230 .await
231 .unwrap();
232 assert!(out.contains("inside.txt"));
233 assert!(!out.contains("outside.txt"));
234 }
235 #[tokio::test]
236 async fn regex_mode_matches_pattern() {
237 let tmp = TempDir::new().unwrap();
238 write(&tmp, "lib.rs", "fn foo() {}\nfn bar() {}\nfn foobar() {}");
239 let out = SearchFiles::new(tmp.path())
240 .execute(json!({"pattern": "fn f\\w+", "regex": true}))
241 .await
242 .unwrap();
243 assert!(out.contains("foo"));
244 assert!(out.contains("foobar"));
245 assert!(!out.contains(": fn bar()"));
246 }
247 #[tokio::test]
248 async fn regex_mode_invalid_pattern_is_bad_args() {
249 let tmp = TempDir::new().unwrap();
250 let err = SearchFiles::new(tmp.path())
251 .execute(json!({"pattern": "(unclosed", "regex": true}))
252 .await
253 .unwrap_err();
254 assert!(matches!(err, Error::BadToolArgs { .. }));
255 assert!(format!("{err}").contains("invalid regex"));
256 }
257 #[tokio::test]
258 async fn literal_mode_treats_pattern_as_substring() {
259 let tmp = TempDir::new().unwrap();
260 write(&tmp, "data.txt", "abc\nadc");
261 let out = SearchFiles::new(tmp.path())
262 .execute(json!({"pattern": "a.c"}))
263 .await
264 .unwrap();
265 assert!(out.contains("no matches"));
266 }
267 #[tokio::test]
268 async fn regex_mode_with_path_scope() {
269 let tmp = TempDir::new().unwrap();
270 std::fs::create_dir(tmp.path().join("src")).unwrap();
271 write(&tmp, "outside.txt", "fn main()");
272 std::fs::write(tmp.path().join("src/lib.rs"), "fn helper()\nfn main()").unwrap();
273 let out = SearchFiles::new(tmp.path())
274 .execute(json!({"pattern": "fn \\w+", "regex": true, "path": "src"}))
275 .await
276 .unwrap();
277 assert!(out.contains("helper"));
278 assert!(out.contains("main"));
279 assert!(!out.contains("outside.txt"));
280 }
281
282 #[tokio::test]
284 async fn literal_mode_case_insensitive_finds_match() {
285 let tmp = TempDir::new().unwrap();
286 write(
287 &tmp,
288 "todo.txt",
289 "TODO: fix this
290todo: done",
291 );
292 let out = SearchFiles::new(tmp.path())
293 .execute(json!({"pattern": "TODO", "case_insensitive": true}))
294 .await
295 .unwrap();
296 assert!(out.contains("TODO: fix this"));
297 assert!(out.contains("todo: done"));
298 }
299
300 #[tokio::test]
301 async fn regex_mode_case_insensitive_finds_match() {
302 let tmp = TempDir::new().unwrap();
303 write(
304 &tmp,
305 "test.txt",
306 "foo123
307bar456",
308 );
309 let out = SearchFiles::new(tmp.path())
310 .execute(json!({"pattern": r"FOO\d+", "regex": true, "case_insensitive": true}))
311 .await
312 .unwrap();
313 assert!(out.contains("foo123"));
314 assert!(!out.contains("bar456"));
315 }
316
317 #[tokio::test]
318 async fn case_sensitive_by_default() {
319 let tmp = TempDir::new().unwrap();
320 write(
321 &tmp,
322 "mixed.txt",
323 "TODO
324todo
325Todo",
326 );
327 let out = SearchFiles::new(tmp.path())
329 .execute(json!({"pattern": "TODO"}))
330 .await
331 .unwrap();
332 assert!(out.contains("TODO"));
333 assert!(!out.contains("todo"));
334 assert!(!out.contains("Todo"));
335 }
336
337 #[tokio::test]
341 async fn long_line_with_multibyte_unicode_does_not_panic() {
342 let tmp = TempDir::new().unwrap();
343
344 let mut long_line = String::from("search_target ");
348 while long_line.len() <= 240 {
349 long_line.push('─');
350 }
351 let body = format!("{long_line}\nno match here\n");
353 write(&tmp, "unicode.txt", &body);
354
355 let out = SearchFiles::new(tmp.path())
357 .execute(json!({"pattern": "search_target"}))
358 .await
359 .unwrap();
360 assert!(out.contains("unicode.txt:1:"));
361 assert!(out.contains("search_target"));
362 }
363
364 #[tokio::test]
367 async fn jsonl_line_with_unicode_box_drawing_does_not_panic() {
368 let tmp = TempDir::new().unwrap();
369 let separator = "─".repeat(60); let content_field =
372 format!("pub struct AgentRuntime{{}}\\n// {separator}─────────────────────────");
373 let line = format!(
374 "{{\"role\":\"tool\",\"content\":\"# range: lines 1-50 of 200\\\\n{content_field}\"}}"
375 );
376 assert!(
377 line.len() > 240,
378 "test line must exceed truncation limit; got {}",
379 line.len()
380 );
381 write(&tmp, "transcript.jsonl", &line);
382
383 let out = SearchFiles::new(tmp.path())
384 .execute(json!({"pattern": "pub struct AgentRuntime"}))
385 .await
386 .unwrap();
387 assert!(out.contains("transcript.jsonl:1:"));
388 }
389}