1use crate::chat::events::{ToolExecutionResult, ToolRequest as ToolRequestEvent, ToolRequestType};
2use crate::file::access::FileAccessManager;
3use crate::file::find::{self, find_closest_match};
4use crate::file::manager::FileModificationManager;
5use crate::tools::r#trait::{
6 ContinuationPreference, FileModification, FileOperation, ToolCallHandle, ToolCategory,
7 ToolExecutor, ToolOutput, ToolRequest,
8};
9use crate::tools::ToolName;
10use anyhow::{bail, Result};
11use serde::Deserialize;
12use serde_json::{json, Value};
13use std::path::PathBuf;
14
15#[derive(Debug, Clone, Deserialize)]
17pub struct SearchReplaceBlock {
18 pub search: String,
19 pub replace: String,
20}
21
22#[derive(Clone)]
23pub struct ReplaceInFileTool {
24 file_manager: FileAccessManager,
25}
26
27impl ReplaceInFileTool {
28 pub fn tool_name() -> ToolName {
29 ToolName::new("modify_file")
30 }
31
32 pub fn new(workspace_roots: Vec<PathBuf>) -> anyhow::Result<Self> {
33 let file_manager = FileAccessManager::new(workspace_roots)?;
34 Ok(Self { file_manager })
35 }
36
37 fn apply_replacements(
39 &self,
40 content: &str,
41 replacements: Vec<SearchReplaceBlock>,
42 ) -> Result<String> {
43 let mut result = content.to_string();
44
45 for block in replacements {
46 let search = match search(result.clone(), block.search.clone()) {
47 MatchResult::Multiple { matches, .. } => {
48 bail!(
49 "The following search pattern appears more than once in the file (found {} times). Use unique context to match exactly one occurrence.\n\nSearch pattern:\n{}\n\nTip: Include more surrounding context to make this search pattern unique.",
50 matches,
51 block.search
52 );
53 }
54 MatchResult::Guess { closest, .. } => {
55 let message = match closest {
56 Some(closest) => closest.get_correction_feedback().unwrap_or_else(|| "Found a perfect line-level match, but the exact string search failed. This may be due to whitespace or formatting differences. Reread the file to see the actual content.".to_string()),
57 None => "Reread the file (using the set_tracked_file tool and/or read the file contents from the next context message).".to_string(),
58 };
59 bail!("Exact match not found. {message}");
60 }
61 MatchResult::Exact(search) => search,
62 };
63
64 if search == block.replace {
66 bail!(
67 "Search and replace contents are identical for the following pattern. No changes would be made. Please provide different replacement content.\n\nSearch/Replace pattern:\n{}",
68 block.replace
69 );
70 }
71
72 result = result.replacen(&search, &block.replace, 1);
74 }
75
76 Ok(result)
77 }
78}
79
80#[allow(dead_code)]
81enum MatchResult {
82 Multiple {
83 requested: String,
84 matches: usize,
85 },
86 Exact(String),
87 Guess {
88 requested: String,
89 closest: Option<find::MatchResult>,
90 },
91}
92
93fn search(source: String, search: String) -> MatchResult {
94 let matches = source.split(&search).count() - 1;
95 if matches > 1 {
96 return MatchResult::Multiple {
97 requested: search,
98 matches,
99 };
100 }
101
102 if matches == 1 {
103 return MatchResult::Exact(search);
104 }
105
106 let best_match = find_closest_match(
107 source.lines().map(str::to_string).collect(),
108 search.lines().map(str::to_string).collect(),
109 );
110
111 MatchResult::Guess {
112 requested: search,
113 closest: best_match,
114 }
115}
116
117struct ReplaceInFileHandle {
118 modification: FileModification,
119 tool_use_id: String,
120 file_manager: FileAccessManager,
121}
122
123#[async_trait::async_trait(?Send)]
124impl ToolCallHandle for ReplaceInFileHandle {
125 fn tool_request(&self) -> ToolRequestEvent {
126 ToolRequestEvent {
127 tool_call_id: self.tool_use_id.clone(),
128 tool_name: "modify_file".to_string(),
129 tool_type: ToolRequestType::ModifyFile {
130 file_path: self.modification.path.to_string_lossy().to_string(),
131 before: self
132 .modification
133 .original_content
134 .clone()
135 .unwrap_or_default(),
136 after: self.modification.new_content.clone().unwrap_or_default(),
137 },
138 }
139 }
140
141 async fn execute(self: Box<Self>) -> ToolOutput {
142 let manager = FileModificationManager::new(self.file_manager.clone());
143 match manager.apply_modification(self.modification).await {
144 Ok(stats) => ToolOutput::Result {
145 content: json!({
146 "success": true,
147 "lines_added": stats.lines_added,
148 "lines_removed": stats.lines_removed
149 })
150 .to_string(),
151 is_error: false,
152 continuation: ContinuationPreference::Continue,
153 ui_result: ToolExecutionResult::ModifyFile {
154 lines_added: stats.lines_added,
155 lines_removed: stats.lines_removed,
156 },
157 },
158 Err(e) => ToolOutput::Result {
159 content: format!("Failed to apply modification: {e:?}"),
160 is_error: true,
161 continuation: ContinuationPreference::Continue,
162 ui_result: ToolExecutionResult::Error {
163 short_message: "Modification failed".to_string(),
164 detailed_message: format!("{e:?}"),
165 },
166 },
167 }
168 }
169}
170
171#[async_trait::async_trait(?Send)]
172impl ToolExecutor for ReplaceInFileTool {
173 fn name(&self) -> String {
174 "modify_file".to_string()
175 }
176
177 fn description(&self) -> String {
178 "Replace sections of content in an existing file".to_string()
179 }
180
181 fn input_schema(&self) -> Value {
182 json!({
183 "type": "object",
184 "properties": {
185 "file_path": {
186 "type": "string",
187 "description": "Absolute path to the file to modify. Must be tracked using the set_tracked_files tool before being modified. Paths must always be absolute (e.g., starting from the project root like /tycode/...). The search block in diff must exactly match the content of the file to replace from the context."
188 },
189 "diff": {
190 "type": "array",
191 "description": "Array of search and replace blocks. You can (and should) specify multiple find/replace blocks for the same file to apply multiple changes at once.",
192 "items": {
193 "type": "object",
194 "properties": {
195 "search": {
196 "type": "string",
197 "description": "Exact content to find. The search block must exactly match exactly one string in the source file — do not use it to match multiple instances (e.g., you cannot replace all 'banana' with 'carrot' if there are multiple 'banana'). Include sufficient unique surrounding context to ensure unambiguous, exact matching."
198 },
199 "replace": {
200 "type": "string",
201 "description": "New content to replace with"
202 }
203 },
204 "required": ["search", "replace"],
205 "additionalProperties": false
206 }
207 }
208 },
209 "required": ["file_path", "diff"]
210 })
211 }
212
213 fn category(&self) -> ToolCategory {
214 ToolCategory::Execution
215 }
216
217 async fn process(&self, request: &ToolRequest) -> Result<Box<dyn ToolCallHandle>> {
218 let file_path = request
219 .arguments
220 .get("file_path")
221 .and_then(|v| v.as_str())
222 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: file_path"))?;
223
224 let diff_value = request
225 .arguments
226 .get("diff")
227 .ok_or_else(|| anyhow::anyhow!("Missing required parameter: diff"))?;
228
229 let mut diff_value_parsed = diff_value.clone();
230 let diff_arr: Vec<Value> = loop {
231 match diff_value_parsed {
232 Value::Array(arr) => break arr,
233 Value::String(s) => match serde_json::from_str::<Value>(&s) {
234 Ok(value) => diff_value_parsed = value,
235 Err(_) => bail!("diff must be an array of search and replace blocks"),
236 },
237 _ => bail!("diff must be an array of search and replace blocks"),
238 }
239 };
240
241 let original_content: String = self.file_manager.read_file(file_path).await?;
242
243 let replacements: Vec<SearchReplaceBlock> = diff_arr
244 .into_iter()
245 .map(|item| {
246 serde_json::from_value(item)
247 .map_err(|e| anyhow::anyhow!("Invalid diff entry: {e:?}"))
248 })
249 .collect::<Result<Vec<_>, _>>()?;
250 let new_content = self.apply_replacements(&original_content, replacements)?;
251
252 let modification = FileModification {
253 path: PathBuf::from(file_path),
254 operation: FileOperation::Update,
255 original_content: Some(original_content),
256 new_content: Some(new_content),
257 warning: None,
258 };
259
260 Ok(Box::new(ReplaceInFileHandle {
261 modification,
262 tool_use_id: request.tool_use_id.clone(),
263 file_manager: self.file_manager.clone(),
264 }))
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn test_apply_replacements_fails_on_multiple_occurrences() {
274 let tool = ReplaceInFileTool::new(vec![]).unwrap();
275 let content = "line1\nsearch\nline2\nsearch\nline3";
276 let replacements = vec![SearchReplaceBlock {
277 search: "search".to_string(),
278 replace: "replaced".to_string(),
279 }];
280
281 let result = tool.apply_replacements(content, replacements);
282 assert!(result.is_err());
283 assert!(result
284 .unwrap_err()
285 .to_string()
286 .contains("The following search pattern appears more than once in the file"));
287 }
288
289 #[test]
290 fn test_apply_replacements_succeeds_on_single_occurrence() {
291 let tool = ReplaceInFileTool::new(vec![]).unwrap();
292 let content = "line1\nsearch\nline2";
293 let replacements = vec![SearchReplaceBlock {
294 search: "search".to_string(),
295 replace: "replaced".to_string(),
296 }];
297
298 let result = tool.apply_replacements(content, replacements);
299 assert!(result.is_ok());
300 assert_eq!(result.unwrap(), "line1\nreplaced\nline2");
301 }
302
303 #[test]
304 fn test_apply_replacements_fails_on_identical_search_and_replace() {
305 let tool = ReplaceInFileTool::new(vec![]).unwrap();
306 let content = "line1\nsearch\nline2";
307 let replacements = vec![SearchReplaceBlock {
308 search: "search".to_string(),
309 replace: "search".to_string(), }];
311
312 let result = tool.apply_replacements(content, replacements);
313 assert!(result.is_err());
314 assert!(result
315 .unwrap_err()
316 .to_string()
317 .contains("Search and replace contents are identical"));
318 }
319}