Skip to main content

defect_tools/fs/
edit.rs

1//! `edit_file` tool: exact string replacement.
2//!
3//! Edit tool — applies a patch to an existing file.
4
5use 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    /// Which match level succeeded: `"exact"` for the strict path, or the name of the
95    /// fallback matcher (e.g. `"line_trimmed"`). Surfaced so fuzzy matches are observable
96    /// rather than silent.
97    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    // Immediately after the read, capture a "read-time fingerprint" as a baseline. The
174    // backend either uses mtime+size (`LocalFsBackend`) or falls back to re-reading the
175    // full content hash (`AcpFsBackend`). Either scheme can be compared with the
176    // "pre-write" fingerprint to detect concurrent external modifications during the
177    // read→write window.
178    //
179    // On failure (rare, e.g. `NotPermitted`), drop this guard and proceed normally —
180    // conflict detection is best-effort and should not block the main flow.
181    let baseline_fp = fs.fingerprint(path.clone()).await.ok();
182
183    // Match in the file's own line-ending space. The model almost always sends LF in
184    // `old_string`; if the file is CRLF, an exact match would fail on every line. Convert
185    // `old`/`new` to the file's dominant ending before matching so the replacer chain
186    // operates on comparable bytes. (The backend re-normalizes on write regardless, but
187    // that does not help the *match* step.)
188    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    // Conflict detection: re-fingerprint before writing and compare against the baseline.
215    // If they differ, return [`FsError::Conflict`] — the LLM should re-read and re-edit
216    // rather than overwrite.
217    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            // Don't block if the current fingerprint is unavailable — conflict
223            // detection is best-effort.
224            _ => {}
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    // When a non-exact matcher hit, the spliced replacement does not preserve the
248    // region's original whitespace/indentation — make that observable to the model.
249    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
268/// Returns whether `text`'s dominant line ending is CRLF (more `\r\n` than lone `\n`).
269fn 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
275/// Converts `s` (assumed LF or mixed) to the target line ending. When `crlf` is true,
276/// every `\n` becomes `\r\n` (after first collapsing any existing `\r\n` to avoid
277/// doubling); when false, returns `s` unchanged (matching is done in LF space).
278fn 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}