agent_code_lib/tools/
file_edit.rs1use 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 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}