steer_tools/tools/
edit.rs

1use once_cell::sync::Lazy;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6use std::sync::Arc;
7use steer_macros::tool;
8use tokio::fs;
9use tokio::sync::Mutex;
10use tokio_util::sync::CancellationToken;
11
12use crate::context::ExecutionContext;
13use crate::error::ToolError;
14use crate::result::EditResult;
15
16// Global lock manager for file paths
17static FILE_LOCKS: Lazy<Mutex<HashMap<String, Arc<Mutex<()>>>>> =
18    Lazy::new(|| Mutex::new(HashMap::new()));
19
20async fn get_file_lock(file_path: &str) -> Arc<Mutex<()>> {
21    let mut locks_map_guard = FILE_LOCKS.lock().await;
22    locks_map_guard
23        .entry(file_path.to_string())
24        .or_insert_with(|| Arc::new(Mutex::new(())))
25        .clone()
26}
27
28#[derive(Deserialize, Debug, JsonSchema, Clone)]
29pub struct SingleEditOperation {
30    /// The exact string to find and replace. Must be unique within the current state of the file content.
31    /// If this is the first operation in a MultiEditTool call for a new file, or for EditTool if the file
32    /// is being created, an empty string indicates that `new_string` should be used as the initial content.
33    pub old_string: String,
34    /// The string to replace `old_string` with.
35    pub new_string: String,
36}
37
38/// Core logic for performing one or more edit operations on a file's content in memory.
39/// This function handles file reading/creation setup, applies operations, and returns the new content.
40/// It does NOT write to disk itself.
41async fn perform_edit_operations(
42    file_path_str: &str,
43    operations: &[SingleEditOperation],
44    token: Option<&CancellationToken>,
45    tool_name_for_errors: &str,
46) -> Result<(String, usize, bool), ToolError> {
47    if let Some(t) = &token {
48        if t.is_cancelled() {
49            return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
50        }
51    }
52
53    let path = Path::new(file_path_str);
54    let mut current_content: String;
55    let mut file_created_this_op = false;
56
57    match fs::read_to_string(path).await {
58        Ok(content_from_file) => {
59            current_content = content_from_file;
60        }
61        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
62            if operations.is_empty() {
63                return Err(ToolError::execution(
64                    tool_name_for_errors,
65                    format!(
66                        "File {file_path_str} does not exist and no operations provided to create it."
67                    ),
68                ));
69            }
70            let first_op = &operations[0];
71            if first_op.old_string.is_empty() {
72                if let Some(parent) = path.parent() {
73                    if !fs::metadata(parent)
74                        .await
75                        .map(|m| m.is_dir())
76                        .unwrap_or(false)
77                    {
78                        if let Some(t) = &token {
79                            if t.is_cancelled() {
80                                return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
81                            }
82                        }
83                        fs::create_dir_all(parent).await.map_err(|e| {
84                            ToolError::io(
85                                tool_name_for_errors,
86                                format!("Failed to create directory {}: {}", parent.display(), e),
87                            )
88                        })?;
89                    }
90                }
91                current_content = first_op.new_string.clone();
92                file_created_this_op = true;
93            } else {
94                return Err(ToolError::io(
95                    tool_name_for_errors,
96                    format!(
97                        "File {file_path_str} not found, and the first/only operation's old_string is not empty (required for creation)."
98                    ),
99                ));
100            }
101        }
102        Err(e) => {
103            // Other read error
104            return Err(ToolError::io(
105                tool_name_for_errors,
106                format!("Failed to read file {file_path_str}: {e}"),
107            ));
108        }
109    }
110
111    if operations.is_empty() {
112        // Should have been caught if file not found, but good for existing empty file
113        return Ok((current_content, 0, false));
114    }
115
116    let mut edits_applied_count = 0;
117    for (index, edit_op) in operations.iter().enumerate() {
118        if let Some(t) = &token {
119            if t.is_cancelled() {
120                return Err(ToolError::Cancelled(tool_name_for_errors.to_string()));
121            }
122        }
123
124        if edit_op.old_string.is_empty() {
125            if index == 0 && file_created_this_op {
126                // This was the creation step; content is already set from edit_op.new_string.
127            } else if index == 0 && operations.len() == 1 && edit_op.old_string.is_empty() {
128                // This is a single "EditTool" style operation to overwrite/create the file
129                current_content = edit_op.new_string.clone();
130                if !file_created_this_op {
131                    // If file existed and we are "creating" it with empty old_string
132                    file_created_this_op = true; // Treat as creation for consistent messaging later
133                }
134            } else {
135                return Err(ToolError::execution(
136                    tool_name_for_errors,
137                    format!(
138                        "Edit #{} for file {} has an empty old_string. This is only allowed for the first operation if the file is being created or for a single operation to overwrite the file.",
139                        index + 1,
140                        file_path_str
141                    ),
142                ));
143            }
144        } else {
145            // Normal replacement
146            let occurrences = current_content.matches(&edit_op.old_string).count();
147            if occurrences == 0 {
148                return Err(ToolError::execution(
149                    tool_name_for_errors,
150                    format!(
151                        "For edit #{}, string not found in file {} (after {} previous successful edits). String to find (first 50 chars): '{}'",
152                        index + 1,
153                        file_path_str,
154                        edits_applied_count,
155                        edit_op.old_string.chars().take(50).collect::<String>()
156                    ),
157                ));
158            }
159            if occurrences > 1 {
160                return Err(ToolError::execution(
161                    tool_name_for_errors,
162                    format!(
163                        "For edit #{}, found {} occurrences of string in file {} (after {} previous successful edits). String to find (first 50 chars): '{}'. Please provide more context.",
164                        index + 1,
165                        occurrences,
166                        file_path_str,
167                        edits_applied_count,
168                        edit_op.old_string.chars().take(50).collect::<String>()
169                    ),
170                ));
171            }
172            current_content = current_content.replace(&edit_op.old_string, &edit_op.new_string);
173        }
174        edits_applied_count += 1;
175    }
176    Ok((current_content, edits_applied_count, file_created_this_op))
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180pub struct EditParams {
181    /// The absolute path to the file to edit
182    pub file_path: String,
183    /// The exact string to find and replace. If empty, the file will be created.
184    pub old_string: String,
185    /// The string to replace `old_string` with.
186    pub new_string: String,
187}
188
189tool! {
190    EditTool {
191        params: EditParams,
192        output: EditResult,
193        variant: Edit,
194        description: r#"This is a tool for editing files. For moving or renaming files, you should generally use the Bash tool with the 'mv' command instead. For larger edits, use the replace tool to overwrite files.
195
196Before using this tool:
197
1981. Use the View tool to understand the file's contents and context
199
2002. Verify the directory path is correct (only applicable when creating new files):
201 - Use the LS tool to verify the parent directory exists and is the correct location
202
203To make a file edit, provide the following:
2041. file_path: The absolute path to the file to modify (must be absolute, not relative)
2052. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
2063. new_string: The edited text to replace the old_string
207
208The tool will replace ONE occurrence of old_string with new_string in the specified file.
209
210CRITICAL REQUIREMENTS FOR USING THIS TOOL:
211
2121. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
213 - Include AT LEAST 3-5 lines of context BEFORE the change point
214 - Include AT LEAST 3-5 lines of context AFTER the change point
215 - Include all whitespace, indentation, and surrounding code exactly as it appears in the file
216
2172. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
218 - Make separate calls to this tool for each instance
219 - Each call must uniquely identify its specific instance using extensive context
220
2213. VERIFICATION: Before using this tool:
222 - Check how many instances of the target text exist in the file
223 - If multiple instances exist, gather enough context to uniquely identify each one
224 - Plan separate tool calls for each instance
225
226WARNING: If you do not follow these requirements:
227 - The tool will fail if old_string matches multiple locations
228 - The tool will fail if old_string doesn't match exactly (including whitespace)
229 - You may change the wrong instance if you don't include enough context
230
231When making edits:
232 - Ensure the edit results in idiomatic, correct code
233 - Do not leave the code in a broken state
234 - Always use absolute file paths (starting with /)
235
236If you want to create a new file, use:
237 - A new file path, including dir name if needed
238 - An empty old_string
239 - The new file's contents as new_string
240
241Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each."#,
242        name: "edit_file",
243        require_approval: true
244    }
245
246    async fn run(
247        _tool: &EditTool,
248        params: EditParams,
249        context: &ExecutionContext,
250    ) -> Result<EditResult, ToolError> {
251        let file_lock = get_file_lock(&params.file_path).await;
252        let _lock_guard = file_lock.lock().await;
253
254        let operation = SingleEditOperation {
255            old_string: params.old_string,
256            new_string: params.new_string,
257        };
258
259        match perform_edit_operations(&params.file_path, &[operation], Some(&context.cancellation_token), EDIT_TOOL_NAME).await {
260            Ok((final_content, num_ops, created_or_overwritten)) => {
261                // perform_edit_operations ensures num_ops is 1 if Ok for a single op, or 0 if it was a no-op on existing file.
262                // It also handles the "creation" logic if old_string was empty.
263
264                if created_or_overwritten || num_ops > 0 { // If created, or if existing file was modified
265                    if context.cancellation_token.is_cancelled() { return Err(ToolError::Cancelled(EDIT_TOOL_NAME.to_string())); }
266                    fs::write(Path::new(&params.file_path), &final_content)
267                        .await
268                        .map_err(|e| ToolError::io(EDIT_TOOL_NAME, format!("Failed to write file {}: {}", params.file_path, e)))?;
269
270                    if created_or_overwritten {
271                        // The "created_or_overwritten" flag from perform_edit_operations handles if old_string was empty.
272                        Ok(EditResult {
273                            file_path: params.file_path.clone(),
274                            changes_made: num_ops,
275                            file_created: true,
276                            old_content: None,
277                            new_content: Some(final_content),
278                        })
279                    } else {
280                        Ok(EditResult {
281                            file_path: params.file_path.clone(),
282                            changes_made: num_ops,
283                            file_created: false,
284                            old_content: None,
285                            new_content: Some(final_content),
286                        })
287                    }
288                } else {
289                     // This case implies the file existed, old_string was empty, new_string was same as existing content,
290                     // or some other no-op scenario that perform_edit_operations handled by returning num_ops = 0 and created = false.
291                    Ok(EditResult {
292                        file_path: params.file_path.clone(),
293                        changes_made: 0,
294                        file_created: false,
295                        old_content: None,
296                        new_content: None,
297                    })
298                }
299            }
300            Err(e) => Err(e),
301        }
302    }
303}
304
305pub mod multi_edit {
306    use super::*;
307    use crate::result::MultiEditResult;
308
309    #[derive(Deserialize, Debug, JsonSchema)]
310    pub struct MultiEditParams {
311        /// The absolute path to the file to edit.
312        pub file_path: String,
313        /// A list of edit operations to apply sequentially.
314        pub edits: Vec<SingleEditOperation>,
315    }
316
317    tool! {
318        MultiEditTool {
319            params: MultiEditParams,
320            output: MultiEditResult,
321            variant: Edit,
322            description: format!(r#"This is a tool for making multiple edits to a single file in one operation. It is built on top of the {} tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the {} tool when you need to make multiple edits to the same file.
323
324Before using this tool:
325
3261. Use the View tool to understand the file's contents and context
3272. Verify the directory path is correct
328
329To make multiple file edits, provide the following:
3301. file_path: The absolute path to the file to modify (must be absolute, not relative)
3312. edits: An array of edit operations to perform, where each edit contains:
332   - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation)
333   - new_string: The edited text to replace the old_string
334
335IMPORTANT:
336- All edits are applied in sequence, in the order they are provided
337- Each edit operates on the result of the previous edit
338- All edits must be valid for the operation to succeed - if any edit fails, none will be applied
339- This tool is ideal when you need to make several changes to different parts of the same file
340
341CRITICAL REQUIREMENTS:
3421. All edits follow the same requirements as the single Edit tool
3432. The edits are atomic - either all succeed or none are applied
3443. Plan your edits carefully to avoid conflicts between sequential operations
345
346WARNING: Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find.
347
348When making edits:
349- Ensure all edits result in idiomatic, correct code
350- Do not leave the code in a broken state
351- Always use absolute file paths (starting with /)
352
353If you want to create a new file, use:
354- A new file path, including dir name if needed
355- First edit: empty old_string and the new file's contents as new_string
356- Subsequent edits: normal edit operations on the created content
357"#, EDIT_TOOL_NAME, EDIT_TOOL_NAME),
358            name: "multi_edit_file",
359            require_approval: true
360        }
361
362        async fn run(
363            _tool: &MultiEditTool,
364            params: MultiEditParams,
365            context: &ExecutionContext,
366        ) -> Result<MultiEditResult, ToolError> {
367            let file_lock = super::get_file_lock(&params.file_path).await;
368            let _lock_guard = file_lock.lock().await;
369
370            if params.edits.is_empty() {
371                // If file exists, no change. If not, error.
372                let path = Path::new(&params.file_path);
373                 if !fs::metadata(path).await.map(|m| m.is_file()).unwrap_or(false) {
374                     return Err(ToolError::execution(
375                        MULTI_EDIT_TOOL_NAME,
376                        format!("File {} does not exist and no edit operations provided to create or modify it.", params.file_path),
377                    ));
378                 }
379                return Ok(MultiEditResult(EditResult {
380                    file_path: params.file_path.clone(),
381                    changes_made: 0,
382                    file_created: false,
383                    old_content: None,
384                    new_content: None,
385                }));
386            }
387
388            match super::perform_edit_operations(&params.file_path, &params.edits, Some(&context.cancellation_token), MULTI_EDIT_TOOL_NAME).await {
389                Ok((final_content, num_ops_processed, file_was_created)) => {
390                    // If perform_edit_operations returned Ok, it means all operations in params.edits were valid and processed.
391                    // num_ops_processed should equal params.edits.len().
392                    // The content is now ready to be written.
393
394                    if num_ops_processed > 0 || file_was_created { // If any actual changes or creation happened
395                        if context.cancellation_token.is_cancelled() { return Err(ToolError::Cancelled(MULTI_EDIT_TOOL_NAME.to_string())); }
396                        fs::write(Path::new(&params.file_path), &final_content)
397                            .await
398                            .map_err(|e| ToolError::io(MULTI_EDIT_TOOL_NAME, format!("Failed to write file {}: {}", params.file_path, e)))?;
399
400                        if file_was_created {
401                            Ok(MultiEditResult(EditResult {
402                                file_path: params.file_path.clone(),
403                                changes_made: num_ops_processed,
404                                file_created: true,
405                                old_content: None,
406                                new_content: Some(final_content.clone()),
407                            }))
408                        } else { // Edits were made to an existing file
409                            Ok(MultiEditResult(EditResult {
410                                file_path: params.file_path.clone(),
411                                changes_made: num_ops_processed,
412                                file_created: false,
413                                old_content: None,
414                                new_content: Some(final_content.clone()),
415                            }))
416                        }
417                    } else {
418                        // This case implies params.edits was not empty, but perform_edit_operations resulted in no effective change
419                        // (e.g. all edits were no-ops that didn't change content and didn't create a file).
420                        Ok(MultiEditResult(EditResult {
421                            file_path: params.file_path.clone(),
422                            changes_made: 0,
423                            file_created: false,
424                            old_content: None,
425                            new_content: None,
426                        }))
427                    }
428                }
429                Err(e) => Err(e), // Propagate error from perform_edit_operations
430            }
431        }
432    }
433}