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