Skip to main content

oxi_agent/tools/
edit.rs

1/// Edit file tool - make targeted edits to files
2/// Supports:
3/// - Multiple non-overlapping edits in one call (`edits[]` array)
4/// - Legacy `old_text`/`new_text` single-edit mode
5/// - BOM detection and preservation
6/// - Line ending normalization (CRLF → LF for matching)
7/// - Unified diff output for previews
8/// - File mutation queue for concurrent write safety
9use super::edit_diff::{
10    self, detect_line_ending, has_bom, normalize_to_lf, restore_line_endings, strip_bom, Edit,
11    EditDiffError,
12};
13use super::file_mutation_queue::global_mutation_queue;
14use super::path_security::PathGuard;
15use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18use serde_json::{json, Value};
19use std::path::{Path, PathBuf};
20use tokio::fs;
21use tokio::sync::oneshot;
22
23/// EditTool.
24pub struct EditTool {
25    root_dir: Option<PathBuf>,
26}
27
28impl EditTool {
29    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
30    pub fn new() -> Self {
31        Self { root_dir: None }
32    }
33
34    /// Create with a specific working directory (overrides ToolContext).
35    pub fn with_cwd(cwd: PathBuf) -> Self {
36        Self {
37            root_dir: Some(cwd),
38        }
39    }
40
41    /// Prepare edit arguments, handling both legacy and multi-edit formats.
42    /// Some models send edits as a JSON string instead of an array.
43    fn prepare_arguments(params: &Value) -> EditInput {
44        let path = params
45            .get("path")
46            .or(params.get("file_path"))
47            .and_then(|v| v.as_str())
48            .unwrap_or("")
49            .to_string();
50
51        // Try to parse edits array
52        let mut edits: Vec<EditEntry> = Vec::new();
53
54        if let Some(edits_val) = params.get("edits") {
55            // Some models send edits as a JSON string
56            let edits_val = if let Some(s) = edits_val.as_str() {
57                serde_json::from_str::<Vec<EditEntry>>(s).unwrap_or_default()
58            } else if let Some(arr) = edits_val.as_array() {
59                arr.iter()
60                    .filter_map(|v| serde_json::from_value::<EditEntry>(v.clone()).ok())
61                    .collect()
62            } else {
63                Vec::new()
64            };
65            edits = edits_val;
66        }
67
68        // Legacy mode: old_text + new_text
69        if edits.is_empty() {
70            if let (Some(old), Some(new)) = (
71                params
72                    .get("old_text")
73                    .or(params.get("oldText"))
74                    .and_then(|v| v.as_str()),
75                params
76                    .get("new_text")
77                    .or(params.get("newText"))
78                    .and_then(|v| v.as_str()),
79            ) {
80                edits.push(EditEntry {
81                    old_text: old.to_string(),
82                    new_text: new.to_string(),
83                });
84            }
85        }
86
87        let dry_run = params
88            .get("dry_run")
89            .and_then(|v| v.as_bool())
90            .unwrap_or(false);
91
92        EditInput {
93            path,
94            edits,
95            dry_run,
96        }
97    }
98
99    /// Apply edits to a file with full BOM/line-ending handling and diff output.
100    async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
101        // Security: validate path with PathGuard
102        let guard = PathGuard::new(root_dir);
103        let validated_path = guard
104            .validate_traversal(Path::new(&input.path))
105            .map_err(|e| e.to_string())?;
106        let path = validated_path.as_path();
107
108        // Validate edits
109        if input.edits.is_empty() {
110            return Err(
111                "No edits provided. Either use old_text/new_text or edits array.".to_string(),
112            );
113        }
114
115        // Read file content
116        let raw_content = fs::read_to_string(path)
117            .await
118            .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
119
120        // Detect BOM and line endings
121        let had_bom = has_bom(&raw_content);
122        let line_ending = detect_line_ending(&raw_content);
123        let content = normalize_to_lf(strip_bom(&raw_content));
124
125        // Convert to Edit structs with normalized text
126        let edits: Vec<Edit> = input
127            .edits
128            .iter()
129            .map(|e| Edit {
130                old_text: normalize_to_lf(&e.old_text),
131                new_text: normalize_to_lf(&e.new_text),
132            })
133            .collect();
134
135        // Compute diff for preview
136        let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
137            .map_err(|e: EditDiffError| e.message)?;
138
139        if input.dry_run {
140            return Ok(EditOutput {
141                diff: diff_result.diff,
142                first_changed_line: diff_result.first_changed_line,
143                applied: false,
144                message: "Dry run — no changes applied".to_string(),
145            });
146        }
147
148        // Apply edits to normalized content
149        let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
150            .map_err(|e: EditDiffError| e.message)?;
151
152        // Restore line endings and BOM
153        let mut final_content = restore_line_endings(&modified, line_ending);
154        if had_bom {
155            final_content = format!("\u{feff}{}", final_content);
156        }
157
158        // Write through mutation queue (serializes per-file)
159        let final_content_clone = final_content.clone();
160        global_mutation_queue()
161            .with_queue(path, || async {
162                fs::write(&validated_path, &final_content_clone)
163                    .await
164                    .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
165            })
166            .await
167            .map_err(|e: String| e)?;
168
169        Ok(EditOutput {
170            diff: diff_result.diff,
171            first_changed_line: diff_result.first_changed_line,
172            applied: true,
173            message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
174        })
175    }
176}
177
178impl Default for EditTool {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Parsed edit input
185struct EditInput {
186    path: String,
187    edits: Vec<EditEntry>,
188    dry_run: bool,
189}
190
191/// A single edit entry
192#[derive(Debug, Clone, Serialize, Deserialize)]
193struct EditEntry {
194    #[serde(rename = "oldText", alias = "old_text")]
195    old_text: String,
196    #[serde(rename = "newText", alias = "new_text")]
197    new_text: String,
198}
199
200/// Result of edit operation
201#[derive(Debug)]
202
203struct EditOutput {
204    diff: String,
205    first_changed_line: Option<usize>,
206    #[allow(dead_code)]
207    applied: bool,
208    message: String,
209}
210
211#[async_trait]
212impl AgentTool for EditTool {
213    fn name(&self) -> &str {
214        "edit"
215    }
216
217    fn label(&self) -> &str {
218        "Edit File"
219    }
220
221    fn essential(&self) -> bool {
222        true
223    }
224    fn description(&self) -> &str {
225        "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
226         Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
227         If two changes touch the same block or nearby lines, merge them into one edit instead. \
228         Use dry_run=true to preview without making changes."
229    }
230
231    fn parameters_schema(&self) -> Value {
232        json!({
233            "type": "object",
234            "properties": {
235                "path": {
236                    "type": "string",
237                    "description": "Path to the file to edit (relative or absolute)"
238                },
239                "edits": {
240                    "type": "array",
241                    "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
242                    "items": {
243                        "type": "object",
244                        "properties": {
245                            "oldText": {
246                                "type": "string",
247                                "description": "Exact text for one targeted replacement. Must be unique in the original file."
248                            },
249                            "newText": {
250                                "type": "string",
251                                "description": "Replacement text for this targeted edit."
252                            }
253                        },
254                        "required": ["oldText", "newText"]
255                    }
256                },
257                "old_text": {
258                    "type": "string",
259                    "description": "Legacy: exact text to replace (use edits[] instead for new code)"
260                },
261                "new_text": {
262                    "type": "string",
263                    "description": "Legacy: replacement text (use edits[] instead for new code)"
264                },
265                "dry_run": {
266                    "type": "boolean",
267                    "description": "If true, preview the change without applying it",
268                    "default": false
269                }
270            },
271            "required": ["path"]
272        })
273    }
274
275    async fn execute(
276        &self,
277        _tool_call_id: &str,
278        params: Value,
279        _signal: Option<oneshot::Receiver<()>>,
280        ctx: &ToolContext,
281    ) -> Result<AgentToolResult, ToolError> {
282        let input = Self::prepare_arguments(&params);
283
284        // Use root_dir if set, else ctx.root()
285        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
286
287        match Self::apply_edits(root, &input).await {
288            Ok(output) => {
289                let mut result =
290                    AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
291
292                // Add metadata with first changed line for editor navigation
293                if let Some(line) = output.first_changed_line {
294                    result = result.with_metadata(json!({
295                        "firstChangedLine": line,
296                    }));
297                }
298
299                Ok(result)
300            }
301            Err(e) => Ok(AgentToolResult::error(e)),
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn test_prepare_arguments_legacy() {
312        let params = json!({
313            "path": "/tmp/test.txt",
314            "old_text": "hello",
315            "new_text": "world"
316        });
317        let input = EditTool::prepare_arguments(&params);
318        assert_eq!(input.path, "/tmp/test.txt");
319        assert_eq!(input.edits.len(), 1);
320        assert_eq!(input.edits[0].old_text, "hello");
321        assert_eq!(input.edits[0].new_text, "world");
322        assert!(!input.dry_run);
323    }
324
325    #[test]
326    fn test_prepare_arguments_multi_edit() {
327        let params = json!({
328            "path": "/tmp/test.txt",
329            "edits": [
330                {"oldText": "foo", "newText": "bar"},
331                {"oldText": "baz", "newText": "qux"}
332            ]
333        });
334        let input = EditTool::prepare_arguments(&params);
335        assert_eq!(input.edits.len(), 2);
336    }
337
338    #[test]
339    fn test_prepare_arguments_edits_as_string() {
340        let params = json!({
341            "path": "/tmp/test.txt",
342            "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
343        });
344        let input = EditTool::prepare_arguments(&params);
345        assert_eq!(input.edits.len(), 1);
346        assert_eq!(input.edits[0].old_text, "a");
347    }
348
349    #[test]
350    fn test_prepare_arguments_dry_run() {
351        let params = json!({
352            "path": "/tmp/test.txt",
353            "old_text": "hello",
354            "new_text": "world",
355            "dry_run": true
356        });
357        let input = EditTool::prepare_arguments(&params);
358        assert!(input.dry_run);
359    }
360
361    #[tokio::test]
362    async fn test_apply_edits_file_not_found() {
363        let input = EditInput {
364            path: "/tmp/nonexistent_file_12345.txt".to_string(),
365            edits: vec![EditEntry {
366                old_text: "foo".to_string(),
367                new_text: "bar".to_string(),
368            }],
369            dry_run: false,
370        };
371        let result = EditTool::apply_edits(Path::new("."), &input).await;
372        assert!(result.is_err());
373        assert!(result.unwrap_err().contains("Cannot read file"));
374    }
375
376    #[tokio::test]
377    async fn test_apply_edits_dry_run() {
378        let dir = tempfile::tempdir().unwrap();
379        let file_path = dir.path().join("test.txt");
380        fs::write(&file_path, "hello world\n").await.unwrap();
381
382        let input = EditInput {
383            path: file_path.to_str().unwrap().to_string(),
384            edits: vec![EditEntry {
385                old_text: "hello".to_string(),
386                new_text: "goodbye".to_string(),
387            }],
388            dry_run: true,
389        };
390        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
391        assert!(!output.applied);
392        assert!(output.diff.contains("-hello"));
393        assert!(output.diff.contains("+goodbye"));
394
395        // Verify file was not modified
396        let content = fs::read_to_string(&file_path).await.unwrap();
397        assert_eq!(content, "hello world\n");
398    }
399
400    #[tokio::test]
401    async fn test_apply_edits_single_edit() {
402        let dir = tempfile::tempdir().unwrap();
403        let file_path = dir.path().join("test.txt");
404        fs::write(&file_path, "hello world\nfoo bar\n")
405            .await
406            .unwrap();
407
408        let input = EditInput {
409            path: file_path.to_str().unwrap().to_string(),
410            edits: vec![EditEntry {
411                old_text: "hello".to_string(),
412                new_text: "goodbye".to_string(),
413            }],
414            dry_run: false,
415        };
416        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
417        assert!(output.applied);
418        assert!(output.message.contains("1 edit(s)"));
419
420        let content = fs::read_to_string(&file_path).await.unwrap();
421        assert_eq!(content, "goodbye world\nfoo bar\n");
422    }
423
424    #[tokio::test]
425    async fn test_apply_edits_multiple_edits() {
426        let dir = tempfile::tempdir().unwrap();
427        let file_path = dir.path().join("test.txt");
428        fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
429
430        let input = EditInput {
431            path: file_path.to_str().unwrap().to_string(),
432            edits: vec![
433                EditEntry {
434                    old_text: "aaa".to_string(),
435                    new_text: "AAA".to_string(),
436                },
437                EditEntry {
438                    old_text: "ccc".to_string(),
439                    new_text: "CCC".to_string(),
440                },
441            ],
442            dry_run: false,
443        };
444        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
445        assert!(output.applied);
446        assert!(output.message.contains("2 edit(s)"));
447
448        let content = fs::read_to_string(&file_path).await.unwrap();
449        assert_eq!(content, "AAA\nbbb\nCCC\n");
450    }
451
452    #[tokio::test]
453    async fn test_apply_edits_crlf_preserved() {
454        let dir = tempfile::tempdir().unwrap();
455        let file_path = dir.path().join("test.txt");
456        fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
457
458        let input = EditInput {
459            path: file_path.to_str().unwrap().to_string(),
460            edits: vec![EditEntry {
461                old_text: "hello".to_string(),
462                new_text: "goodbye".to_string(),
463            }],
464            dry_run: false,
465        };
466        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
467
468        let content = fs::read_to_string(&file_path).await.unwrap();
469        assert_eq!(content, "goodbye\r\nworld\r\n");
470    }
471
472    #[tokio::test]
473    async fn test_apply_edits_bom_preserved() {
474        let dir = tempfile::tempdir().unwrap();
475        let file_path = dir.path().join("test.txt");
476        fs::write(&file_path, "\u{feff}hello world\n")
477            .await
478            .unwrap();
479
480        let input = EditInput {
481            path: file_path.to_str().unwrap().to_string(),
482            edits: vec![EditEntry {
483                old_text: "hello".to_string(),
484                new_text: "goodbye".to_string(),
485            }],
486            dry_run: false,
487        };
488        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
489
490        let content = fs::read_to_string(&file_path).await.unwrap();
491        assert!(content.starts_with('\u{feff}'));
492        assert!(content.contains("goodbye"));
493    }
494}