1use async_trait::async_trait;
25use serde_json::{json, Value};
26use std::path::PathBuf;
27use std::sync::{Arc, Mutex};
28
29use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
30use crate::acp::ToolKind;
31use crate::error::{Error, Result};
32use crate::llm::ToolSpec;
33use crate::tools::fs::{get_file_mtime, ReadFileState};
34
35const MAX_EDIT_FILE_SIZE: u64 = 1024 * 1024 * 1024;
39
40#[derive(Debug, Clone)]
41pub struct EditTool {
42 pub root: PathBuf,
43 pub extra_roots: Vec<(PathBuf, AccessTier)>,
44 pub session_roots: Option<SharedSandboxRoots>,
45 pub read_state: Option<Arc<Mutex<ReadFileState>>>,
49}
50
51impl EditTool {
52 pub fn new(root: impl Into<PathBuf>) -> Self {
53 Self {
54 root: root.into(),
55 extra_roots: Vec::new(),
56 session_roots: None,
57 read_state: None,
58 }
59 }
60
61 pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
62 self.read_state = Some(slot);
63 self
64 }
65
66 pub fn with_extra_roots(
69 mut self,
70 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
71 ) -> Self {
72 self.extra_roots.extend(extra);
73 self
74 }
75
76 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
78 self.session_roots = Some(slot);
79 self
80 }
81
82 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
84 if let Some(s) = slot {
85 self.session_roots = Some(s);
86 }
87 self
88 }
89
90 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
91 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
92 v.push((self.root.clone(), AccessTier::ReadWrite));
93 v.extend(self.extra_roots.iter().cloned());
94 if let Some(slot) = &self.session_roots {
95 if let Ok(roots) = slot.read() {
96 v.extend(roots.iter().cloned());
97 }
98 }
99 v
100 }
101}
102
103const LEFT_SINGLE: char = '\u{2018}';
108const RIGHT_SINGLE: char = '\u{2019}';
109const LEFT_DOUBLE: char = '\u{201c}';
110const RIGHT_DOUBLE: char = '\u{201d}';
111
112fn normalize_quotes(s: &str) -> String {
114 s.replace([LEFT_SINGLE, RIGHT_SINGLE], "'")
115 .replace([LEFT_DOUBLE, RIGHT_DOUBLE], "\"")
116}
117
118fn is_opening_context(chars: &[char], i: usize) -> bool {
119 if i == 0 {
120 return true;
121 }
122 matches!(
123 chars[i - 1],
124 ' ' | '\t' | '\n' | '\r' | '(' | '[' | '{' | '\u{2014}' | '\u{2013}'
125 )
126}
127
128fn apply_curly_double(s: &str) -> String {
130 let chars: Vec<char> = s.chars().collect();
131 let mut out = String::with_capacity(s.len());
132 for (i, &c) in chars.iter().enumerate() {
133 if c == '"' {
134 out.push(if is_opening_context(&chars, i) {
135 LEFT_DOUBLE
136 } else {
137 RIGHT_DOUBLE
138 });
139 } else {
140 out.push(c);
141 }
142 }
143 out
144}
145
146fn apply_curly_single(s: &str) -> String {
148 let chars: Vec<char> = s.chars().collect();
149 let mut out = String::with_capacity(s.len());
150 for (i, &c) in chars.iter().enumerate() {
151 if c == '\'' {
152 let prev_letter = i > 0 && chars[i - 1].is_alphabetic(); let next_letter = i + 1 < chars.len() && chars[i + 1].is_alphabetic(); if prev_letter && next_letter {
159 out.push(RIGHT_SINGLE);
160 } else if is_opening_context(&chars, i) {
161 out.push(LEFT_SINGLE);
162 } else {
163 out.push(RIGHT_SINGLE);
164 }
165 } else {
166 out.push(c);
167 }
168 }
169 out
170}
171
172fn preserve_quote_style(old_string: &str, actual_old: &str, new_string: &str) -> String {
176 if old_string == actual_old {
177 return new_string.to_string();
178 }
179 let has_double = actual_old.contains(LEFT_DOUBLE) || actual_old.contains(RIGHT_DOUBLE);
180 let has_single = actual_old.contains(LEFT_SINGLE) || actual_old.contains(RIGHT_SINGLE);
181 if !has_double && !has_single {
182 return new_string.to_string();
183 }
184 let mut result = new_string.to_string();
185 if has_double {
186 result = apply_curly_double(&result);
187 }
188 if has_single {
189 result = apply_curly_single(&result);
190 }
191 result
192}
193
194fn strip_trailing_whitespace(s: &str) -> String {
200 let trailing_newline = s.ends_with('\n');
201 let stripped: Vec<&str> = s.lines().map(|l| l.trim_end()).collect();
202 let out = stripped.join("\n");
203 if trailing_newline {
204 out + "\n"
205 } else {
206 out
207 }
208}
209
210fn desanitize(s: &str) -> String {
217 let mut out = s.to_string();
218 let pairs: &[(&str, &str)] = &[
219 ("<fnr>", "<function_results>"),
220 ("</fnr>", "</function_results>"),
221 ("<n>", "<name>"),
222 ("</n>", "</name>"),
223 ("<o>", "<output>"),
224 ("</o>", "</output>"),
225 ("<e>", "<error>"),
226 ("</e>", "</error>"),
227 ("<s>", "<system>"),
228 ("</s>", "</system>"),
229 ("<r>", "<result>"),
230 ("</r>", "</result>"),
231 ("< META_START >", "<META_START>"),
232 ("< META_END >", "</META_END>"),
233 ("< EOT >", "<EOT>"),
234 ("< META >", "<META>"),
235 ("< SOS >", "<SOS>"),
236 (
237 "
238
239H:", "
240
241Human:",
242 ),
243 (
244 "
245
246A:",
247 "
248
249Assistant:",
250 ),
251 ];
252 for &(from, to) in pairs {
253 if out.contains(from) {
254 out = out.replace(from, to);
255 }
256 }
257 out
258}
259
260fn try_match(haystack: &str, needle: &str) -> Option<String> {
275 if haystack.contains(needle) {
277 return Some(needle.to_string());
278 }
279
280 let qn_needle = normalize_quotes(needle);
289 let qn_haystack = normalize_quotes(haystack);
290 if qn_needle != needle || qn_haystack != haystack {
292 if let Some(idx) = qn_haystack.find(qn_needle.as_str()) {
293 let actual = extract_by_char_count(haystack, &qn_haystack, idx, needle.chars().count());
298 return Some(actual);
299 }
300 }
301
302 let tws = strip_trailing_whitespace(needle);
304 if tws != needle && haystack.contains(tws.as_str()) {
305 return Some(tws);
306 }
307
308 let qn_tws = strip_trailing_whitespace(&qn_needle);
310 if qn_tws != needle && qn_tws != qn_needle && qn_tws != tws {
314 let qn_tws_haystack = strip_trailing_whitespace(&qn_haystack);
316 if let Some(idx) = qn_tws_haystack.find(qn_tws.as_str()) {
317 let actual =
318 extract_by_char_count(haystack, &qn_tws_haystack, idx, needle.chars().count());
319 return Some(actual);
320 }
321 }
322
323 let ds = desanitize(needle);
325 if ds != needle && haystack.contains(ds.as_str()) {
326 return Some(ds);
327 }
328
329 None
330}
331
332fn extract_by_char_count(
340 original: &str,
341 normalised: &str,
342 byte_idx: usize,
343 char_len: usize,
344) -> String {
345 let char_start = normalised[..byte_idx].chars().count();
347 original.chars().skip(char_start).take(char_len).collect()
348}
349
350#[async_trait]
355impl Tool for EditTool {
356 fn spec(&self) -> ToolSpec {
357 ToolSpec {
358 name: "Edit".into(),
359 description: "Performs exact string replacements in files.\n\
360Usage:\n\
361- You must use your `Read` tool at least once in the conversation before editing. \
362This tool will error if you attempt an edit without reading the file.\n\
363- When editing text from Read tool output, ensure you preserve the exact indentation \
364(tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: \
365line number + tab. Everything after that is the actual file content to match. \
366Never include any part of the line number prefix in the old_string or new_string.\n\
367- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless \
368explicitly required.\n\
369- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n\
370- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger \
371string with more surrounding context to make it unique or use `replace_all` to change \
372every instance of `old_string`.\n\
373- Use `replace_all` for replacing and renaming strings across the file. This parameter is \
374useful if you want to rename a variable for instance."
375 .into(),
376 parameters: json!({
377 "type": "object",
378 "properties": {
379 "file_path": {
380 "type": "string",
381 "description": "The absolute path to the file to modify"
382 },
383 "old_string": {
384 "type": "string",
385 "description": "The text to replace (must be different from new_string). If empty, creates a new file with new_string as content."
386 },
387 "new_string": {
388 "type": "string",
389 "description": "The text to replace it with (must be different from old_string)"
390 },
391 "replace_all": {
392 "type": "boolean",
393 "description": "Replace all occurrences of old_string (default false)",
394 "default": false
395 }
396 },
397 "required": ["file_path", "old_string", "new_string"]
398 }),
399 }
400 }
401
402 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
403 crate::tools::ToolSideEffect::Mutating
404 }
405
406 fn kind(&self) -> ToolKind {
407 ToolKind::Edit
408 }
409
410 async fn execute(&self, args: Value) -> Result<String> {
411 let file_path = args["file_path"]
412 .as_str()
413 .ok_or_else(|| Error::BadToolArgs {
414 name: "Edit".into(),
415 message: "missing `file_path`".into(),
416 })?;
417 let old_string = args["old_string"]
418 .as_str()
419 .ok_or_else(|| Error::BadToolArgs {
420 name: "Edit".into(),
421 message: "missing `old_string`".into(),
422 })?;
423 let new_string = args["new_string"]
424 .as_str()
425 .ok_or_else(|| Error::BadToolArgs {
426 name: "Edit".into(),
427 message: "missing `new_string`".into(),
428 })?;
429 let replace_all = args["replace_all"].as_bool().unwrap_or(false);
430
431 let abs_path = resolve_within_any(&self.all_roots(), file_path, true)?;
432
433 if let Some(slot) = &self.read_state {
463 let staleness_check: Option<(u64, String)> = {
464 let state = slot.lock().map_err(|_| Error::Tool {
465 name: "Edit".into(),
466 call_id: None,
467 message: "internal: read-state lock poisoned".into(),
468 })?;
469 match state.get(&abs_path) {
470 None => {
471 return Err(Error::Tool {
472 name: "Edit".into(),
473 call_id: None,
474 message: format!(
475 "File `{file_path}` has not been read yet. \
476 Read it first before editing."
477 ),
478 });
479 }
480 Some(record) => {
481 let disk_mtime = get_file_mtime(&abs_path);
485 if disk_mtime > record.timestamp && !record.is_partial {
486 Some((disk_mtime, record.content.clone()))
496 } else {
497 None }
499 }
500 }
501 };
503
504 if let Some((_disk_mtime, cached_content)) = staleness_check {
506 let disk_content = tokio::fs::read_to_string(&abs_path)
507 .await
508 .unwrap_or_default();
509 if disk_content != cached_content {
510 return Err(Error::Tool {
511 name: "Edit".into(),
512 call_id: None,
513 message: format!(
514 "File `{file_path}` has been modified since it was \
515 last read. Read it again before editing."
516 ),
517 });
518 }
519 }
521 }
522
523 match tokio::fs::metadata(&abs_path).await {
527 Ok(meta) => {
528 let size = meta.len();
529 if size > MAX_EDIT_FILE_SIZE {
530 return Err(Error::Tool {
531 name: "Edit".into(),
532 call_id: None,
533 message: format!(
534 "File `{file_path}` is too large to edit ({} bytes). \
535 Maximum editable file size is {} bytes.",
536 size, MAX_EDIT_FILE_SIZE
537 ),
538 });
539 }
540 }
541 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
542 }
544 Err(e) => {
545 return Err(Error::Tool {
546 name: "Edit".into(),
547 call_id: None,
548 message: format!("{}: {e}", abs_path.display()),
549 });
550 }
551 }
552
553 if old_string.is_empty() {
555 if let Some(parent) = abs_path.parent() {
556 tokio::fs::create_dir_all(parent)
557 .await
558 .map_err(|e| Error::Tool {
559 name: "Edit".into(),
560 call_id: None,
561 message: format!("mkdir {}: {e}", parent.display()),
562 })?;
563 }
564 tokio::fs::write(&abs_path, new_string)
565 .await
566 .map_err(|e| Error::Tool {
567 name: "Edit".into(),
568 call_id: None,
569 message: format!("{}: {e}", abs_path.display()),
570 })?;
571 return Ok(format!(
572 "Created `{}` ({} bytes)",
573 file_path,
574 new_string.len()
575 ));
576 }
577
578 let content = tokio::fs::read_to_string(&abs_path)
580 .await
581 .map_err(|e| Error::Tool {
582 name: "Edit".into(),
583 call_id: None,
584 message: format!("{}: {e}", abs_path.display()),
585 })?;
586
587 if old_string == new_string {
589 return Err(Error::Tool {
590 name: "Edit".into(),
591 call_id: None,
592 message: "No changes to make: old_string and new_string are exactly the same."
593 .into(),
594 });
595 }
596
597 let new_string_normalised;
604 let new_string = if file_path.ends_with(".md") || file_path.ends_with(".mdx") {
605 new_string
606 } else {
607 new_string_normalised = strip_trailing_whitespace(new_string);
608 &new_string_normalised
609 };
610
611 let actual_old = try_match(&content, old_string).ok_or_else(|| Error::Tool {
613 name: "Edit".into(),
614 call_id: None,
615 message: format!("String to replace not found in `{file_path}`.\nString: {old_string}"),
616 })?;
617
618 let occurrence_count = content.matches(actual_old.as_str()).count();
620 if !replace_all && occurrence_count > 1 {
621 return Err(Error::Tool {
622 name: "Edit".into(),
623 call_id: None,
624 message: format!(
625 "Found {occurrence_count} matches of the string to replace, but \
626replace_all is false. To replace all occurrences, set replace_all to true. \
627To replace only one occurrence, please provide more context to uniquely identify \
628the instance.\nString: {old_string}"
629 ),
630 });
631 }
632
633 let actual_new = preserve_quote_style(old_string, &actual_old, new_string);
635
636 let max_replace = if replace_all { usize::MAX } else { 1 };
638 let updated = content.replacen(actual_old.as_str(), actual_new.as_str(), max_replace);
639
640 if let Some(slot) = &self.read_state {
654 let cached: Option<(u64, bool, String)> = {
655 let state = slot.lock().map_err(|_| Error::Tool {
656 name: "Edit".into(),
657 call_id: None,
658 message: "internal: read-state lock poisoned".into(),
659 })?;
660 state
661 .get(&abs_path)
662 .map(|r| (r.timestamp, r.is_partial, r.content.clone()))
663 };
665 if let Some((cached_ts, is_partial, cached_content)) = cached {
666 let disk_mtime = get_file_mtime(&abs_path);
667 if disk_mtime > cached_ts && !is_partial && content != cached_content {
668 return Err(Error::Tool {
669 name: "Edit".into(),
670 call_id: None,
671 message: format!(
672 "File `{file_path}` was modified after it was last \
673 read. Read it again before editing."
674 ),
675 });
676 }
677 }
678 }
679
680 tokio::fs::write(&abs_path, &updated)
681 .await
682 .map_err(|e| Error::Tool {
683 name: "Edit".into(),
684 call_id: None,
685 message: format!("{}: {e}", abs_path.display()),
686 })?;
687
688 if let Some(slot) = &self.read_state {
692 if let Ok(mut state) = slot.lock() {
693 let new_mtime = get_file_mtime(&abs_path);
694 state.record(abs_path, false, updated.clone(), new_mtime);
695 }
696 }
697
698 let replaced_count = if replace_all {
699 occurrence_count.to_string()
700 } else {
701 "1".to_string()
702 };
703
704 if replace_all {
705 Ok(format!(
706 "The file {file_path} has been updated. All occurrences were successfully replaced."
707 ))
708 } else {
709 Ok(format!(
710 "The file {file_path} has been updated successfully. {replaced_count} occurrence replaced."
711 ))
712 }
713 }
714}
715
716#[cfg(test)]
721mod tests {
722 use super::*;
723 use tempfile::TempDir;
724
725 #[test]
728 fn quote_normalization_replaces_curly_quotes() {
729 let input = "Here\u{2019}s a \u{201c}quoted\u{201d} string with \u{2018}single\u{2019}.";
730 let output = normalize_quotes(input);
731 assert!(!output.contains('\u{2018}'));
732 assert!(!output.contains('\u{2019}'));
733 assert!(!output.contains('\u{201c}'));
734 assert!(!output.contains('\u{201d}'));
735 assert!(output.contains('\'')); assert!(output.contains('"')); }
738
739 #[test]
742 fn strip_trailing_whitespace_per_line() {
743 let input = "hello \nworld\t \n spaced \n";
744 let output = strip_trailing_whitespace(input);
745 assert_eq!(output, "hello\nworld\n spaced\n");
746 }
747
748 #[test]
751 fn preserve_quote_style_noop_when_exact_match() {
752 let result = preserve_quote_style("hello", "hello", "world");
753 assert_eq!(result, "world");
754 }
755
756 #[test]
757 fn preserve_quote_style_applies_curly_double_when_needed() {
758 let actual_old = "\u{201c}quoted\u{201d}";
760 let result = preserve_quote_style("\"quoted\"", actual_old, "\"replaced\"");
761 assert!(result.contains('\u{201c}') || result.contains('\u{201d}'));
763 }
764
765 #[test]
768 fn desanitize_function_results_tag() {
769 let input = "before <fnr> after";
770 let output = desanitize(input);
771 assert!(output.contains("<function_results>"), "got: {output}");
772 }
773
774 #[test]
775 fn desanitize_noop_when_no_match() {
776 let input = "nothing to desanitize here";
777 let output = desanitize(input);
778 assert_eq!(output, input);
779 }
780
781 #[test]
784 fn try_match_exact() {
785 assert_eq!(
786 try_match("hello world", "hello world"),
787 Some("hello world".to_string())
788 );
789 }
790
791 #[test]
792 fn try_match_quote_normalization() {
793 let haystack = "Here's a string";
796 let needle = "Here\u{2019}s a string";
797 assert_eq!(
798 try_match(haystack, needle),
799 Some("Here's a string".to_string())
800 );
801 }
802
803 #[test]
804 fn try_match_quote_normalization_file_has_curly_returns_original() {
805 let haystack = "say \u{201c}hello\u{201d} world";
808 let needle = "say \"hello\" world";
809 let result = try_match(haystack, needle);
810 assert!(result.is_some(), "should match via quote normalisation");
811 let matched = result.unwrap();
812 assert!(
814 matched.contains('\u{201c}'),
815 "returned slice should preserve original curly quote, got: {matched:?}"
816 );
817 assert!(
819 haystack.contains(&matched),
820 "returned slice must be present verbatim in haystack, got: {matched:?}"
821 );
822 }
823
824 #[test]
825 fn try_match_quote_norm_combined_with_tws() {
826 let haystack = "fn foo() {\n bar\n}\n";
828 let needle = "fn foo() {\n bar \n}\n";
832 let result = try_match(haystack, needle);
833 assert!(result.is_some(), "should match via tws strip");
834 let matched = result.unwrap();
835 assert!(
836 haystack.contains(&matched),
837 "returned slice must be verbatim in haystack"
838 );
839 }
840
841 #[test]
842 fn try_match_trailing_whitespace() {
843 let haystack = "fn foo() {\n bar\n}\n";
844 let needle = "fn foo() { \n bar \n}\n";
845 assert_eq!(
846 try_match(haystack, needle),
847 Some("fn foo() {\n bar\n}\n".to_string())
848 );
849 }
850
851 #[test]
852 fn try_match_desanitization() {
853 let haystack = "result: <function_results>data</function_results>";
854 let needle = "result: <fnr>data</fnr>";
855 assert!(try_match(haystack, needle).is_some());
856 }
857
858 #[test]
859 fn try_match_not_found() {
860 assert_eq!(try_match("hello world", "goodbye"), None);
861 }
862
863 #[test]
870 fn is_opening_context_position_zero_is_opening() {
871 let chars: Vec<char> = "hello".chars().collect();
872 assert!(is_opening_context(&chars, 0), "i=0 must be opening context");
873 }
874
875 #[test]
878 fn is_opening_context_after_space_is_opening() {
879 let chars: Vec<char> = " word".chars().collect();
880 assert!(
881 is_opening_context(&chars, 1),
882 "after space must be opening context"
883 );
884 }
885
886 #[test]
889 fn is_opening_context_after_letter_is_not_opening() {
890 let chars: Vec<char> = "word'end".chars().collect();
891 assert!(
892 !is_opening_context(&chars, 4),
893 "after letter must NOT be opening context"
894 );
895 }
896
897 #[test]
903 fn apply_curly_double_wraps_correctly() {
904 let result = apply_curly_double("\"hello\"");
905 assert_eq!(
906 result, "\u{201c}hello\u{201d}",
907 "opening \" → LEFT_DOUBLE, closing \" → RIGHT_DOUBLE"
908 );
909 }
910
911 #[test]
914 fn apply_curly_double_no_quotes_unchanged() {
915 let result = apply_curly_double("no quotes here");
916 assert_eq!(result, "no quotes here");
917 }
918
919 #[test]
928 fn apply_curly_single_contraction_becomes_right_curly() {
929 let result = apply_curly_single("it's");
931 assert_eq!(result, "it\u{2019}s");
932 }
933
934 #[test]
939 fn apply_curly_single_apostrophe_at_start_is_left_curly() {
940 let result = apply_curly_single("'world");
941 assert!(
943 result.starts_with('\u{2018}'),
944 "apostrophe at position 0 must be left curly; got: {result:?}"
945 );
946 }
947
948 #[test]
955 fn apply_curly_single_apostrophe_at_end_is_right_curly() {
956 let result = apply_curly_single("hello'");
958 assert!(
959 result.ends_with('\u{2019}'),
960 "trailing apostrophe must be right curly; got: {result:?}"
961 );
962 }
963
964 #[test]
967 fn apply_curly_single_no_apostrophe_unchanged() {
968 let result = apply_curly_single("hello world");
969 assert_eq!(result, "hello world");
970 }
971
972 #[test]
975 fn apply_curly_single_after_space_is_opening() {
976 let result = apply_curly_single(" 'hello");
982 let chars: Vec<char> = result.chars().collect();
983 assert_eq!(
984 chars[1], '\u{2018}',
985 "apostrophe after space must be left curly (opening); got: {result:?}"
986 );
987 }
988
989 #[test]
995 fn preserve_quote_style_left_double_only_applies_curly() {
996 let actual_old = "\u{201c}open quote only";
998 let result = preserve_quote_style("\"open quote only", actual_old, "\"replaced\"");
999 assert!(
1000 result.contains('\u{201c}') || result.contains('\u{201d}'),
1001 "only LEFT_DOUBLE in actual_old must still trigger curly-double application; got: {result:?}"
1002 );
1003 }
1004
1005 #[test]
1008 fn preserve_quote_style_left_single_only_applies_curly() {
1009 let actual_old = "\u{2018}open single only";
1010 let result = preserve_quote_style("'open single only", actual_old, "'replaced'");
1011 assert!(
1012 result.contains('\u{2018}') || result.contains('\u{2019}'),
1013 "only LEFT_SINGLE in actual_old must still trigger curly-single application; got: {result:?}"
1014 );
1015 }
1016
1017 #[test]
1022 fn preserve_quote_style_single_quotes_only_applied() {
1023 let actual_old = "\u{2018}hello\u{2019}";
1024 let result = preserve_quote_style("'hello'", actual_old, "'world'");
1025 assert!(
1026 result.contains('\u{2018}') || result.contains('\u{2019}'),
1027 "actual_old with only single curly quotes must apply single curly style; got: {result:?}"
1028 );
1029 }
1030
1031 #[test]
1037 fn try_match_trailing_whitespace_not_in_haystack_returns_none() {
1038 assert_eq!(
1040 try_match("bar baz qux", "foo "),
1041 None,
1042 "stripped needle not in haystack must return None"
1043 );
1044 }
1045
1046 #[test]
1051 fn try_match_combined_step_does_not_fire_when_no_change() {
1052 assert_eq!(try_match("hello world", "not found"), None);
1055 }
1056
1057 #[test]
1074 fn try_match_combined_quote_and_trailing_ws() {
1075 let needle = "He said \u{2019}hello\u{2019} ";
1077 let haystack = "He said 'hello'";
1078
1079 let result = try_match(haystack, needle);
1080 assert!(
1081 result.is_some(),
1082 "step 4 must match: needle={needle:?} in haystack={haystack:?}"
1083 );
1084 let matched = result.unwrap();
1085 assert!(
1086 haystack.contains(&matched),
1087 "returned slice must be verbatim in haystack, got {matched:?}"
1088 );
1089 }
1090
1091 #[test]
1097 fn try_match_desanitized_needle_not_in_haystack_returns_none() {
1098 assert_eq!(
1100 try_match("some other content", "<fnr>data</fnr>"),
1101 None,
1102 "desanitized form not in haystack must return None"
1103 );
1104 }
1105
1106 #[tokio::test]
1109 async fn exact_match_replaces_once() {
1110 let tmp = TempDir::new().unwrap();
1111 std::fs::write(
1112 tmp.path().join("src.txt"),
1113 "fn old_func() {}\nfn mid() {}\nfn other_func() {}\n",
1114 )
1115 .unwrap();
1116
1117 let tool = EditTool::new(tmp.path());
1118 let result = tool
1119 .execute(serde_json::json!({
1120 "file_path": "src.txt",
1121 "old_string": "fn old_func() {}",
1122 "new_string": "fn new() {}",
1123 }))
1124 .await
1125 .unwrap();
1126
1127 assert!(result.contains("updated successfully"));
1128 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1129 assert_eq!(content, "fn new() {}\nfn mid() {}\nfn other_func() {}\n");
1130 }
1131
1132 #[tokio::test]
1133 async fn fails_when_not_found() {
1134 let tmp = TempDir::new().unwrap();
1135 std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1136
1137 let err = EditTool::new(tmp.path())
1138 .execute(serde_json::json!({
1139 "file_path": "src.txt",
1140 "old_string": "goodbye",
1141 "new_string": "replaced"
1142 }))
1143 .await
1144 .unwrap_err();
1145
1146 let msg = format!("{err}");
1147 assert!(msg.contains("not found"), "expected not found, got: {msg}");
1148 }
1149
1150 #[tokio::test]
1151 async fn fails_when_ambiguous() {
1152 let tmp = TempDir::new().unwrap();
1153 std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
1154
1155 let err = EditTool::new(tmp.path())
1156 .execute(serde_json::json!({
1157 "file_path": "src.txt",
1158 "old_string": "foo",
1159 "new_string": "bar",
1160 }))
1161 .await
1162 .unwrap_err();
1163
1164 let msg = format!("{err}");
1165 assert!(msg.contains("Found 3 matches"), "got: {msg}");
1166 }
1167
1168 #[tokio::test]
1169 async fn replace_all_replaces_all() {
1170 let tmp = TempDir::new().unwrap();
1171 std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
1172
1173 let result = EditTool::new(tmp.path())
1174 .execute(serde_json::json!({
1175 "file_path": "src.txt",
1176 "old_string": "foo",
1177 "new_string": "bar",
1178 "replace_all": true
1179 }))
1180 .await
1181 .unwrap();
1182
1183 assert!(result.contains("All occurrences"));
1184 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1185 assert_eq!(content, "bar\nbar\nbar\n");
1186 }
1187
1188 #[tokio::test]
1189 async fn empty_old_string_creates_file() {
1190 let tmp = TempDir::new().unwrap();
1191
1192 let result = EditTool::new(tmp.path())
1193 .execute(serde_json::json!({
1194 "file_path": "new_file.txt",
1195 "old_string": "",
1196 "new_string": "brand new content"
1197 }))
1198 .await
1199 .unwrap();
1200
1201 assert!(result.contains("Created"));
1202 let content = std::fs::read_to_string(tmp.path().join("new_file.txt")).unwrap();
1203 assert_eq!(content, "brand new content");
1204 }
1205
1206 #[tokio::test]
1207 async fn empty_old_creates_nested_file() {
1208 let tmp = TempDir::new().unwrap();
1209
1210 EditTool::new(tmp.path())
1211 .execute(serde_json::json!({
1212 "file_path": "a/b/c/new_file.txt",
1213 "old_string": "",
1214 "new_string": "deep"
1215 }))
1216 .await
1217 .unwrap();
1218
1219 let content = std::fs::read_to_string(tmp.path().join("a/b/c/new_file.txt")).unwrap();
1220 assert_eq!(content, "deep");
1221 }
1222
1223 #[tokio::test]
1224 async fn identical_strings_errors() {
1225 let tmp = TempDir::new().unwrap();
1226 std::fs::write(tmp.path().join("src.txt"), "hello\n").unwrap();
1227
1228 let err = EditTool::new(tmp.path())
1229 .execute(serde_json::json!({
1230 "file_path": "src.txt",
1231 "old_string": "hello",
1232 "new_string": "hello"
1233 }))
1234 .await
1235 .unwrap_err();
1236
1237 let msg = format!("{err}");
1238 assert!(msg.contains("same"), "got: {msg}");
1239 }
1240
1241 #[tokio::test]
1242 async fn sandboxed_path_rejected() {
1243 let tmp = TempDir::new().unwrap();
1244 let err = EditTool::new(tmp.path())
1245 .execute(serde_json::json!({
1246 "file_path": "../outside.txt",
1247 "old_string": "x",
1248 "new_string": "y"
1249 }))
1250 .await
1251 .unwrap_err();
1252 let msg = format!("{err}");
1253 assert!(
1254 msg.contains("escapes") || msg.contains("BadToolArgs"),
1255 "got: {msg}"
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn quote_normalization_matches_in_file() {
1261 let tmp = TempDir::new().unwrap();
1262 std::fs::write(tmp.path().join("src.txt"), "let msg = \"it's done\";\n").unwrap();
1263
1264 let result = EditTool::new(tmp.path())
1265 .execute(serde_json::json!({
1266 "file_path": "src.txt",
1267 "old_string": "let msg = \"it\u{2019}s done\";",
1268 "new_string": "let msg = \"it's replaced\";"
1269 }))
1270 .await
1271 .unwrap();
1272
1273 assert!(result.contains("updated successfully"));
1274 }
1275
1276 #[tokio::test]
1277 async fn trailing_whitespace_strip_matches() {
1278 let tmp = TempDir::new().unwrap();
1279 std::fs::write(tmp.path().join("src.txt"), "fn foo() {\n bar\n}\n").unwrap();
1280
1281 let result = EditTool::new(tmp.path())
1282 .execute(serde_json::json!({
1283 "file_path": "src.txt",
1284 "old_string": "fn foo() { \n bar \n}\n",
1285 "new_string": "fn foo() {\n baz\n}\n"
1286 }))
1287 .await
1288 .unwrap();
1289
1290 assert!(result.contains("updated successfully"));
1291 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1292 assert_eq!(content, "fn foo() {\n baz\n}\n");
1293 }
1294
1295 #[tokio::test]
1296 async fn new_string_trailing_whitespace_stripped() {
1297 let tmp = TempDir::new().unwrap();
1298 std::fs::write(tmp.path().join("src.txt"), "fn foo() {\n bar\n}\n").unwrap();
1299
1300 EditTool::new(tmp.path())
1301 .execute(serde_json::json!({
1302 "file_path": "src.txt",
1303 "old_string": "fn foo() {\n bar\n}\n",
1304 "new_string": "fn foo() {\n baz \n}\n" }))
1306 .await
1307 .unwrap();
1308
1309 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1310 assert_eq!(content, "fn foo() {\n baz\n}\n");
1312 }
1313
1314 #[tokio::test]
1315 async fn new_string_trailing_whitespace_preserved_in_markdown() {
1316 let tmp = TempDir::new().unwrap();
1317 std::fs::write(tmp.path().join("doc.md"), "# Hello\nold line \n").unwrap();
1318
1319 EditTool::new(tmp.path())
1320 .execute(serde_json::json!({
1321 "file_path": "doc.md",
1322 "old_string": "old line \n",
1323 "new_string": "new line \n" }))
1325 .await
1326 .unwrap();
1327
1328 let content = std::fs::read_to_string(tmp.path().join("doc.md")).unwrap();
1329 assert_eq!(content, "# Hello\nnew line \n");
1331 }
1332
1333 #[tokio::test]
1334 async fn replace_all_with_multiple_occurrences() {
1335 let tmp = TempDir::new().unwrap();
1336 std::fs::write(tmp.path().join("src.txt"), "aaa bbb aaa\n").unwrap();
1337
1338 let result = EditTool::new(tmp.path())
1339 .execute(serde_json::json!({
1340 "file_path": "src.txt",
1341 "old_string": "aaa",
1342 "new_string": "ccc",
1343 "replace_all": true
1344 }))
1345 .await
1346 .unwrap();
1347
1348 assert!(result.contains("All occurrences"));
1349 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
1350 assert_eq!(content, "ccc bbb ccc\n");
1351 }
1352
1353 fn make_slot() -> std::sync::Arc<std::sync::Mutex<crate::tools::fs::ReadFileState>> {
1356 std::sync::Arc::new(std::sync::Mutex::new(crate::tools::fs::ReadFileState::new()))
1357 }
1358
1359 #[tokio::test]
1360 async fn edit_rejected_when_file_never_read() {
1361 let tmp = TempDir::new().unwrap();
1362 std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1363 let slot = make_slot();
1364 let err = EditTool::new(tmp.path())
1365 .with_read_state(slot)
1366 .execute(serde_json::json!({
1367 "file_path": "src.txt",
1368 "old_string": "hello",
1369 "new_string": "goodbye"
1370 }))
1371 .await
1372 .unwrap_err();
1373 let msg = format!("{err}");
1374 assert!(
1375 msg.contains("not been read"),
1376 "expected 'not been read', got: {msg}"
1377 );
1378 }
1379
1380 #[tokio::test]
1381 async fn edit_allowed_after_partial_read() {
1382 let tmp = TempDir::new().unwrap();
1386 let path = tmp.path().join("src.txt");
1387 std::fs::write(&path, "hello world\n").unwrap();
1388 let slot = make_slot();
1389 slot.lock().unwrap().record(
1391 path.clone(),
1392 true,
1393 std::fs::read_to_string(&path).unwrap(),
1394 get_file_mtime(&path),
1395 );
1396 let result = EditTool::new(tmp.path())
1397 .with_read_state(slot)
1398 .execute(serde_json::json!({
1399 "file_path": "src.txt",
1400 "old_string": "hello",
1401 "new_string": "goodbye"
1402 }))
1403 .await
1404 .unwrap();
1405 assert!(
1406 result.contains("updated successfully"),
1407 "partial read + valid old_string must now succeed, got: {result}"
1408 );
1409 let content = std::fs::read_to_string(&path).unwrap();
1410 assert_eq!(content, "goodbye world\n");
1411 }
1412
1413 #[tokio::test]
1414 async fn edit_allowed_after_full_read() {
1415 let tmp = TempDir::new().unwrap();
1416 let path = tmp.path().join("src.txt");
1417 std::fs::write(&path, "hello world\n").unwrap();
1418 let slot = make_slot();
1419 slot.lock().unwrap().record(
1421 path.clone(),
1422 false,
1423 std::fs::read_to_string(&path).unwrap(),
1424 get_file_mtime(&path),
1425 );
1426 let result = EditTool::new(tmp.path())
1427 .with_read_state(slot)
1428 .execute(serde_json::json!({
1429 "file_path": "src.txt",
1430 "old_string": "hello",
1431 "new_string": "goodbye"
1432 }))
1433 .await
1434 .unwrap();
1435 assert!(result.contains("updated successfully"));
1436 }
1437
1438 #[tokio::test]
1439 async fn edit_allowed_when_no_read_state() {
1440 let tmp = TempDir::new().unwrap();
1443 std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
1444 let result = EditTool::new(tmp.path())
1445 .execute(serde_json::json!({
1446 "file_path": "src.txt",
1447 "old_string": "hello",
1448 "new_string": "goodbye"
1449 }))
1450 .await
1451 .unwrap();
1452 assert!(result.contains("updated successfully"));
1453 }
1454
1455 fn bump_mtime_future(path: &std::path::Path) {
1460 use std::time::{Duration, SystemTime};
1461 let future = SystemTime::now() + Duration::from_secs(3600);
1462 let f = std::fs::OpenOptions::new()
1463 .write(true)
1464 .open(path)
1465 .expect("open for set_modified");
1466 f.set_modified(future).expect("set_modified");
1467 }
1468
1469 #[tokio::test]
1470 async fn edit_rejected_when_file_modified_since_read() {
1471 let tmp = TempDir::new().unwrap();
1472 let path = tmp.path().join("src.txt");
1473 std::fs::write(&path, "hello world\n").unwrap();
1474 let slot = make_slot();
1475 slot.lock().unwrap().record(
1477 path.clone(),
1478 false,
1479 std::fs::read_to_string(&path).unwrap(),
1480 get_file_mtime(&path),
1481 );
1482 std::fs::write(&path, "externally changed\n").unwrap();
1484 bump_mtime_future(&path);
1485 let err = EditTool::new(tmp.path())
1486 .with_read_state(slot)
1487 .execute(serde_json::json!({
1488 "file_path": "src.txt",
1489 "old_string": "hello",
1490 "new_string": "goodbye"
1491 }))
1492 .await
1493 .unwrap_err();
1494 let msg = format!("{err}");
1495 assert!(
1496 msg.contains("modified") && msg.contains("Read it again"),
1497 "expected staleness rejection, got: {msg}"
1498 );
1499 }
1500
1501 #[tokio::test]
1502 async fn edit_allowed_when_mtime_bumped_but_content_unchanged() {
1503 let tmp = TempDir::new().unwrap();
1506 let path = tmp.path().join("src.txt");
1507 std::fs::write(&path, "hello world\n").unwrap();
1508 let slot = make_slot();
1509 slot.lock().unwrap().record(
1510 path.clone(),
1511 false,
1512 std::fs::read_to_string(&path).unwrap(),
1513 get_file_mtime(&path),
1514 );
1515 bump_mtime_future(&path);
1517 let result = EditTool::new(tmp.path())
1518 .with_read_state(slot)
1519 .execute(serde_json::json!({
1520 "file_path": "src.txt",
1521 "old_string": "hello",
1522 "new_string": "goodbye"
1523 }))
1524 .await
1525 .expect("unchanged content with bumped mtime must be allowed");
1526 assert!(result.contains("updated successfully"));
1527 }
1528
1529 #[tokio::test]
1530 async fn edit_post_update_cache_allows_consecutive_edit() {
1531 let tmp = TempDir::new().unwrap();
1534 let path = tmp.path().join("src.txt");
1535 std::fs::write(&path, "hello world\n").unwrap();
1536 let slot = make_slot();
1537 slot.lock().unwrap().record(
1538 path.clone(),
1539 false,
1540 std::fs::read_to_string(&path).unwrap(),
1541 get_file_mtime(&path),
1542 );
1543 let tool = EditTool::new(tmp.path()).with_read_state(slot);
1544 tool.execute(serde_json::json!({
1545 "file_path": "src.txt",
1546 "old_string": "hello",
1547 "new_string": "goodbye"
1548 }))
1549 .await
1550 .unwrap();
1551 let result = tool
1553 .execute(serde_json::json!({
1554 "file_path": "src.txt",
1555 "old_string": "goodbye",
1556 "new_string": "hello"
1557 }))
1558 .await
1559 .expect("consecutive edit must succeed without re-read");
1560 assert!(result.contains("updated successfully"));
1561 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello world\n");
1562 }
1563
1564 #[tokio::test]
1565 async fn edit_rejected_for_oversized_file() {
1566 let tmp = TempDir::new().unwrap();
1570 let path = tmp.path().join("big.txt");
1571 let f = std::fs::File::create(&path).unwrap();
1572 f.set_len(MAX_EDIT_FILE_SIZE + 1).unwrap();
1573 drop(f);
1574 let slot = make_slot();
1575 slot.lock()
1576 .unwrap()
1577 .record(path.clone(), false, String::new(), get_file_mtime(&path));
1578 let err = EditTool::new(tmp.path())
1579 .with_read_state(slot)
1580 .execute(serde_json::json!({
1581 "file_path": "big.txt",
1582 "old_string": "x",
1583 "new_string": "y"
1584 }))
1585 .await
1586 .unwrap_err();
1587 let msg = format!("{err}");
1588 assert!(
1589 msg.contains("too large to edit"),
1590 "expected 'too large to edit', got: {msg}"
1591 );
1592 }
1593
1594 use crate::tools::ReadFile;
1602 use std::sync::{Arc, Mutex};
1603
1604 #[tokio::test]
1608 async fn edit_succeeds_after_partial_read_when_old_string_found() {
1609 let tmp = TempDir::new().unwrap();
1610 let path = tmp.path().join("e.txt");
1611 std::fs::write(&path, "line1\nline2 target\nline3\n").unwrap();
1612 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1613 ReadFile::new(tmp.path())
1615 .with_read_state(slot.clone())
1616 .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
1617 .await
1618 .unwrap();
1619 let result = EditTool::new(tmp.path())
1621 .with_read_state(slot)
1622 .execute(serde_json::json!({
1623 "file_path": "e.txt",
1624 "old_string": "line2 target",
1625 "new_string": "line2 edited",
1626 }))
1627 .await
1628 .unwrap();
1629 assert!(result.contains("updated successfully"), "got: {result}");
1630 let content = std::fs::read_to_string(&path).unwrap();
1631 assert_eq!(content, "line1\nline2 edited\nline3\n");
1632 }
1633
1634 #[tokio::test]
1638 async fn edit_after_partial_read_still_rejects_missing_old_string() {
1639 let tmp = TempDir::new().unwrap();
1640 std::fs::write(tmp.path().join("e.txt"), "alpha\nbeta\n").unwrap();
1641 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1642 ReadFile::new(tmp.path())
1643 .with_read_state(slot.clone())
1644 .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 1}))
1645 .await
1646 .unwrap();
1647 let err = EditTool::new(tmp.path())
1648 .with_read_state(slot)
1649 .execute(serde_json::json!({
1650 "file_path": "e.txt",
1651 "old_string": "not-in-file",
1652 "new_string": "x",
1653 }))
1654 .await
1655 .unwrap_err();
1656 let msg = format!("{err}");
1657 assert!(msg.contains("not found"), "expected not found, got: {msg}");
1658 }
1659
1660 #[tokio::test]
1663 async fn edit_after_partial_read_still_rejects_ambiguous_old_string() {
1664 let tmp = TempDir::new().unwrap();
1665 std::fs::write(tmp.path().join("e.txt"), "dup\ndup\ndup\n").unwrap();
1666 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1667 ReadFile::new(tmp.path())
1668 .with_read_state(slot.clone())
1669 .execute(serde_json::json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
1670 .await
1671 .unwrap();
1672 let err = EditTool::new(tmp.path())
1673 .with_read_state(slot)
1674 .execute(serde_json::json!({
1675 "file_path": "e.txt",
1676 "old_string": "dup",
1677 "new_string": "uniq",
1678 }))
1679 .await
1680 .unwrap_err();
1681 let msg = format!("{err}");
1682 assert!(
1683 msg.contains("matches") || msg.contains("replace_all"),
1684 "expected ambiguity rejection, got: {msg}"
1685 );
1686 }
1687
1688 #[tokio::test]
1691 async fn edit_still_rejects_never_read_file() {
1692 let tmp = TempDir::new().unwrap();
1693 std::fs::write(tmp.path().join("e.txt"), "hello\n").unwrap();
1694 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1695 let err = EditTool::new(tmp.path())
1697 .with_read_state(slot)
1698 .execute(serde_json::json!({
1699 "file_path": "e.txt",
1700 "old_string": "hello",
1701 "new_string": "world",
1702 }))
1703 .await
1704 .unwrap_err();
1705 let msg = format!("{err}");
1706 assert!(
1707 msg.contains("has not been read yet"),
1708 "expected never-read rejection, got: {msg}"
1709 );
1710 }
1711
1712 #[tokio::test]
1729 async fn edit_partial_read_skips_staleness_cache_check_when_mtime_advanced() {
1730 let tmp = TempDir::new().unwrap();
1731 let path = tmp.path().join("src.txt");
1732 std::fs::write(&path, "line1\nline2 target\nline3\n").unwrap();
1733 let slot = make_slot();
1734 slot.lock().unwrap().record(
1738 path.clone(),
1739 true,
1740 "line1\nline2 target\n".to_string(),
1741 get_file_mtime(&path),
1742 );
1743 std::fs::write(&path, "line1\nline2 target\nline3 modified\n").unwrap();
1745 bump_mtime_future(&path);
1746 let result = EditTool::new(tmp.path())
1747 .with_read_state(slot)
1748 .execute(serde_json::json!({
1749 "file_path": "src.txt",
1750 "old_string": "line2 target",
1751 "new_string": "line2 edited",
1752 }))
1753 .await
1754 .unwrap_or_else(|e| {
1755 panic!("partial read with mtime advanced must NOT be rejected as stale, got: {e}")
1756 });
1757 assert!(result.contains("updated successfully"), "got: {result}");
1758 let content = std::fs::read_to_string(&path).unwrap();
1759 assert_eq!(content, "line1\nline2 edited\nline3 modified\n");
1760 }
1761
1762 #[tokio::test]
1767 async fn edit_full_read_still_runs_staleness_check_when_mtime_advanced() {
1768 let tmp = TempDir::new().unwrap();
1769 let path = tmp.path().join("src.txt");
1770 std::fs::write(&path, "hello world\n").unwrap();
1771 let slot = make_slot();
1772 slot.lock().unwrap().record(
1774 path.clone(),
1775 false,
1776 std::fs::read_to_string(&path).unwrap(),
1777 get_file_mtime(&path),
1778 );
1779 std::fs::write(&path, "externally changed\n").unwrap();
1781 bump_mtime_future(&path);
1782 let err = EditTool::new(tmp.path())
1783 .with_read_state(slot)
1784 .execute(serde_json::json!({
1785 "file_path": "src.txt",
1786 "old_string": "hello",
1787 "new_string": "goodbye",
1788 }))
1789 .await
1790 .unwrap_err();
1791 let msg = format!("{err}");
1792 assert!(
1793 msg.contains("modified") && msg.contains("Read it again"),
1794 "expected full-read staleness rejection, got: {msg}"
1795 );
1796 }
1797
1798 #[tokio::test]
1803 async fn edit_partial_read_still_rejects_when_old_string_gone_after_external_change() {
1804 let tmp = TempDir::new().unwrap();
1805 let path = tmp.path().join("src.txt");
1806 std::fs::write(&path, "hello world\n").unwrap();
1807 let slot = make_slot();
1808 slot.lock().unwrap().record(
1810 path.clone(),
1811 true,
1812 "hello world\n".to_string(),
1813 get_file_mtime(&path),
1814 );
1815 std::fs::write(&path, "externally changed\n").unwrap();
1817 bump_mtime_future(&path);
1818 let err = EditTool::new(tmp.path())
1819 .with_read_state(slot)
1820 .execute(serde_json::json!({
1821 "file_path": "src.txt",
1822 "old_string": "hello",
1823 "new_string": "goodbye",
1824 }))
1825 .await
1826 .unwrap_err();
1827 let msg = format!("{err}");
1828 assert!(
1829 msg.contains("not found"),
1830 "expected 'not found' rejection from old_string match, got: {msg}"
1831 );
1832 }
1833}