Skip to main content

agent_code_lib/tools/
file_edit.rs

1//! FileEdit tool: targeted search-and-replace editing.
2//!
3//! Performs exact string replacement within a file. The `old_string`
4//! must match uniquely (unless `replace_all` is set) to prevent
5//! ambiguous edits.
6
7use async_trait::async_trait;
8use serde_json::json;
9use std::path::PathBuf;
10
11use super::{Tool, ToolContext, ToolResult};
12use crate::error::ToolError;
13
14pub struct FileEditTool;
15
16#[async_trait]
17impl Tool for FileEditTool {
18    fn name(&self) -> &'static str {
19        "FileEdit"
20    }
21
22    fn description(&self) -> &'static str {
23        "Performs exact string replacements in files. The old_string must \
24         match uniquely unless replace_all is true."
25    }
26
27    fn input_schema(&self) -> serde_json::Value {
28        json!({
29            "type": "object",
30            "required": ["file_path", "old_string", "new_string"],
31            "properties": {
32                "file_path": {
33                    "type": "string",
34                    "description": "Absolute path to the file to modify"
35                },
36                "old_string": {
37                    "type": "string",
38                    "description": "The text to replace"
39                },
40                "new_string": {
41                    "type": "string",
42                    "description": "The replacement text (must differ from old_string)"
43                },
44                "replace_all": {
45                    "type": "boolean",
46                    "description": "Replace all occurrences (default: false)",
47                    "default": false
48                }
49            }
50        })
51    }
52
53    fn is_read_only(&self) -> bool {
54        false
55    }
56
57    fn get_path(&self, input: &serde_json::Value) -> Option<PathBuf> {
58        input
59            .get("file_path")
60            .and_then(|v| v.as_str())
61            .map(PathBuf::from)
62    }
63
64    async fn call(
65        &self,
66        input: serde_json::Value,
67        _ctx: &ToolContext,
68    ) -> Result<ToolResult, ToolError> {
69        let file_path = input
70            .get("file_path")
71            .and_then(|v| v.as_str())
72            .ok_or_else(|| ToolError::InvalidInput("'file_path' is required".into()))?;
73
74        let old_string = input
75            .get("old_string")
76            .and_then(|v| v.as_str())
77            .ok_or_else(|| ToolError::InvalidInput("'old_string' is required".into()))?;
78
79        let new_string = input
80            .get("new_string")
81            .and_then(|v| v.as_str())
82            .ok_or_else(|| ToolError::InvalidInput("'new_string' is required".into()))?;
83
84        let replace_all = input
85            .get("replace_all")
86            .and_then(|v| v.as_bool())
87            .unwrap_or(false);
88
89        if old_string == new_string {
90            return Err(ToolError::InvalidInput(
91                "old_string and new_string must be different".into(),
92            ));
93        }
94
95        // Check file size before reading (reject files > 1MB).
96        const MAX_EDIT_SIZE: u64 = 1_048_576;
97        if let Ok(meta) = tokio::fs::metadata(file_path).await
98            && meta.len() > MAX_EDIT_SIZE
99        {
100            return Err(ToolError::InvalidInput(format!(
101                "File too large for editing ({} bytes, max {}). \
102                 Consider using Bash with sed/awk for large files.",
103                meta.len(),
104                MAX_EDIT_SIZE
105            )));
106        }
107
108        let content = tokio::fs::read_to_string(file_path)
109            .await
110            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to read {file_path}: {e}")))?;
111
112        let occurrences = content.matches(old_string).count();
113
114        if occurrences == 0 {
115            return Err(ToolError::InvalidInput(format!(
116                "old_string not found in {file_path}"
117            )));
118        }
119
120        if occurrences > 1 && !replace_all {
121            return Err(ToolError::InvalidInput(format!(
122                "old_string has {occurrences} occurrences in {file_path}. \
123                 Use replace_all=true to replace all, or provide a more \
124                 specific old_string."
125            )));
126        }
127
128        let new_content = if replace_all {
129            content.replace(old_string, new_string)
130        } else {
131            content.replacen(old_string, new_string, 1)
132        };
133
134        tokio::fs::write(file_path, &new_content)
135            .await
136            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to write {file_path}: {e}")))?;
137
138        let replaced = if replace_all { occurrences } else { 1 };
139        Ok(ToolResult::success(format!(
140            "Replaced {replaced} occurrence(s) in {file_path}"
141        )))
142    }
143}