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, Edit, EditDiffError, detect_line_ending, has_bom, normalize_to_lf, restore_line_endings,
11    strip_bom,
12};
13use super::file_mutation_queue::global_mutation_queue;
14use super::hashline_fs::TokioHashlineFs;
15use super::path_security::PathGuard;
16use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
17use async_trait::async_trait;
18use oxi_hashline::parser::split_patch_input;
19use oxi_hashline::patcher::Patcher;
20use serde::{Deserialize, Serialize};
21use serde_json::{Value, json};
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use tokio::fs;
25use tokio::sync::oneshot;
26
27/// EditTool.
28pub struct EditTool {
29    root_dir: Option<PathBuf>,
30}
31
32impl EditTool {
33    /// Create with no explicit root (uses ToolContext.workspace_dir at runtime).
34    pub fn new() -> Self {
35        Self { root_dir: None }
36    }
37
38    /// Create with a specific working directory (overrides ToolContext).
39    pub fn with_cwd(cwd: PathBuf) -> Self {
40        Self {
41            root_dir: Some(cwd),
42        }
43    }
44
45    /// Prepare edit arguments, handling both legacy and multi-edit formats.
46    /// Some models send edits as a JSON string instead of an array.
47    fn prepare_arguments(params: &Value) -> EditInput {
48        let path = params
49            .get("path")
50            .or(params.get("file_path"))
51            .and_then(|v| v.as_str())
52            .unwrap_or("")
53            .to_string();
54
55        // Try to parse edits array
56        let mut edits: Vec<EditEntry> = Vec::new();
57
58        if let Some(edits_val) = params.get("edits") {
59            // Some models send edits as a JSON string
60            let edits_val = if let Some(s) = edits_val.as_str() {
61                serde_json::from_str::<Vec<EditEntry>>(s).unwrap_or_default()
62            } else if let Some(arr) = edits_val.as_array() {
63                arr.iter()
64                    .filter_map(|v| serde_json::from_value::<EditEntry>(v.clone()).ok())
65                    .collect()
66            } else {
67                Vec::new()
68            };
69            edits = edits_val;
70        }
71
72        // Legacy mode: old_text + new_text
73        if edits.is_empty()
74            && let (Some(old), Some(new)) = (
75                params
76                    .get("old_text")
77                    .or(params.get("oldText"))
78                    .and_then(|v| v.as_str()),
79                params
80                    .get("new_text")
81                    .or(params.get("newText"))
82                    .and_then(|v| v.as_str()),
83            )
84        {
85            edits.push(EditEntry {
86                old_text: old.to_string(),
87                new_text: new.to_string(),
88            });
89        }
90
91        let dry_run = params
92            .get("dry_run")
93            .and_then(|v| v.as_bool())
94            .unwrap_or(false);
95
96        let expected_hash = params
97            .get("expected_hash")
98            .and_then(|v| v.as_str())
99            .map(|s| s.to_string());
100
101        // Hashline mode: if a `patch` field is present, use hashline dispatch.
102        let patch = params
103            .get("patch")
104            .and_then(|v| v.as_str())
105            .map(|s| s.to_string());
106
107        EditInput {
108            path,
109            edits,
110            dry_run,
111            expected_hash,
112            patch,
113        }
114    }
115
116    /// Apply edits to a file with full BOM/line-ending handling and diff output.
117    async fn apply_edits(root_dir: &Path, input: &EditInput) -> Result<EditOutput, ToolError> {
118        // Security: validate path with PathGuard
119        let guard = PathGuard::new(root_dir);
120        let validated_path = guard
121            .validate_traversal(Path::new(&input.path))
122            .map_err(|e| e.to_string())?;
123        let path = validated_path.as_path();
124
125        // Validate edits
126        if input.edits.is_empty() {
127            return Err(
128                "No edits provided. Either use old_text/new_text or edits array.".to_string(),
129            );
130        }
131
132        // Content-based conflict detection
133        // Note: We do a synchronous read for the hash check, then reuse the
134        // content below via the async read. The gap is minimal and the
135        // file_mutation_queue serializes concurrent edits to the same file.
136        if let Some(ref expected) = input.expected_hash {
137            let current_content = std::fs::read_to_string(path)
138                .map_err(|e| format!("Failed to read file for hash check: {}", e))?;
139            use std::hash::{Hash, Hasher};
140            let mut hasher = std::collections::hash_map::DefaultHasher::new();
141            current_content.hash(&mut hasher);
142            let current_hash = format!("{:016x}", hasher.finish());
143            if current_hash != *expected {
144                return Ok(EditOutput {
145                    diff: String::new(),
146                    first_changed_line: None,
147                    applied: false,
148                    message: "File has been modified since last read. Re-read the file and retry."
149                        .to_string(),
150                });
151            }
152        }
153
154        // Read file content
155        let raw_content = fs::read_to_string(path)
156            .await
157            .map_err(|e| format!("Cannot read file '{}': {}", input.path, e))?;
158
159        // Detect BOM and line endings
160        let had_bom = has_bom(&raw_content);
161        let line_ending = detect_line_ending(&raw_content);
162        let content = normalize_to_lf(strip_bom(&raw_content));
163
164        // Convert to Edit structs with normalized text
165        let edits: Vec<Edit> = input
166            .edits
167            .iter()
168            .map(|e| Edit {
169                old_text: normalize_to_lf(&e.old_text),
170                new_text: normalize_to_lf(&e.new_text),
171            })
172            .collect();
173
174        // Compute diff for preview
175        let diff_result = edit_diff::generate_diff_string(&content, &edits, 4)
176            .map_err(|e: EditDiffError| e.message)?;
177
178        if input.dry_run {
179            return Ok(EditOutput {
180                diff: diff_result.diff,
181                first_changed_line: diff_result.first_changed_line,
182                applied: false,
183                message: "Dry run — no changes applied".to_string(),
184            });
185        }
186
187        // Apply edits to normalized content
188        let modified = edit_diff::apply_edits_to_normalized_content(&content, &edits)
189            .map_err(|e: EditDiffError| e.message)?;
190
191        // Restore line endings and BOM
192        let mut final_content = restore_line_endings(&modified, line_ending);
193        if had_bom {
194            final_content = format!("\u{feff}{}", final_content);
195        }
196
197        // Write through mutation queue (serializes per-file)
198        let final_content_clone = final_content.clone();
199        global_mutation_queue()
200            .with_queue(path, || async {
201                fs::write(&validated_path, &final_content_clone)
202                    .await
203                    .map_err(|e| format!("Cannot write file '{}': {}", validated_path.display(), e))
204            })
205            .await
206            .map_err(|e: String| e)?;
207
208        Ok(EditOutput {
209            diff: diff_result.diff,
210            first_changed_line: diff_result.first_changed_line,
211            applied: true,
212            message: format!("Applied {} edit(s) to {}", edits.len(), input.path),
213        })
214    }
215
216    /// Apply a hashline patch.
217    async fn apply_hashline(
218        root_dir: &Path,
219        patch_text: &str,
220        dry_run: bool,
221        ctx: &ToolContext,
222    ) -> Result<EditOutput, ToolError> {
223        let snapshots = ctx.snapshot_store.clone().ok_or_else(|| {
224            "Hashline edit mode requires a snapshot store (not configured in this session)."
225                .to_string()
226        })?;
227
228        let patch = split_patch_input(patch_text, None).map_err(|e| e.to_string())?;
229
230        if dry_run {
231            // Preflight without writing.
232            let fs = Arc::new(TokioHashlineFs::new(root_dir.to_path_buf()));
233            let patcher = Patcher::new(fs, snapshots);
234            patcher.preflight(&patch).await.map_err(|e| e.to_string())?;
235            return Ok(EditOutput {
236                diff: String::new(),
237                first_changed_line: None,
238                applied: false,
239                message: "Dry run — no changes applied".to_string(),
240            });
241        }
242
243        let fs = Arc::new(TokioHashlineFs::new(root_dir.to_path_buf()));
244        let patcher = Patcher::new(fs, snapshots);
245        let result = patcher.apply(&patch).await.map_err(|e| e.to_string())?;
246
247        let mut diff_parts = Vec::new();
248        let mut messages = Vec::new();
249        let mut first_changed: Option<usize> = None;
250        for section in &result.sections {
251            if !section.diff.is_empty() {
252                diff_parts.push(format!(
253                    "[{}#{}]\n{}",
254                    section.path, section.new_hash, section.diff
255                ));
256            }
257            for w in &section.warnings {
258                diff_parts.push(format!("⚠ {w}"));
259            }
260            if first_changed.is_none()
261                && let Some(line) = section.first_changed_line
262            {
263                first_changed = Some(line as usize);
264            }
265            messages.push(format!("{}#{}", section.path, section.new_hash));
266        }
267
268        let section_count = result.sections.len();
269        Ok(EditOutput {
270            diff: diff_parts.join("\n"),
271            first_changed_line: first_changed,
272            applied: true,
273            message: format!(
274                "Applied hashline patch to {} section(s). New tags: {}",
275                section_count,
276                messages.join(", ")
277            ),
278        })
279    }
280}
281
282impl Default for EditTool {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288/// Parsed edit input
289#[derive(Default)]
290struct EditInput {
291    path: String,
292    edits: Vec<EditEntry>,
293    dry_run: bool,
294    /// Hash of file content at last read. If provided, edit will be
295    /// rejected if the file has been modified since.
296    expected_hash: Option<String>,
297    /// Hashline patch text. When present, hashline mode is used instead of
298    /// str_replace.
299    patch: Option<String>,
300}
301
302/// A single edit entry
303#[derive(Debug, Clone, Serialize, Deserialize)]
304struct EditEntry {
305    #[serde(rename = "oldText", alias = "old_text")]
306    old_text: String,
307    #[serde(rename = "newText", alias = "new_text")]
308    new_text: String,
309}
310
311/// Result of edit operation
312#[derive(Debug)]
313
314struct EditOutput {
315    diff: String,
316    first_changed_line: Option<usize>,
317    #[allow(dead_code)]
318    applied: bool,
319    message: String,
320}
321
322#[async_trait]
323impl AgentTool for EditTool {
324    fn name(&self) -> &str {
325        "edit"
326    }
327
328    fn label(&self) -> &str {
329        "Edit File"
330    }
331
332    fn essential(&self) -> bool {
333        true
334    }
335    fn description(&self) -> &str {
336        "Make targeted edits to a file. Supports both single edit (old_text/new_text) and multiple edits (edits[] array). \
337         Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. \
338         If two changes touch the same block or nearby lines, merge them into one edit instead. \
339         Use dry_run=true to preview without making changes."
340    }
341
342    fn parameters_schema(&self) -> Value {
343        json!({
344            "type": "object",
345            "properties": {
346                "path": {
347                    "type": "string",
348                    "description": "Path to the file to edit (relative or absolute)"
349                },
350                "edits": {
351                    "type": "array",
352                    "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally.",
353                    "items": {
354                        "type": "object",
355                        "properties": {
356                            "oldText": {
357                                "type": "string",
358                                "description": "Exact text for one targeted replacement. Must be unique in the original file."
359                            },
360                            "newText": {
361                                "type": "string",
362                                "description": "Replacement text for this targeted edit."
363                            }
364                        },
365                        "required": ["oldText", "newText"]
366                    }
367                },
368                "old_text": {
369                    "type": "string",
370                    "description": "Legacy: exact text to replace (use edits[] instead for new code)"
371                },
372                "new_text": {
373                    "type": "string",
374                    "description": "Legacy: replacement text (use edits[] instead for new code)"
375                },
376                "dry_run": {
377                    "type": "boolean",
378                    "description": "If true, preview the change without applying it",
379                    "default": false
380                },
381                "expected_hash": {
382                    "type": "string",
383                    "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."
384                },
385                "patch": {
386                    "type": "string",
387                    "description": "Hashline patch text (*** Begin Patch … *** End Patch). When present, hashline line-anchored editing is used instead of str_replace. Mutually exclusive with edits/old_text/new_text."
388                }
389            },
390            "required": ["path"]
391        })
392    }
393
394    async fn execute(
395        &self,
396        _tool_call_id: &str,
397        params: Value,
398        _signal: Option<oneshot::Receiver<()>>,
399        ctx: &ToolContext,
400    ) -> Result<AgentToolResult, ToolError> {
401        let input = Self::prepare_arguments(&params);
402
403        // Use root_dir if set, else ctx.root()
404        let root = self.root_dir.as_deref().unwrap_or(ctx.root());
405
406        // Dispatch: hashline mode if `patch` field is present, else str_replace.
407        let output = if let Some(ref patch_text) = input.patch {
408            Self::apply_hashline(root, patch_text, input.dry_run, ctx).await
409        } else {
410            Self::apply_edits(root, &input).await
411        };
412
413        match output {
414            Ok(output) => {
415                let mut result =
416                    AgentToolResult::success(format!("{}\n\n{}", output.message, output.diff));
417
418                // Add metadata with first changed line for editor navigation
419                if let Some(line) = output.first_changed_line {
420                    result = result.with_metadata(json!({
421                        "firstChangedLine": line,
422                    }));
423                }
424
425                Ok(result)
426            }
427            Err(e) => Ok(AgentToolResult::error(e)),
428        }
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn test_prepare_arguments_legacy() {
438        let params = json!({
439            "path": "/tmp/test.txt",
440            "old_text": "hello",
441            "new_text": "world"
442        });
443        let input = EditTool::prepare_arguments(&params);
444        assert_eq!(input.path, "/tmp/test.txt");
445        assert_eq!(input.edits.len(), 1);
446        assert_eq!(input.edits[0].old_text, "hello");
447        assert_eq!(input.edits[0].new_text, "world");
448        assert!(!input.dry_run);
449    }
450
451    #[test]
452    fn test_prepare_arguments_multi_edit() {
453        let params = json!({
454            "path": "/tmp/test.txt",
455            "edits": [
456                {"oldText": "foo", "newText": "bar"},
457                {"oldText": "baz", "newText": "qux"}
458            ]
459        });
460        let input = EditTool::prepare_arguments(&params);
461        assert_eq!(input.edits.len(), 2);
462    }
463
464    #[test]
465    fn test_prepare_arguments_edits_as_string() {
466        let params = json!({
467            "path": "/tmp/test.txt",
468            "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
469        });
470        let input = EditTool::prepare_arguments(&params);
471        assert_eq!(input.edits.len(), 1);
472        assert_eq!(input.edits[0].old_text, "a");
473    }
474
475    #[test]
476    fn test_prepare_arguments_dry_run() {
477        let params = json!({
478            "path": "/tmp/test.txt",
479            "old_text": "hello",
480            "new_text": "world",
481            "dry_run": true
482        });
483        let input = EditTool::prepare_arguments(&params);
484        assert!(input.dry_run);
485    }
486
487    #[tokio::test]
488    async fn test_apply_edits_file_not_found() {
489        let input = EditInput {
490            path: "/tmp/nonexistent_file_12345.txt".to_string(),
491            edits: vec![EditEntry {
492                old_text: "foo".to_string(),
493                new_text: "bar".to_string(),
494            }],
495            dry_run: false,
496            expected_hash: None,
497            ..Default::default()
498        };
499        let result = EditTool::apply_edits(Path::new("."), &input).await;
500        assert!(result.is_err());
501        assert!(result.unwrap_err().contains("Cannot read file"));
502    }
503
504    #[tokio::test]
505    async fn test_apply_edits_dry_run() {
506        let dir = tempfile::tempdir().unwrap();
507        let file_path = dir.path().join("test.txt");
508        fs::write(&file_path, "hello world\n").await.unwrap();
509
510        let input = EditInput {
511            path: file_path.to_str().unwrap().to_string(),
512            edits: vec![EditEntry {
513                old_text: "hello".to_string(),
514                new_text: "goodbye".to_string(),
515            }],
516            dry_run: true,
517            expected_hash: None,
518            ..Default::default()
519        };
520        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
521        assert!(!output.applied);
522        assert!(output.diff.contains("-hello"));
523        assert!(output.diff.contains("+goodbye"));
524
525        // Verify file was not modified
526        let content = fs::read_to_string(&file_path).await.unwrap();
527        assert_eq!(content, "hello world\n");
528    }
529
530    #[tokio::test]
531    async fn test_apply_edits_single_edit() {
532        let dir = tempfile::tempdir().unwrap();
533        let file_path = dir.path().join("test.txt");
534        fs::write(&file_path, "hello world\nfoo bar\n")
535            .await
536            .unwrap();
537
538        let input = EditInput {
539            path: file_path.to_str().unwrap().to_string(),
540            edits: vec![EditEntry {
541                old_text: "hello".to_string(),
542                new_text: "goodbye".to_string(),
543            }],
544            dry_run: false,
545            expected_hash: None,
546            ..Default::default()
547        };
548        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
549        assert!(output.applied);
550        assert!(output.message.contains("1 edit(s)"));
551
552        let content = fs::read_to_string(&file_path).await.unwrap();
553        assert_eq!(content, "goodbye world\nfoo bar\n");
554    }
555
556    #[tokio::test]
557    async fn test_apply_edits_multiple_edits() {
558        let dir = tempfile::tempdir().unwrap();
559        let file_path = dir.path().join("test.txt");
560        fs::write(&file_path, "aaa\nbbb\nccc\n").await.unwrap();
561
562        let input = EditInput {
563            path: file_path.to_str().unwrap().to_string(),
564            edits: vec![
565                EditEntry {
566                    old_text: "aaa".to_string(),
567                    new_text: "AAA".to_string(),
568                },
569                EditEntry {
570                    old_text: "ccc".to_string(),
571                    new_text: "CCC".to_string(),
572                },
573            ],
574            dry_run: false,
575            expected_hash: None,
576            ..Default::default()
577        };
578        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
579        assert!(output.applied);
580        assert!(output.message.contains("2 edit(s)"));
581
582        let content = fs::read_to_string(&file_path).await.unwrap();
583        assert_eq!(content, "AAA\nbbb\nCCC\n");
584    }
585
586    #[tokio::test]
587    async fn test_apply_edits_crlf_preserved() {
588        let dir = tempfile::tempdir().unwrap();
589        let file_path = dir.path().join("test.txt");
590        fs::write(&file_path, "hello\r\nworld\r\n").await.unwrap();
591
592        let input = EditInput {
593            path: file_path.to_str().unwrap().to_string(),
594            edits: vec![EditEntry {
595                old_text: "hello".to_string(),
596                new_text: "goodbye".to_string(),
597            }],
598            dry_run: false,
599            expected_hash: None,
600            ..Default::default()
601        };
602        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
603
604        let content = fs::read_to_string(&file_path).await.unwrap();
605        assert_eq!(content, "goodbye\r\nworld\r\n");
606    }
607
608    #[tokio::test]
609    async fn test_apply_edits_bom_preserved() {
610        let dir = tempfile::tempdir().unwrap();
611        let file_path = dir.path().join("test.txt");
612        fs::write(&file_path, "\u{feff}hello world\n")
613            .await
614            .unwrap();
615
616        let input = EditInput {
617            path: file_path.to_str().unwrap().to_string(),
618            edits: vec![EditEntry {
619                old_text: "hello".to_string(),
620                new_text: "goodbye".to_string(),
621            }],
622            dry_run: false,
623            expected_hash: None,
624            ..Default::default()
625        };
626        EditTool::apply_edits(Path::new("."), &input).await.unwrap();
627
628        let content = fs::read_to_string(&file_path).await.unwrap();
629        assert!(content.starts_with('\u{feff}'));
630        assert!(content.contains("goodbye"));
631    }
632
633    #[test]
634    fn test_prepare_arguments_expected_hash() {
635        let params = json!({
636            "path": "/tmp/test.txt",
637            "old_text": "hello",
638            "new_text": "world",
639            "expected_hash": "abcd1234"
640        });
641        let input = EditTool::prepare_arguments(&params);
642        assert_eq!(input.expected_hash.as_deref(), Some("abcd1234"));
643    }
644
645    #[test]
646    fn test_prepare_arguments_no_expected_hash() {
647        let params = json!({
648            "path": "/tmp/test.txt",
649            "old_text": "hello",
650            "new_text": "world"
651        });
652        let input = EditTool::prepare_arguments(&params);
653        assert!(input.expected_hash.is_none());
654    }
655
656    fn compute_hash(content: &str) -> String {
657        use std::hash::{Hash, Hasher};
658        let mut hasher = std::collections::hash_map::DefaultHasher::new();
659        content.hash(&mut hasher);
660        format!("{:016x}", hasher.finish())
661    }
662
663    #[tokio::test]
664    async fn test_conflict_detection_hash_mismatch() {
665        let dir = tempfile::tempdir().unwrap();
666        let file_path = dir.path().join("test.txt");
667        fs::write(&file_path, "hello world\n").await.unwrap();
668
669        let hash = compute_hash("hello world\n");
670
671        // Modify the file after computing the hash
672        fs::write(&file_path, "hello modified world\n")
673            .await
674            .unwrap();
675
676        let input = EditInput {
677            path: file_path.to_str().unwrap().to_string(),
678            edits: vec![EditEntry {
679                old_text: "hello".to_string(),
680                new_text: "goodbye".to_string(),
681            }],
682            dry_run: false,
683            expected_hash: Some(hash),
684            ..Default::default()
685        };
686        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
687        assert!(!output.applied);
688        assert!(output.message.contains("modified since last read"));
689
690        // Verify file was NOT modified by the edit attempt
691        let content = fs::read_to_string(&file_path).await.unwrap();
692        assert_eq!(content, "hello modified world\n");
693    }
694
695    #[tokio::test]
696    async fn test_conflict_detection_hash_match() {
697        let dir = tempfile::tempdir().unwrap();
698        let file_path = dir.path().join("test.txt");
699        fs::write(&file_path, "hello world\n").await.unwrap();
700
701        let hash = compute_hash("hello world\n");
702
703        let input = EditInput {
704            path: file_path.to_str().unwrap().to_string(),
705            edits: vec![EditEntry {
706                old_text: "hello".to_string(),
707                new_text: "goodbye".to_string(),
708            }],
709            dry_run: false,
710            expected_hash: Some(hash),
711            ..Default::default()
712        };
713        let output = EditTool::apply_edits(Path::new("."), &input).await.unwrap();
714        assert!(output.applied);
715
716        let content = fs::read_to_string(&file_path).await.unwrap();
717        assert_eq!(content, "goodbye world\n");
718    }
719}