Skip to main content

opendev_tools_impl/
file_edit.rs

1//! Edit file tool — string replacement with 9-pass fuzzy matching, diff preview,
2//! per-file locking, and proper line-count statistics.
3
4use 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
15// ---------------------------------------------------------------------------
16// Per-file locking: serialize concurrent edits to the same file.
17// ---------------------------------------------------------------------------
18
19static 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// ---------------------------------------------------------------------------
30// FileEditTool
31// ---------------------------------------------------------------------------
32
33/// Tool for editing files via string replacement with fuzzy matching fallback.
34#[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        // Block editing sensitive files (same as file_write).
111        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        // Acquire per-file lock — scoped so the guard drops before async diagnostics
120        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            // --- Fuzzy match ---
130            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            // --- Uniqueness check ---
141            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            // --- Perform replacement ---
158            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            // --- Diff stats ---
165            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            // --- Generate unified diff preview ---
171            let diff_text = edit_replacers::unified_diff(file_path, &content, &new_content, 3);
172
173            // --- Atomic write ---
174            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            // Auto-format if a formatter is available
186            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        }; // lock guard dropped here
216
217        // Collect LSP diagnostics after edit (requires no lock held)
218        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;