1use anyhow::Result;
2use async_trait::async_trait;
3use globset::{Glob, GlobMatcher};
4use ignore::WalkBuilder;
5use serde::Deserialize;
6use serde_json::json;
7
8use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
9
10pub struct SearchReplaceTool;
11
12#[derive(Deserialize)]
13struct SearchReplaceArgs {
14 search: String,
16 replace: String,
18 #[serde(default)]
20 glob: Option<String>,
21 #[serde(default)]
23 path: Option<String>,
24 #[serde(default)]
26 regex: bool,
27}
28
29#[async_trait]
30impl Tool for SearchReplaceTool {
31 fn definition(&self) -> ToolDef {
32 ToolDef {
33 name: "search_replace",
34 description: "Search and replace text across multiple files. Replaces ALL occurrences in ALL matching files.\n\
35 When to use:\n\
36 - Rename a CSS class, variable, or import across the entire project\n\
37 - Change colors, sizes, or other repeated values in bulk\n\
38 - Migrate API endpoints, config keys, or string literals\n\
39 - Any change that affects many files with the same pattern\n\
40 When NOT to use:\n\
41 - Editing a single file: use edit_file instead\n\
42 - Complex structural refactoring: use edit_file per file\n\
43 Examples:\n\
44 - Change color: {\"search\": \"bg-blue-600\", \"replace\": \"bg-violet-600\", \"glob\": \"*.vue\"}\n\
45 - Rename class: {\"search\": \"rounded-2xl\", \"replace\": \"rounded-lg\", \"glob\": \"*.vue\"}\n\
46 - Regex rename: {\"search\": \"bg-blue-(\\\\d+)\", \"replace\": \"bg-violet-$1\", \"glob\": \"*.vue\", \"regex\": true}".to_string(),
47 parameters: json!({
48 "type": "object",
49 "properties": {
50 "search": { "type": "string", "description": "Text or regex pattern to find" },
51 "replace": { "type": "string", "description": "Replacement text (use $1, $2 for regex captures)" },
52 "glob": { "type": "string", "description": "File pattern to limit scope, e.g. \"*.vue\", \"*.css\" (default: all files)" },
53 "path": { "type": "string", "description": "Directory to search in (default: working directory)" },
54 "regex": { "type": "boolean", "description": "Use regex matching (default: false = literal)" }
55 },
56 "required": ["search", "replace"]
57 }),
58 }
59 }
60
61 fn validate_args(&self, args: &str) -> std::result::Result<(), String> {
62 serde_json::from_str::<SearchReplaceArgs>(args)
63 .map(|_| ())
64 .map_err(|e| format!(
65 "{} (could not parse search_replace arguments; check `search` and `replace` are present)",
66 e
67 ))
68 }
69
70 fn approval(&self, _args: &str) -> ApprovalRequirement {
71 ApprovalRequirement::AutoApprove
75 }
76
77 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
78 let parsed = match serde_json::from_str::<SearchReplaceArgs>(args) {
79 Ok(parsed) => parsed,
80 Err(_) => return self.approval(args),
81 };
82 let working_dir = match ctx.working_dir.try_read() {
83 Ok(wd) => wd.clone(),
84 Err(_) => return self.approval(args),
85 };
86 let raw_path = parsed.path.as_deref().unwrap_or(".");
87 match super::approval_for_path(raw_path, &working_dir, super::ExternalPathAction::Write) {
88 Ok(approval) => approval,
89 Err(_) => self.approval(args),
90 }
91 }
92
93 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
94 let parsed: SearchReplaceArgs = serde_json::from_str(args)?;
95 let wd = ctx.working_dir.read().await.clone();
96 let search_dir =
97 match super::inspect_path_access(parsed.path.as_deref().unwrap_or("."), &wd) {
98 Ok(access) => access.path,
99 Err(err) => {
100 return Ok(ToolResult {
101 call_id: String::new(),
102 output: err.to_string(),
103 success: false,
104 });
105 }
106 };
107
108 if !search_dir.exists() {
109 return Ok(ToolResult {
110 call_id: String::new(),
111 output: format!("Directory not found: {}", search_dir.display()),
112 success: false,
113 });
114 }
115
116 let re = if parsed.regex {
118 match regex::Regex::new(&parsed.search) {
119 Ok(r) => r,
120 Err(e) => {
121 return Ok(ToolResult {
122 call_id: String::new(),
123 output: format!("Invalid regex '{}': {}", parsed.search, e),
124 success: false,
125 });
126 }
127 }
128 } else {
129 regex::Regex::new(®ex::escape(&parsed.search)).unwrap()
131 };
132
133 let glob_filter = match parsed.glob.as_deref() {
134 Some(pattern) => match FileGlob::new(pattern) {
135 Ok(filter) => Some(filter),
136 Err(e) => {
137 return Ok(ToolResult {
138 call_id: String::new(),
139 output: format!("Invalid glob '{}': {}", pattern, e),
140 success: false,
141 });
142 }
143 },
144 None => None,
145 };
146
147 let mut walker = WalkBuilder::new(&search_dir);
149 walker.hidden(true).git_ignore(true);
150 let walk = walker.build();
151
152 let mut total_replacements = 0usize;
153 let mut files_modified = Vec::new();
154 let mut files_scanned = 0usize;
155
156 for entry in walk {
157 let entry = match entry {
158 Ok(e) => e,
159 Err(_) => continue,
160 };
161
162 if !entry.file_type().map_or(false, |ft| ft.is_file()) {
163 continue;
164 }
165
166 let file_path = entry.path();
167
168 if let Some(ref filter) = glob_filter {
169 if !filter.is_match(file_path, &search_dir) {
170 continue;
171 }
172 }
173
174 let content = match std::fs::read_to_string(file_path) {
176 Ok(c) => c,
177 Err(_) => continue, };
179
180 files_scanned += 1;
181
182 if !re.is_match(&content) {
184 continue;
185 }
186
187 let count = re.find_iter(&content).count();
189 let new_content = re
190 .replace_all(&content, parsed.replace.as_str())
191 .to_string();
192
193 if new_content != content {
194 ctx.file_history
196 .lock()
197 .await
198 .backup_before_write(&file_path.to_string_lossy())
199 .await;
200 if let Err(e) = std::fs::write(file_path, &new_content) {
202 return Ok(ToolResult {
203 call_id: String::new(),
204 output: format!("Failed to write {}: {}", file_path.display(), e),
205 success: false,
206 });
207 }
208 let canon = std::fs::canonicalize(file_path)
215 .unwrap_or_else(|_| file_path.to_path_buf());
216 ctx.notify_lsp_file_changed(&canon, &new_content).await;
218 ctx.file_store.write().await.invalidate(&canon);
221 total_replacements += count;
222 files_modified.push(format!(
223 " {} ({} replacements)",
224 file_path.display(),
225 count
226 ));
227 }
228 }
229
230 if files_modified.is_empty() {
231 return Ok(ToolResult {
232 call_id: String::new(),
233 output: format!(
234 "No matches found for '{}' in {} ({} files scanned)",
235 parsed.search,
236 search_dir.display(),
237 files_scanned,
238 ),
239 success: false,
240 });
241 }
242
243 let output = format!(
244 "Replaced '{}' → '{}': {} replacements across {} files.\n{}",
245 parsed.search,
246 parsed.replace,
247 total_replacements,
248 files_modified.len(),
249 files_modified.join("\n"),
250 );
251
252 Ok(ToolResult {
253 call_id: String::new(),
254 output,
255 success: true,
256 })
257 }
258}
259
260struct FileGlob {
261 pattern_has_path: bool,
262 matcher: GlobMatcher,
263}
264
265impl FileGlob {
266 fn new(pattern: &str) -> std::result::Result<Self, globset::Error> {
267 let normalized = pattern.replace('\\', "/");
268 Ok(Self {
269 pattern_has_path: normalized.contains('/'),
270 matcher: Glob::new(&normalized)?.compile_matcher(),
271 })
272 }
273
274 fn is_match(&self, file_path: &std::path::Path, search_dir: &std::path::Path) -> bool {
275 let candidate = if self.pattern_has_path {
276 file_path.strip_prefix(search_dir).unwrap_or(file_path)
277 } else {
278 match file_path.file_name().and_then(|name| name.to_str()) {
279 Some(name) => return self.matcher.is_match(name),
280 None => return false,
281 }
282 };
283
284 let normalized = candidate.to_string_lossy().replace('\\', "/");
285 self.matcher.is_match(normalized)
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::tool::{Tool, ToolContext};
293 use tempfile::TempDir;
294
295 #[tokio::test]
296 async fn search_replace_path_glob_matches_relative_paths() {
297 let dir = TempDir::new().unwrap();
298 std::fs::create_dir_all(dir.path().join("src")).unwrap();
299 std::fs::create_dir_all(dir.path().join("tests")).unwrap();
300 std::fs::write(dir.path().join("src/app.ts"), "const v = 'needle';\n").unwrap();
301 std::fs::write(dir.path().join("tests/app.ts"), "const v = 'needle';\n").unwrap();
302
303 let ctx = ToolContext::new(dir.path().to_path_buf());
304 let args = r#"{"search":"needle","replace":"replaced","glob":"src/**/*.ts"}"#;
305 let result = SearchReplaceTool.execute(args, &ctx).await.unwrap();
306
307 assert!(result.success, "{}", result.output);
308 assert_eq!(
309 std::fs::read_to_string(dir.path().join("src/app.ts")).unwrap(),
310 "const v = 'replaced';\n"
311 );
312 assert_eq!(
313 std::fs::read_to_string(dir.path().join("tests/app.ts")).unwrap(),
314 "const v = 'needle';\n"
315 );
316 }
317
318 #[tokio::test]
319 async fn search_replace_filename_glob_still_matches_nested_files() {
320 let dir = TempDir::new().unwrap();
321 std::fs::create_dir_all(dir.path().join("src")).unwrap();
322 std::fs::write(dir.path().join("src/app.ts"), "const v = 'needle';\n").unwrap();
323 std::fs::write(dir.path().join("src/app.md"), "needle\n").unwrap();
324
325 let ctx = ToolContext::new(dir.path().to_path_buf());
326 let args = r#"{"search":"needle","replace":"replaced","glob":"*.ts"}"#;
327 let result = SearchReplaceTool.execute(args, &ctx).await.unwrap();
328
329 assert!(result.success, "{}", result.output);
330 assert_eq!(
331 std::fs::read_to_string(dir.path().join("src/app.ts")).unwrap(),
332 "const v = 'replaced';\n"
333 );
334 assert_eq!(
335 std::fs::read_to_string(dir.path().join("src/app.md")).unwrap(),
336 "needle\n"
337 );
338 }
339}