Skip to main content

tycode_core/file/modify/
replace_in_file.rs

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/// Tool for replacing sections of content in files
16#[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    /// Apply replacements to content
38    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            // Check if search and replace are identical
65            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            // Replace the single occurrence as specified
73            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(), // identical to search
310        }];
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}