opendev_tools_impl/
file_edit.rs1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, LazyLock, Mutex};
7
8use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
9
10use crate::diagnostics_helper;
11use crate::edit_replacers;
12use crate::formatter;
13use crate::path_utils::{is_sensitive_file, resolve_file_path, validate_path_access};
14
15static FILE_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> =
20 LazyLock::new(|| Mutex::new(HashMap::new()));
21
22fn get_file_lock(path: &Path) -> Arc<Mutex<()>> {
23 let mut map = FILE_LOCKS.lock().unwrap();
24 map.entry(path.to_path_buf())
25 .or_insert_with(|| Arc::new(Mutex::new(())))
26 .clone()
27}
28
29#[derive(Debug)]
35pub struct FileEditTool;
36
37#[async_trait::async_trait]
38impl BaseTool for FileEditTool {
39 fn name(&self) -> &str {
40 "edit_file"
41 }
42
43 fn description(&self) -> &str {
44 "Edit a file by replacing a string match. Uses a 9-pass fuzzy matching \
45 chain so minor whitespace/indentation differences are tolerated. \
46 The old_string must be unique in the file unless replace_all is true."
47 }
48
49 fn parameter_schema(&self) -> serde_json::Value {
50 serde_json::json!({
51 "type": "object",
52 "properties": {
53 "file_path": {
54 "type": "string",
55 "description": "Absolute path to the file to edit"
56 },
57 "old_string": {
58 "type": "string",
59 "description": "The string to find and replace"
60 },
61 "new_string": {
62 "type": "string",
63 "description": "The replacement string"
64 },
65 "replace_all": {
66 "type": "boolean",
67 "description": "Replace all occurrences (default: false)"
68 }
69 },
70 "required": ["file_path", "old_string", "new_string"]
71 })
72 }
73
74 async fn execute(
75 &self,
76 args: HashMap<String, serde_json::Value>,
77 ctx: &ToolContext,
78 ) -> ToolResult {
79 let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
80 Some(p) => p,
81 None => return ToolResult::fail("file_path is required"),
82 };
83 let old_string = match args.get("old_string").and_then(|v| v.as_str()) {
84 Some(s) => s,
85 None => return ToolResult::fail("old_string is required"),
86 };
87 let new_string = match args.get("new_string").and_then(|v| v.as_str()) {
88 Some(s) => s,
89 None => return ToolResult::fail("new_string is required"),
90 };
91 let replace_all = args
92 .get("replace_all")
93 .and_then(|v| v.as_bool())
94 .unwrap_or(false);
95
96 if old_string == new_string {
97 return ToolResult::fail("old_string and new_string are identical");
98 }
99
100 let path = resolve_file_path(file_path, &ctx.working_dir);
101
102 if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
103 return ToolResult::fail(msg);
104 }
105
106 if !path.exists() {
107 return ToolResult::fail(format!("File not found: {file_path}"));
108 }
109
110 if let Some(reason) = is_sensitive_file(&path) {
112 return ToolResult::fail(format!(
113 "Refusing to edit {}: {} — this file likely contains secrets. \
114 If you need to modify it, ask the user to do so manually.",
115 file_path, reason
116 ));
117 }
118
119 let (output_text, metadata) = {
121 let lock = get_file_lock(&path);
122 let _guard = lock.lock().unwrap();
123
124 let content = match std::fs::read_to_string(&path) {
125 Ok(c) => c,
126 Err(e) => return ToolResult::fail(format!("Failed to read file: {e}")),
127 };
128
129 let (actual_old, pass_name) = match edit_replacers::find_match(&content, old_string) {
131 Some(m) => (m.actual, m.pass_name),
132 None => {
133 return ToolResult::fail(format!(
134 "old_string not found in {file_path}. Make sure the string matches \
135 the file content (tried 9 fuzzy matching passes)."
136 ));
137 }
138 };
139
140 let count = content.matches(&actual_old as &str).count();
142
143 if count > 1 && !replace_all {
144 let positions = edit_replacers::find_occurrence_positions(&content, &actual_old);
145 let locations: String = positions
146 .iter()
147 .map(|n| format!("line {n}"))
148 .collect::<Vec<_>>()
149 .join(", ");
150 return ToolResult::fail(format!(
151 "old_string found {count} times at {locations} in {file_path}. \
152 Provide more surrounding context to make the match unique, \
153 or use replace_all=true."
154 ));
155 }
156
157 let new_content = if replace_all {
159 content.replace(&actual_old, new_string)
160 } else {
161 content.replacen(&actual_old, new_string, 1)
162 };
163
164 let old_line_parts: Vec<&str> = actual_old.split('\n').collect();
166 let new_line_parts: Vec<&str> = new_string.split('\n').collect();
167 let removals = old_line_parts.len();
168 let additions = new_line_parts.len();
169
170 let diff_text = edit_replacers::unified_diff(file_path, &content, &new_content, 3);
172
173 let dir = path.parent().unwrap_or(Path::new("."));
175 let tmp_path = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
176
177 if let Err(e) = std::fs::write(&tmp_path, &new_content) {
178 return ToolResult::fail(format!("Failed to write temp file: {e}"));
179 }
180 if let Err(e) = std::fs::rename(&tmp_path, &path) {
181 let _ = std::fs::remove_file(&tmp_path);
182 return ToolResult::fail(format!("Failed to rename temp file: {e}"));
183 }
184
185 let formatted =
187 formatter::format_file(path.to_str().unwrap_or(file_path), &ctx.working_dir);
188
189 let replacements = if replace_all { count } else { 1 };
190
191 let mut metadata = HashMap::new();
192 metadata.insert("replacements".into(), serde_json::json!(replacements));
193 metadata.insert("additions".into(), serde_json::json!(additions));
194 metadata.insert("removals".into(), serde_json::json!(removals));
195 metadata.insert("diff".into(), serde_json::json!(diff_text));
196 if pass_name != "simple" {
197 metadata.insert("match_pass".into(), serde_json::json!(pass_name));
198 }
199 if formatted {
200 metadata.insert("formatted".into(), serde_json::json!(true));
201 }
202
203 let fmt_note = if formatted { " (formatted)" } else { "" };
204 let summary = format!(
205 "Edited {file_path}: {replacements} replacement(s), \
206 {additions} addition(s) and {removals} removal(s){fmt_note}"
207 );
208 let output_text = if diff_text.is_empty() {
209 summary
210 } else {
211 format!("{summary}\n{diff_text}")
212 };
213
214 (output_text, metadata)
215 }; let mut output_text = output_text;
219 if let Some(diag_output) =
220 diagnostics_helper::collect_post_edit_diagnostics(ctx, &path).await
221 {
222 output_text.push_str(&diag_output);
223 }
224
225 ToolResult::ok_with_metadata(output_text, metadata)
226 }
227}
228
229#[cfg(test)]
230#[path = "file_edit_tests.rs"]
231mod tests;