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        let expected_hash = params
93            .get("expected_hash")
94            .and_then(|v| v.as_str())
95            .map(|s| s.to_string());
96
97        EditInput {
98            path,
99            edits,
100            dry_run,
101            expected_hash,
102        }
103    }
104
105    /// Apply edits to a file with full BOM/line-ending handling and diff output.
106    async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
107        // Security: validate path with PathGuard
108        let guard = PathGuard::new(root_dir);
109        let validated_path = guard
110            .validate_traversal(Path::new(&input.path))
111            .map_err(|e| e.to_string())?;
112        let path = validated_path.as_path();
113
114        // Validate edits
115        if input.edits.is_empty() {
116            return Err(
117                "No edits provided. Either use old_text/new_text or edits array.".to_string(),
118            );
119        }
120
121        // Content-based conflict detection
122        // Note: We do a synchronous read for the hash check, then reuse the
123        // content below via the async read. The gap is minimal and the
124        // file_mutation_queue serializes concurrent edits to the same file.
125        if let Some(ref expected) = input.expected_hash {
126            let current_content = std::fs::read_to_string(path)
127                .map_err(|e| format!("Failed to read file for hash check: {}", e))?;
128            use std::hash::{Hash, Hasher};
129            let mut hasher = std::collections::hash_map::DefaultHasher::new();
130            current_content.hash(&mut hasher);
131            let current_hash = format!("{:016x}", hasher.finish());
132            if current_hash != *expected {
133                return Ok(EditOutput {
134                    diff: String::new(),
135                    first_changed_line: None,
136                    applied: false,
137                    message: "File has been modified since last read. Re-read the file and retry."
138                        .to_string(),
139                });
140            }
141        }
142
143        // Read file content
144        let raw_content = fs::read_to_string(path)
145            .await
146            .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
147
148        // Detect BOM and line endings
149        let had_bom = has_bom(&raw_content);
150        let line_ending = detect_line_ending(&raw_content);
151        let content = normalize_to_lf(strip_bom(&raw_content));
152
153        // Convert to Edit structs with normalized text
154        let edits: Vec<Edit> = input
155            .edits
156            .iter()
157            .map(|e| Edit {
158                old_text: normalize_to_lf(&e.old_text),
159                new_text: normalize_to_lf(&e.new_text),
160            })
161            .collect();
162
163        // Compute diff for preview
164        let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
165            .map_err(|e: EditDiffError| e.message)?;
166
167        if input.dry_run {
168            return Ok(EditOutput {
169                diff: diff_result.diff,
170                first_changed_line: diff_result.first_changed_line,
171                applied: false,
172                message: "Dry run — no changes applied".to_string(),
173            });
174        }
175
176        // Apply edits to normalized content
177        let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
178            .map_err(|e: EditDiffError| e.message)?;
179
180        // Restore line endings and BOM
181        let mut final_content = restore_line_endings(&modified, line_ending);
182        if had_bom {
183            final_content = format!("\u{feff}{}", final_content);
184        }
185
186        // Write through mutation queue (serializes per-file)
187        let final_content_clone = final_content.clone();
188        global_mutation_queue()
189            .with_queue(path, || async {
190                fs::write(&validated_path, &final_content_clone)
191                    .await
192                    .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
193            })
194            .await
195            .map_err(|e: String| e)?;
196
197        Ok(EditOutput {
198            diff: diff_result.diff,
199            first_changed_line: diff_result.first_changed_line,
200            applied: true,
201            message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
202        })
203    }
204}
205
206impl Default for EditTool {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212/// Parsed edit input
213struct EditInput {
214    path: String,
215    edits: Vec<EditEntry>,
216    dry_run: bool,
217    /// Hash of file content at last read. If provided, edit will be
218    /// rejected if the file has been modified since.
219    expected_hash: Option<String>,
220}
221
222/// A single edit entry
223#[derive(Debug, Clone, Serialize, Deserialize)]
224struct EditEntry {
225    #[serde(rename = "oldText", alias = "old_text")]
226    old_text: String,
227    #[serde(rename = "newText", alias = "new_text")]
228    new_text: String,
229}
230
231/// Result of edit operation
232#[derive(Debug)]
233
234struct EditOutput {
235    diff: String,
236    first_changed_line: Option<usize>,
237    #[allow(dead_code)]
238    applied: bool,
239    message: String,
240}
241
242#[async_trait]
243impl AgentTool for EditTool {
244    fn name(&self) -> &str {
245        "edit"
246    }
247
248    fn label(&self) -> &str {
249        "Edit File"
250    }
251
252    fn essential(&self) -> bool {
253        true
254    }
255    fn description(&self) -> &str {
256        "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
257         Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
258         If two changes touch the same block or nearby lines, merge them into one edit instead. \
259         Use dry_run=true to preview without making changes."
260    }
261
262    fn parameters_schema(&self) -> Value {
263        json!({
264            "type": "object",
265            "properties": {
266                "path": {
267                    "type": "string",
268                    "description": "Path to the file to edit (relative or absolute)"
269                },
270                "edits": {
271                    "type": "array",
272                    "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
273                    "items": {
274                        "type": "object",
275                        "properties": {
276                            "oldText": {
277                                "type": "string",
278                                "description": "Exact text for one targeted replacement. Must be unique in the original file."
279                            },
280                            "newText": {
281                                "type": "string",
282                                "description": "Replacement text for this targeted edit."
283                            }
284                        },
285                        "required": ["oldText", "newText"]
286                    }
287                },
288                "old_text": {
289                    "type": "string",
290                    "description": "Legacy: exact text to replace (use edits[] instead for new code)"
291                },
292                "new_text": {
293                    "type": "string",
294                    "description": "Legacy: replacement text (use edits[] instead for new code)"
295                },
296                "dry_run": {
297                    "type": "boolean",
298                    "description": "If true, preview the change without applying it",
299                    "default": false
300                },
301                "expected_hash": {
302                    "type": "string",
303                    "description": "Hash of the file content at last read. If provided, the edit will be rejected if the file was modified since the hash was computed."
304                }
305            },
306            "required": ["path"]
307        })
308    }
309
310    async fn execute(
311        &self,
312        _tool_call_id: &str,
313        params: Value,
314        _signal: Option<oneshot::Receiver<()>>,
315        ctx: &ToolContext,
316    ) -> Result<AgentToolResult, ToolError> {
317        let input = Self::prepare_arguments(&params);
318
319        // Use root_dir if set, else ctx.root()
320        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
321
322        match Self::apply_edits(root, &input).await {
323            Ok(output) => {
324                let mut result =
325                    AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
326
327                // Add metadata with first changed line for editor navigation
328                if let Some(line) = output.first_changed_line {
329                    result = result.with_metadata(json!({
330                        "firstChangedLine": line,
331                    }));
332                }
333
334                Ok(result)
335            }
336            Err(e) => Ok(AgentToolResult::error(e)),
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_prepare_arguments_legacy() {
347        let params = json!({
348            "path": "/tmp/test.txt",
349            "old_text": "hello",
350            "new_text": "world"
351        });
352        let input = EditTool::prepare_arguments(&params);
353        assert_eq!(input.path, "/tmp/test.txt");
354        assert_eq!(input.edits.len(), 1);
355        assert_eq!(input.edits[0].old_text, "hello");
356        assert_eq!(input.edits[0].new_text, "world");
357        assert!(!input.dry_run);
358    }
359
360    #[test]
361    fn test_prepare_arguments_multi_edit() {
362        let params = json!({
363            "path": "/tmp/test.txt",
364            "edits": [
365                {"oldText": "foo", "newText": "bar"},
366                {"oldText": "baz", "newText": "qux"}
367            ]
368        });
369        let input = EditTool::prepare_arguments(&params);
370        assert_eq!(input.edits.len(), 2);
371    }
372
373    #[test]
374    fn test_prepare_arguments_edits_as_string() {
375        let params = json!({
376            "path": "/tmp/test.txt",
377            "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
378        });
379        let input = EditTool::prepare_arguments(&params);
380        assert_eq!(input.edits.len(), 1);
381        assert_eq!(input.edits[0].old_text, "a");
382    }
383
384    #[test]
385    fn test_prepare_arguments_dry_run() {
386        let params = json!({
387            "path": "/tmp/test.txt",
388            "old_text": "hello",
389            "new_text": "world",
390            "dry_run": true
391        });
392        let input = EditTool::prepare_arguments(&params);
393        assert!(input.dry_run);
394    }
395
396    #[tokio::test]
397    async fn test_apply_edits_file_not_found() {
398        let input = EditInput {
399            path: "/tmp/nonexistent_file_12345.txt".to_string(),
400            edits: vec![EditEntry {
401                old_text: "foo".to_string(),
402                new_text: "bar".to_string(),
403            }],
404            dry_run: false,
405            expected_hash: None,
406        };
407        let result = EditTool::apply_edits(Path::new("."), &input).await;
408        assert!(result.is_err());
409        assert!(result.unwrap_err().contains("Cannot read file"));
410    }
411
412    #[tokio::test]
413    async fn test_apply_edits_dry_run() {
414        let dir = tempfile::tempdir().unwrap();
415        let file_path = dir.path().join("test.txt");
416        fs::write(&file_path, "hello world\n").await.unwrap();
417
418        let input = EditInput {
419            path: file_path.to_str().unwrap().to_string(),
420            edits: vec![EditEntry {
421                old_text: "hello".to_string(),
422                new_text: "goodbye".to_string(),
423            }],
424            dry_run: true,
425            expected_hash: None,
426        };
427        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
428        assert!(!output.applied);
429        assert!(output.diff.contains("-hello"));
430        assert!(output.diff.contains("+goodbye"));
431
432        // Verify file was not modified
433        let content = fs::read_to_string(&file_path).await.unwrap();
434        assert_eq!(content, "hello world\n");
435    }
436
437    #[tokio::test]
438    async fn test_apply_edits_single_edit() {
439        let dir = tempfile::tempdir().unwrap();
440        let file_path = dir.path().join("test.txt");
441        fs::write(&file_path, "hello world\nfoo bar\n")
442            .await
443            .unwrap();
444
445        let input = EditInput {
446            path: file_path.to_str().unwrap().to_string(),
447            edits: vec![EditEntry {
448                old_text: "hello".to_string(),
449                new_text: "goodbye".to_string(),
450            }],
451            dry_run: false,
452            expected_hash: None,
453        };
454        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
455        assert!(output.applied);
456        assert!(output.message.contains("1 edit(s)"));
457
458        let content = fs::read_to_string(&file_path).await.unwrap();
459        assert_eq!(content, "goodbye world\nfoo bar\n");
460    }
461
462    #[tokio::test]
463    async fn test_apply_edits_multiple_edits() {
464        let dir = tempfile::tempdir().unwrap();
465        let file_path = dir.path().join("test.txt");
466        fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
467
468        let input = EditInput {
469            path: file_path.to_str().unwrap().to_string(),
470            edits: vec![
471                EditEntry {
472                    old_text: "aaa".to_string(),
473                    new_text: "AAA".to_string(),
474                },
475                EditEntry {
476                    old_text: "ccc".to_string(),
477                    new_text: "CCC".to_string(),
478                },
479            ],
480            dry_run: false,
481            expected_hash: None,
482        };
483        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
484        assert!(output.applied);
485        assert!(output.message.contains("2 edit(s)"));
486
487        let content = fs::read_to_string(&file_path).await.unwrap();
488        assert_eq!(content, "AAA\nbbb\nCCC\n");
489    }
490
491    #[tokio::test]
492    async fn test_apply_edits_crlf_preserved() {
493        let dir = tempfile::tempdir().unwrap();
494        let file_path = dir.path().join("test.txt");
495        fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
496
497        let input = EditInput {
498            path: file_path.to_str().unwrap().to_string(),
499            edits: vec![EditEntry {
500                old_text: "hello".to_string(),
501                new_text: "goodbye".to_string(),
502            }],
503            dry_run: false,
504            expected_hash: None,
505        };
506        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
507
508        let content = fs::read_to_string(&file_path).await.unwrap();
509        assert_eq!(content, "goodbye\r\nworld\r\n");
510    }
511
512    #[tokio::test]
513    async fn test_apply_edits_bom_preserved() {
514        let dir = tempfile::tempdir().unwrap();
515        let file_path = dir.path().join("test.txt");
516        fs::write(&file_path, "\u{feff}hello world\n")
517            .await
518            .unwrap();
519
520        let input = EditInput {
521            path: file_path.to_str().unwrap().to_string(),
522            edits: vec![EditEntry {
523                old_text: "hello".to_string(),
524                new_text: "goodbye".to_string(),
525            }],
526            dry_run: false,
527            expected_hash: None,
528        };
529        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
530
531        let content = fs::read_to_string(&file_path).await.unwrap();
532        assert!(content.starts_with('\u{feff}'));
533        assert!(content.contains("goodbye"));
534    }
535
536    #[test]
537    fn test_prepare_arguments_expected_hash() {
538        let params = json!({
539            "path": "/tmp/test.txt",
540            "old_text": "hello",
541            "new_text": "world",
542            "expected_hash": "abcd1234"
543        });
544        let input = EditTool::prepare_arguments(&params);
545        assert_eq!(input.expected_hash.as_deref(), Some("abcd1234"));
546    }
547
548    #[test]
549    fn test_prepare_arguments_no_expected_hash() {
550        let params = json!({
551            "path": "/tmp/test.txt",
552            "old_text": "hello",
553            "new_text": "world"
554        });
555        let input = EditTool::prepare_arguments(&params);
556        assert!(input.expected_hash.is_none());
557    }
558
559    fn compute_hash(content: &str) -> String {
560        use std::hash::{Hash, Hasher};
561        let mut hasher = std::collections::hash_map::DefaultHasher::new();
562        content.hash(&mut hasher);
563        format!("{:016x}", hasher.finish())
564    }
565
566    #[tokio::test]
567    async fn test_conflict_detection_hash_mismatch() {
568        let dir = tempfile::tempdir().unwrap();
569        let file_path = dir.path().join("test.txt");
570        fs::write(&file_path, "hello world\n").await.unwrap();
571
572        let hash = compute_hash("hello world\n");
573
574        // Modify the file after computing the hash
575        fs::write(&file_path, "hello modified world\n")
576            .await
577            .unwrap();
578
579        let input = EditInput {
580            path: file_path.to_str().unwrap().to_string(),
581            edits: vec![EditEntry {
582                old_text: "hello".to_string(),
583                new_text: "goodbye".to_string(),
584            }],
585            dry_run: false,
586            expected_hash: Some(hash),
587        };
588        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
589        assert!(!output.applied);
590        assert!(output.message.contains("modified since last read"));
591
592        // Verify file was NOT modified by the edit attempt
593        let content = fs::read_to_string(&file_path).await.unwrap();
594        assert_eq!(content, "hello modified world\n");
595    }
596
597    #[tokio::test]
598    async fn test_conflict_detection_hash_match() {
599        let dir = tempfile::tempdir().unwrap();
600        let file_path = dir.path().join("test.txt");
601        fs::write(&file_path, "hello world\n").await.unwrap();
602
603        let hash = compute_hash("hello world\n");
604
605        let input = EditInput {
606            path: file_path.to_str().unwrap().to_string(),
607            edits: vec![EditEntry {
608                old_text: "hello".to_string(),
609                new_text: "goodbye".to_string(),
610            }],
611            dry_run: false,
612            expected_hash: Some(hash),
613        };
614        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
615        assert!(output.applied);
616
617        let content = fs::read_to_string(&file_path).await.unwrap();
618        assert_eq!(content, "goodbye world\n");
619    }
620}