1use std::io;
6use std::path::PathBuf;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use agent_client_protocol_schema::{
11 Content, ContentBlock, Diff, TextContent, ToolCallContent, ToolCallLocation,
12 ToolCallUpdateFields, ToolKind,
13};
14use defect_agent::error::BoxError;
15use defect_agent::fs::{FsBackend, FsError};
16use defect_agent::tool::{
17 SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
18 ToolStream,
19};
20use futures::future::BoxFuture;
21use futures::stream;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24
25use super::replacer::{EditOutcome, replace};
26
27pub struct EditFileTool {
28 schema: ToolSchema,
29}
30
31impl EditFileTool {
32 pub fn new() -> Self {
33 Self {
34 schema: ToolSchema {
35 name: "edit_file".to_string(),
36 description: "Replace a string in a UTF-8 text file. \
37 Prefers an exact match; if that fails it falls back to \
38 progressively looser matching (ignoring leading/trailing \
39 whitespace, indentation, and line-ending differences). \
40 Fails if `old_string` cannot be located, or if the match is \
41 not unique unless `replace_all` is true. \
42 Path must be inside the workspace root."
43 .to_string(),
44 input_schema: json!({
45 "type": "object",
46 "properties": {
47 "path": {
48 "type": "string",
49 "description": "Absolute path or path relative to the session cwd."
50 },
51 "old_string": {
52 "type": "string",
53 "description": "Exact text to replace. Must match a unique substring \
54 unless `replace_all` is true. Empty string is rejected."
55 },
56 "new_string": {
57 "type": "string",
58 "description": "Replacement text. Must differ from old_string."
59 },
60 "replace_all": {
61 "type": "boolean",
62 "description": "When true, replace every occurrence; when false (default), \
63 require old_string to appear exactly once.",
64 "default": false
65 }
66 },
67 "required": ["path", "old_string", "new_string"]
68 }),
69 },
70 }
71 }
72}
73
74impl Default for EditFileTool {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80#[derive(Debug, Deserialize)]
81struct EditArgs {
82 path: String,
83 old_string: String,
84 new_string: String,
85 #[serde(default)]
86 replace_all: bool,
87}
88
89#[derive(Debug, Serialize)]
90struct EditFileOutput {
91 matches_replaced: u32,
92 bytes_before: u64,
93 bytes_after: u64,
94 matched_strategy: &'static str,
98}
99
100impl Tool for EditFileTool {
101 fn schema(&self) -> &ToolSchema {
102 &self.schema
103 }
104
105 fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
106 SafetyClass::Mutating
107 }
108
109 fn describe<'a>(
110 &'a self,
111 args: &'a serde_json::Value,
112 _ctx: ToolContext<'a>,
113 ) -> BoxFuture<'a, ToolCallDescription> {
114 Box::pin(async move {
115 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
116 let title = if path.is_empty() {
117 "Edit".to_string()
118 } else {
119 format!("Edit {path}")
120 };
121 let mut fields = ToolCallUpdateFields::default();
122 fields.title = Some(title);
123 fields.kind = Some(ToolKind::Edit);
124 if !path.is_empty() {
125 fields.locations = Some(vec![ToolCallLocation::new(PathBuf::from(path))]);
126 }
127 ToolCallDescription { fields }
128 })
129 }
130
131 fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
132 let cancel = ctx.cancel.clone();
133 let fs = ctx.fs.clone();
134 let fut = async move { run_edit(args, cancel, fs).await };
135 let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> = Box::pin(stream::once(fut));
136 s
137 }
138}
139
140async fn run_edit(
141 args: serde_json::Value,
142 cancel: tokio_util::sync::CancellationToken,
143 fs: Arc<dyn FsBackend>,
144) -> ToolEvent {
145 let parsed: EditArgs = match serde_json::from_value(args) {
146 Ok(v) => v,
147 Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
148 };
149
150 if parsed.old_string.is_empty() {
151 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(arg_err(
152 "old_string must not be empty",
153 ))));
154 }
155 if parsed.old_string == parsed.new_string {
156 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(arg_err(
157 "old_string and new_string must differ",
158 ))));
159 }
160
161 let path = PathBuf::from(&parsed.path);
162
163 let read_fut = fs.read_text(path.clone(), None, None);
164 let old_content = tokio::select! {
165 biased;
166 () = cancel.cancelled() => return ToolEvent::Failed(ToolError::Canceled),
167 r = read_fut => match r {
168 Ok(t) => t,
169 Err(e) => return ToolEvent::Failed(map_fs_err(e)),
170 },
171 };
172
173 let baseline_fp = fs.fingerprint(path.clone()).await.ok();
182
183 let crlf = is_crlf(&old_content);
189 let old_norm = to_ending(&parsed.old_string, crlf);
190 let new_norm = to_ending(&parsed.new_string, crlf);
191
192 let (new_content, matches_replaced, matched_strategy) =
193 match replace(&old_content, &old_norm, &new_norm, parsed.replace_all) {
194 Ok(v) => v,
195 Err(EditOutcome::NotFound) => {
196 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(arg_err(
197 "old_string not found. It must match the file content; copy the exact \
198 text including whitespace and indentation, or re-read the file.",
199 ))));
200 }
201 Err(EditOutcome::Ambiguous(n)) => {
202 return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(arg_err(
203 &format!(
204 "old_string matched {n} times; add unique surrounding context or set \
205 replace_all"
206 ),
207 ))));
208 }
209 };
210
211 let bytes_before = old_content.len() as u64;
212 let bytes_after = new_content.len() as u64;
213
214 if let Some(baseline) = baseline_fp {
218 match fs.fingerprint(path.clone()).await {
219 Ok(current) if current != baseline => {
220 return ToolEvent::Failed(map_fs_err(FsError::Conflict(path)));
221 }
222 _ => {}
225 }
226 }
227
228 let write_fut = fs.write_text(path.clone(), new_content.clone());
229 tokio::select! {
230 biased;
231 () = cancel.cancelled() => return ToolEvent::Failed(ToolError::Canceled),
232 r = write_fut => {
233 if let Err(e) = r {
234 return ToolEvent::Failed(map_fs_err(e));
235 }
236 }
237 }
238
239 let raw_output = serde_json::to_value(EditFileOutput {
240 matches_replaced,
241 bytes_before,
242 bytes_after,
243 matched_strategy,
244 })
245 .unwrap_or(serde_json::Value::Null);
246
247 let note = if matched_strategy == "exact" {
250 format!("Replaced {matches_replaced} occurrence(s)")
251 } else {
252 format!(
253 "Replaced {matches_replaced} occurrence(s) (matched via `{matched_strategy}` \
254 fallback — exact text did not match; verify indentation/whitespace is correct)"
255 )
256 };
257
258 let diff = Diff::new(path, new_content).old_text(Some(old_content));
259 let mut fields = ToolCallUpdateFields::default();
260 fields.content = Some(vec![
261 ToolCallContent::Diff(diff),
262 ToolCallContent::Content(Content::new(ContentBlock::Text(TextContent::new(note)))),
263 ]);
264 fields.raw_output = Some(raw_output);
265 ToolEvent::Completed(fields)
266}
267
268fn is_crlf(text: &str) -> bool {
270 let crlf = text.matches("\r\n").count();
271 let lone_lf = text.matches('\n').count().saturating_sub(crlf);
272 crlf > lone_lf
273}
274
275fn to_ending(s: &str, crlf: bool) -> String {
279 if !crlf {
280 return s.to_string();
281 }
282 s.replace("\r\n", "\n").replace('\n', "\r\n")
283}
284
285fn map_fs_err(e: FsError) -> ToolError {
286 ToolError::Execution(BoxError::new(e))
287}
288
289fn arg_err(msg: &str) -> io::Error {
290 io::Error::new(io::ErrorKind::InvalidInput, msg.to_string())
291}