Skip to main content

atomcode_core/tool/
search_replace.rs

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 pattern (literal string by default, regex if `regex` is true)
15    search: String,
16    /// Replacement string (supports regex capture groups $1, $2 if regex mode)
17    replace: String,
18    /// File glob pattern to limit scope, e.g. "*.vue", "*.css", "*.ts" (default: all files)
19    #[serde(default)]
20    glob: Option<String>,
21    /// Directory to search in (default: working directory)
22    #[serde(default)]
23    path: Option<String>,
24    /// Use regex mode (default: false = literal string matching)
25    #[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        // Bulk replacement across files is potentially destructive
72        // Auto-approve: search_replace is safe (literal/regex matching, respects .gitignore).
73        // Requiring approval caused the model to avoid it and use 30+ edit_file calls instead.
74        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        // Build regex or literal matcher
117        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            // Escape the literal string to use as regex
130            regex::Regex::new(&regex::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        // Walk files
148        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            // Read file
175            let content = match std::fs::read_to_string(file_path) {
176                Ok(c) => c,
177                Err(_) => continue, // skip binary/unreadable files
178            };
179
180            files_scanned += 1;
181
182            // Quick check: does the file contain the search pattern?
183            if !re.is_match(&content) {
184                continue;
185            }
186
187            // Count and replace
188            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                // Backup before write
195                ctx.file_history
196                    .lock()
197                    .await
198                    .backup_before_write(&file_path.to_string_lossy())
199                    .await;
200                // Write back
201                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                // Canonicalize once so downstream by-path lookups
209                // (FileStore, LSP) match what read.rs originally
210                // stored — `entry.path()` from the directory walk
211                // can be the un-resolved symlink form (e.g. macOS
212                // `/var/...` vs `/private/var/...`), which would
213                // miss the FileStore key inserted by read_file.
214                let canon = std::fs::canonicalize(file_path)
215                    .unwrap_or_else(|_| file_path.to_path_buf());
216                // Notify LSP that file changed (if LSP is enabled).
217                ctx.notify_lsp_file_changed(&canon, &new_content).await;
218                // D3: drop any FileStore entry — peek_file against an
219                // old store_id reports stale, forcing fresh read_file.
220                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}