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 is_readonly(&self) -> bool {
54 true
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 format!("{}…", &line[..DEFAULT_MAX_LINE_LEN])
144 } else {
145 line.to_string()
146 };
147 hits.push(format!("{}:{}: {}", rel.display(), line_no + 1, truncated));
148 if hits.len() >= cap {
149 break 'outer;
150 }
151 }
152 }
153 }
154
155 if hits.is_empty() {
156 Ok(format!("no matches for `{pattern}`"))
157 } else {
158 Ok(hits.join("\n"))
159 }
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use tempfile::TempDir;
167
168 fn write(dir: &TempDir, name: &str, body: &str) {
169 std::fs::write(dir.path().join(name), body).unwrap();
170 }
171
172 #[tokio::test]
173 async fn finds_matches_with_path_and_line_number() {
174 let tmp = TempDir::new().unwrap();
175 write(&tmp, "a.txt", "foo\nbar\nbaz");
176 write(&tmp, "b.txt", "bar quux");
177 let out = SearchFiles::new(tmp.path())
178 .execute(json!({"pattern": "bar"}))
179 .await
180 .unwrap();
181 assert!(out.contains("a.txt:2: bar"));
182 assert!(out.contains("b.txt:1: bar quux"));
183 }
184
185 #[tokio::test]
186 async fn empty_pattern_is_rejected() {
187 let tmp = TempDir::new().unwrap();
188 let err = SearchFiles::new(tmp.path())
189 .execute(json!({"pattern": ""}))
190 .await
191 .unwrap_err();
192 assert!(matches!(err, Error::BadToolArgs { .. }));
193 }
194
195 #[tokio::test]
196 async fn returns_no_match_message_when_empty() {
197 let tmp = TempDir::new().unwrap();
198 write(&tmp, "a.txt", "hello world");
199 let out = SearchFiles::new(tmp.path())
200 .execute(json!({"pattern": "zzzz"}))
201 .await
202 .unwrap();
203 assert!(out.contains("no matches"));
204 }
205
206 #[tokio::test]
207 async fn respects_max_results_cap() {
208 let tmp = TempDir::new().unwrap();
209 let body: String = (0..10).map(|_| "needle\n").collect();
210 write(&tmp, "many.txt", &body);
211 let out = SearchFiles::new(tmp.path())
212 .execute(json!({"pattern": "needle", "max_results": 3}))
213 .await
214 .unwrap();
215 assert_eq!(out.lines().count(), 3);
216 }
217
218 #[tokio::test]
219 async fn path_argument_scopes_search() {
220 let tmp = TempDir::new().unwrap();
221 std::fs::create_dir(tmp.path().join("sub")).unwrap();
222 write(&tmp, "outside.txt", "hit");
223 std::fs::write(tmp.path().join("sub/inside.txt"), "hit").unwrap();
224 let out = SearchFiles::new(tmp.path())
225 .execute(json!({"pattern": "hit", "path": "sub"}))
226 .await
227 .unwrap();
228 assert!(out.contains("inside.txt"));
229 assert!(!out.contains("outside.txt"));
230 }
231 #[tokio::test]
232 async fn regex_mode_matches_pattern() {
233 let tmp = TempDir::new().unwrap();
234 write(&tmp, "lib.rs", "fn foo() {}\nfn bar() {}\nfn foobar() {}");
235 let out = SearchFiles::new(tmp.path())
236 .execute(json!({"pattern": "fn f\\w+", "regex": true}))
237 .await
238 .unwrap();
239 assert!(out.contains("foo"));
240 assert!(out.contains("foobar"));
241 assert!(!out.contains(": fn bar()"));
242 }
243 #[tokio::test]
244 async fn regex_mode_invalid_pattern_is_bad_args() {
245 let tmp = TempDir::new().unwrap();
246 let err = SearchFiles::new(tmp.path())
247 .execute(json!({"pattern": "(unclosed", "regex": true}))
248 .await
249 .unwrap_err();
250 assert!(matches!(err, Error::BadToolArgs { .. }));
251 assert!(format!("{err}").contains("invalid regex"));
252 }
253 #[tokio::test]
254 async fn literal_mode_treats_pattern_as_substring() {
255 let tmp = TempDir::new().unwrap();
256 write(&tmp, "data.txt", "abc\nadc");
257 let out = SearchFiles::new(tmp.path())
258 .execute(json!({"pattern": "a.c"}))
259 .await
260 .unwrap();
261 assert!(out.contains("no matches"));
262 }
263 #[tokio::test]
264 async fn regex_mode_with_path_scope() {
265 let tmp = TempDir::new().unwrap();
266 std::fs::create_dir(tmp.path().join("src")).unwrap();
267 write(&tmp, "outside.txt", "fn main()");
268 std::fs::write(tmp.path().join("src/lib.rs"), "fn helper()\nfn main()").unwrap();
269 let out = SearchFiles::new(tmp.path())
270 .execute(json!({"pattern": "fn \\w+", "regex": true, "path": "src"}))
271 .await
272 .unwrap();
273 assert!(out.contains("helper"));
274 assert!(out.contains("main"));
275 assert!(!out.contains("outside.txt"));
276 }
277
278 #[tokio::test]
280 async fn literal_mode_case_insensitive_finds_match() {
281 let tmp = TempDir::new().unwrap();
282 write(
283 &tmp,
284 "todo.txt",
285 "TODO: fix this
286todo: done",
287 );
288 let out = SearchFiles::new(tmp.path())
289 .execute(json!({"pattern": "TODO", "case_insensitive": true}))
290 .await
291 .unwrap();
292 assert!(out.contains("TODO: fix this"));
293 assert!(out.contains("todo: done"));
294 }
295
296 #[tokio::test]
297 async fn regex_mode_case_insensitive_finds_match() {
298 let tmp = TempDir::new().unwrap();
299 write(
300 &tmp,
301 "test.txt",
302 "foo123
303bar456",
304 );
305 let out = SearchFiles::new(tmp.path())
306 .execute(json!({"pattern": r"FOO\d+", "regex": true, "case_insensitive": true}))
307 .await
308 .unwrap();
309 assert!(out.contains("foo123"));
310 assert!(!out.contains("bar456"));
311 }
312
313 #[tokio::test]
314 async fn case_sensitive_by_default() {
315 let tmp = TempDir::new().unwrap();
316 write(
317 &tmp,
318 "mixed.txt",
319 "TODO
320todo
321Todo",
322 );
323 let out = SearchFiles::new(tmp.path())
325 .execute(json!({"pattern": "TODO"}))
326 .await
327 .unwrap();
328 assert!(out.contains("TODO"));
329 assert!(!out.contains("todo"));
330 assert!(!out.contains("Todo"));
331 }
332}