1use async_trait::async_trait;
19use serde_json::{json, Value};
20use std::path::PathBuf;
21
22use super::{resolve_within, Tool};
23use crate::error::{Error, Result};
24use crate::llm::ToolSpec;
25
26#[derive(Debug, Clone)]
27pub struct StrReplaceTool {
28 pub root: PathBuf,
29}
30
31impl StrReplaceTool {
32 pub fn new(root: impl Into<PathBuf>) -> Self {
33 Self { root: root.into() }
34 }
35}
36
37const LEFT_SINGLE: char = '\u{2018}';
42const RIGHT_SINGLE: char = '\u{2019}';
43const LEFT_DOUBLE: char = '\u{201c}';
44const RIGHT_DOUBLE: char = '\u{201d}';
45
46fn normalize_quotes(s: &str) -> String {
48 s.replace([LEFT_SINGLE, RIGHT_SINGLE], "'")
49 .replace([LEFT_DOUBLE, RIGHT_DOUBLE], "\"")
50}
51
52fn is_opening_context(chars: &[char], i: usize) -> bool {
53 if i == 0 {
54 return true;
55 }
56 matches!(
57 chars[i - 1],
58 ' ' | '\t' | '\n' | '\r' | '(' | '[' | '{' | '\u{2014}' | '\u{2013}'
59 )
60}
61
62fn apply_curly_double(s: &str) -> String {
64 let chars: Vec<char> = s.chars().collect();
65 let mut out = String::with_capacity(s.len());
66 for (i, &c) in chars.iter().enumerate() {
67 if c == '"' {
68 out.push(if is_opening_context(&chars, i) {
69 LEFT_DOUBLE
70 } else {
71 RIGHT_DOUBLE
72 });
73 } else {
74 out.push(c);
75 }
76 }
77 out
78}
79
80fn apply_curly_single(s: &str) -> String {
82 let chars: Vec<char> = s.chars().collect();
83 let mut out = String::with_capacity(s.len());
84 for (i, &c) in chars.iter().enumerate() {
85 if c == '\'' {
86 let prev_letter = i > 0 && chars[i - 1].is_alphabetic();
87 let next_letter = i + 1 < chars.len() && chars[i + 1].is_alphabetic();
88 if prev_letter && next_letter {
90 out.push(RIGHT_SINGLE);
91 } else if is_opening_context(&chars, i) {
92 out.push(LEFT_SINGLE);
93 } else {
94 out.push(RIGHT_SINGLE);
95 }
96 } else {
97 out.push(c);
98 }
99 }
100 out
101}
102
103fn preserve_quote_style(old_string: &str, actual_old: &str, new_string: &str) -> String {
107 if old_string == actual_old {
108 return new_string.to_string();
109 }
110 let has_double = actual_old.contains(LEFT_DOUBLE) || actual_old.contains(RIGHT_DOUBLE);
111 let has_single = actual_old.contains(LEFT_SINGLE) || actual_old.contains(RIGHT_SINGLE);
112 if !has_double && !has_single {
113 return new_string.to_string();
114 }
115 let mut result = new_string.to_string();
116 if has_double {
117 result = apply_curly_double(&result);
118 }
119 if has_single {
120 result = apply_curly_single(&result);
121 }
122 result
123}
124
125fn strip_trailing_whitespace(s: &str) -> String {
131 let trailing_newline = s.ends_with('\n');
132 let stripped: Vec<&str> = s.lines().map(|l| l.trim_end()).collect();
133 let out = stripped.join("\n");
134 if trailing_newline {
135 out + "\n"
136 } else {
137 out
138 }
139}
140
141fn desanitize(s: &str) -> String {
148 let mut out = s.to_string();
149 let pairs: &[(&str, &str)] = &[
150 ("<fnr>", "<function_results>"),
151 ("</fnr>", "</function_results>"),
152 ("<n>", "<name>"),
153 ("</n>", "</name>"),
154 ("<o>", "<output>"),
155 ("</o>", "</output>"),
156 ("<e>", "<error>"),
157 ("</e>", "</error>"),
158 ("<s>", "<system>"),
159 ("</s>", "</system>"),
160 ("<r>", "<result>"),
161 ("</r>", "</result>"),
162 ("< META_START >", "<META_START>"),
163 ("< META_END >", "</META_END>"),
164 ("< EOT >", "<EOT>"),
165 ("< META >", "<META>"),
166 ("< SOS >", "<SOS>"),
167 (
168 "
169
170H:", "
171
172Human:",
173 ),
174 (
175 "
176
177A:",
178 "
179
180Assistant:",
181 ),
182 ];
183 for &(from, to) in pairs {
184 if out.contains(from) {
185 out = out.replace(from, to);
186 }
187 }
188 out
189}
190
191fn try_match(haystack: &str, needle: &str) -> Option<String> {
198 if haystack.contains(needle) {
200 return Some(needle.to_string());
201 }
202
203 let qn = normalize_quotes(needle);
205 if qn != needle && haystack.contains(qn.as_str()) {
206 return Some(qn);
207 }
208
209 let tws = strip_trailing_whitespace(needle);
211 if tws != needle && haystack.contains(tws.as_str()) {
212 return Some(tws);
213 }
214
215 let qn_tws = strip_trailing_whitespace(&qn);
217 if qn_tws != needle && qn_tws != qn && qn_tws != tws && haystack.contains(qn_tws.as_str()) {
218 return Some(qn_tws);
219 }
220
221 let ds = desanitize(needle);
223 if ds != needle && haystack.contains(ds.as_str()) {
224 return Some(ds);
225 }
226
227 None
228}
229
230#[async_trait]
235impl Tool for StrReplaceTool {
236 fn spec(&self) -> ToolSpec {
237 ToolSpec {
238 name: "str_replace".into(),
239 description: "Performs exact string replacements in files.\n\
240Usage:\n\
241- You must use your `read_file` tool at least once in the conversation before editing. \
242This tool will error if you attempt an edit without reading the file.\n\
243- When editing text from Read tool output, ensure you preserve the exact indentation \
244(tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: \
245line number + tab. Everything after that is the actual file content to match. \
246Never include any part of the line number prefix in the old_string or new_string.\n\
247- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless \
248explicitly required.\n\
249- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n\
250- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger \
251string with more surrounding context to make it unique or use `replace_all` to change \
252every instance of `old_string`.\n\
253- Use `replace_all` for replacing and renaming strings across the file. This parameter is \
254useful if you want to rename a variable for instance."
255 .into(),
256 parameters: json!({
257 "type": "object",
258 "properties": {
259 "file_path": {
260 "type": "string",
261 "description": "The absolute path to the file to modify"
262 },
263 "old_string": {
264 "type": "string",
265 "description": "The text to replace (must be different from new_string). If empty, creates a new file with new_string as content."
266 },
267 "new_string": {
268 "type": "string",
269 "description": "The text to replace it with (must be different from old_string)"
270 },
271 "replace_all": {
272 "type": "boolean",
273 "description": "Replace all occurrences of old_string (default false)",
274 "default": false
275 }
276 },
277 "required": ["file_path", "old_string", "new_string"]
278 }),
279 }
280 }
281
282 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
283 crate::tools::ToolSideEffect::Mutating
284 }
285
286 async fn execute(&self, args: Value) -> Result<String> {
287 let file_path = args["file_path"]
288 .as_str()
289 .ok_or_else(|| Error::BadToolArgs {
290 name: "str_replace".into(),
291 message: "missing `file_path`".into(),
292 })?;
293 let old_string = args["old_string"]
294 .as_str()
295 .ok_or_else(|| Error::BadToolArgs {
296 name: "str_replace".into(),
297 message: "missing `old_string`".into(),
298 })?;
299 let new_string = args["new_string"]
300 .as_str()
301 .ok_or_else(|| Error::BadToolArgs {
302 name: "str_replace".into(),
303 message: "missing `new_string`".into(),
304 })?;
305 let replace_all = args["replace_all"].as_bool().unwrap_or(false);
306
307 let abs_path = resolve_within(&self.root, file_path)?;
308
309 if old_string.is_empty() {
311 if let Some(parent) = abs_path.parent() {
312 tokio::fs::create_dir_all(parent)
313 .await
314 .map_err(|e| Error::Tool {
315 name: "str_replace".into(),
316 message: format!("mkdir {}: {e}", parent.display()),
317 })?;
318 }
319 tokio::fs::write(&abs_path, new_string)
320 .await
321 .map_err(|e| Error::Tool {
322 name: "str_replace".into(),
323 message: format!("{}: {e}", abs_path.display()),
324 })?;
325 return Ok(format!(
326 "Created `{}` ({} bytes)",
327 file_path,
328 new_string.len()
329 ));
330 }
331
332 let content = tokio::fs::read_to_string(&abs_path)
334 .await
335 .map_err(|e| Error::Tool {
336 name: "str_replace".into(),
337 message: format!("{}: {e}", abs_path.display()),
338 })?;
339
340 if old_string == new_string {
342 return Err(Error::Tool {
343 name: "str_replace".into(),
344 message: "No changes to make: old_string and new_string are exactly the same."
345 .into(),
346 });
347 }
348
349 let actual_old = try_match(&content, old_string).ok_or_else(|| Error::Tool {
351 name: "str_replace".into(),
352 message: format!("String to replace not found in `{file_path}`.\nString: {old_string}"),
353 })?;
354
355 let occurrence_count = content.matches(actual_old.as_str()).count();
357 if !replace_all && occurrence_count > 1 {
358 return Err(Error::Tool {
359 name: "str_replace".into(),
360 message: format!(
361 "Found {occurrence_count} matches of the string to replace, but \
362replace_all is false. To replace all occurrences, set replace_all to true. \
363To replace only one occurrence, please provide more context to uniquely identify \
364the instance.\nString: {old_string}"
365 ),
366 });
367 }
368
369 let actual_new = preserve_quote_style(old_string, &actual_old, new_string);
371
372 let max_replace = if replace_all { usize::MAX } else { 1 };
374 let updated = content.replacen(actual_old.as_str(), actual_new.as_str(), max_replace);
375
376 tokio::fs::write(&abs_path, &updated)
377 .await
378 .map_err(|e| Error::Tool {
379 name: "str_replace".into(),
380 message: format!("{}: {e}", abs_path.display()),
381 })?;
382
383 let replaced_count = if replace_all {
384 occurrence_count.to_string()
385 } else {
386 "1".to_string()
387 };
388
389 if replace_all {
390 Ok(format!(
391 "The file {file_path} has been updated. All occurrences were successfully replaced."
392 ))
393 } else {
394 Ok(format!(
395 "The file {file_path} has been updated successfully. {replaced_count} occurrence replaced."
396 ))
397 }
398 }
399}
400
401#[cfg(test)]
406mod tests {
407 use super::*;
408 use tempfile::TempDir;
409
410 #[test]
413 fn quote_normalization_replaces_curly_quotes() {
414 let input = "Here\u{2019}s a \u{201c}quoted\u{201d} string with \u{2018}single\u{2019}.";
415 let output = normalize_quotes(input);
416 assert!(!output.contains('\u{2018}'));
417 assert!(!output.contains('\u{2019}'));
418 assert!(!output.contains('\u{201c}'));
419 assert!(!output.contains('\u{201d}'));
420 assert!(output.contains('\'')); assert!(output.contains('"')); }
423
424 #[test]
427 fn strip_trailing_whitespace_per_line() {
428 let input = "hello \nworld\t \n spaced \n";
429 let output = strip_trailing_whitespace(input);
430 assert_eq!(output, "hello\nworld\n spaced\n");
431 }
432
433 #[test]
436 fn preserve_quote_style_noop_when_exact_match() {
437 let result = preserve_quote_style("hello", "hello", "world");
438 assert_eq!(result, "world");
439 }
440
441 #[test]
442 fn preserve_quote_style_applies_curly_double_when_needed() {
443 let actual_old = "\u{201c}quoted\u{201d}";
445 let result = preserve_quote_style("\"quoted\"", actual_old, "\"replaced\"");
446 assert!(result.contains('\u{201c}') || result.contains('\u{201d}'));
448 }
449
450 #[test]
453 fn desanitize_function_results_tag() {
454 let input = "before <fnr> after";
455 let output = desanitize(input);
456 assert!(output.contains("<function_results>"), "got: {output}");
457 }
458
459 #[test]
460 fn desanitize_noop_when_no_match() {
461 let input = "nothing to desanitize here";
462 let output = desanitize(input);
463 assert_eq!(output, input);
464 }
465
466 #[test]
469 fn try_match_exact() {
470 assert_eq!(
471 try_match("hello world", "hello world"),
472 Some("hello world".to_string())
473 );
474 }
475
476 #[test]
477 fn try_match_quote_normalization() {
478 let haystack = "Here's a string";
479 let needle = "Here\u{2019}s a string";
480 assert_eq!(
481 try_match(haystack, needle),
482 Some("Here's a string".to_string())
483 );
484 }
485
486 #[test]
487 fn try_match_trailing_whitespace() {
488 let haystack = "fn foo() {\n bar\n}\n";
489 let needle = "fn foo() { \n bar \n}\n";
490 assert_eq!(
491 try_match(haystack, needle),
492 Some("fn foo() {\n bar\n}\n".to_string())
493 );
494 }
495
496 #[test]
497 fn try_match_desanitization() {
498 let haystack = "result: <function_results>data</function_results>";
499 let needle = "result: <fnr>data</fnr>";
500 assert!(try_match(haystack, needle).is_some());
501 }
502
503 #[test]
504 fn try_match_not_found() {
505 assert_eq!(try_match("hello world", "goodbye"), None);
506 }
507
508 #[tokio::test]
511 async fn exact_match_replaces_once() {
512 let tmp = TempDir::new().unwrap();
513 std::fs::write(
514 tmp.path().join("src.txt"),
515 "fn old_func() {}\nfn mid() {}\nfn other_func() {}\n",
516 )
517 .unwrap();
518
519 let tool = StrReplaceTool::new(tmp.path());
520 let result = tool
521 .execute(serde_json::json!({
522 "file_path": "src.txt",
523 "old_string": "fn old_func() {}",
524 "new_string": "fn new() {}",
525 }))
526 .await
527 .unwrap();
528
529 assert!(result.contains("updated successfully"));
530 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
531 assert_eq!(content, "fn new() {}\nfn mid() {}\nfn other_func() {}\n");
532 }
533
534 #[tokio::test]
535 async fn fails_when_not_found() {
536 let tmp = TempDir::new().unwrap();
537 std::fs::write(tmp.path().join("src.txt"), "hello world\n").unwrap();
538
539 let err = StrReplaceTool::new(tmp.path())
540 .execute(serde_json::json!({
541 "file_path": "src.txt",
542 "old_string": "goodbye",
543 "new_string": "replaced"
544 }))
545 .await
546 .unwrap_err();
547
548 let msg = format!("{err}");
549 assert!(msg.contains("not found"), "expected not found, got: {msg}");
550 }
551
552 #[tokio::test]
553 async fn fails_when_ambiguous() {
554 let tmp = TempDir::new().unwrap();
555 std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
556
557 let err = StrReplaceTool::new(tmp.path())
558 .execute(serde_json::json!({
559 "file_path": "src.txt",
560 "old_string": "foo",
561 "new_string": "bar",
562 }))
563 .await
564 .unwrap_err();
565
566 let msg = format!("{err}");
567 assert!(msg.contains("Found 3 matches"), "got: {msg}");
568 }
569
570 #[tokio::test]
571 async fn replace_all_replaces_all() {
572 let tmp = TempDir::new().unwrap();
573 std::fs::write(tmp.path().join("src.txt"), "foo\nfoo\nfoo\n").unwrap();
574
575 let result = StrReplaceTool::new(tmp.path())
576 .execute(serde_json::json!({
577 "file_path": "src.txt",
578 "old_string": "foo",
579 "new_string": "bar",
580 "replace_all": true
581 }))
582 .await
583 .unwrap();
584
585 assert!(result.contains("All occurrences"));
586 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
587 assert_eq!(content, "bar\nbar\nbar\n");
588 }
589
590 #[tokio::test]
591 async fn empty_old_string_creates_file() {
592 let tmp = TempDir::new().unwrap();
593
594 let result = StrReplaceTool::new(tmp.path())
595 .execute(serde_json::json!({
596 "file_path": "new_file.txt",
597 "old_string": "",
598 "new_string": "brand new content"
599 }))
600 .await
601 .unwrap();
602
603 assert!(result.contains("Created"));
604 let content = std::fs::read_to_string(tmp.path().join("new_file.txt")).unwrap();
605 assert_eq!(content, "brand new content");
606 }
607
608 #[tokio::test]
609 async fn empty_old_creates_nested_file() {
610 let tmp = TempDir::new().unwrap();
611
612 StrReplaceTool::new(tmp.path())
613 .execute(serde_json::json!({
614 "file_path": "a/b/c/new_file.txt",
615 "old_string": "",
616 "new_string": "deep"
617 }))
618 .await
619 .unwrap();
620
621 let content = std::fs::read_to_string(tmp.path().join("a/b/c/new_file.txt")).unwrap();
622 assert_eq!(content, "deep");
623 }
624
625 #[tokio::test]
626 async fn identical_strings_errors() {
627 let tmp = TempDir::new().unwrap();
628 std::fs::write(tmp.path().join("src.txt"), "hello\n").unwrap();
629
630 let err = StrReplaceTool::new(tmp.path())
631 .execute(serde_json::json!({
632 "file_path": "src.txt",
633 "old_string": "hello",
634 "new_string": "hello"
635 }))
636 .await
637 .unwrap_err();
638
639 let msg = format!("{err}");
640 assert!(msg.contains("same"), "got: {msg}");
641 }
642
643 #[tokio::test]
644 async fn sandboxed_path_rejected() {
645 let tmp = TempDir::new().unwrap();
646 let err = StrReplaceTool::new(tmp.path())
647 .execute(serde_json::json!({
648 "file_path": "../outside.txt",
649 "old_string": "x",
650 "new_string": "y"
651 }))
652 .await
653 .unwrap_err();
654 let msg = format!("{err}");
655 assert!(
656 msg.contains("escapes") || msg.contains("BadToolArgs"),
657 "got: {msg}"
658 );
659 }
660
661 #[tokio::test]
662 async fn quote_normalization_matches_in_file() {
663 let tmp = TempDir::new().unwrap();
664 std::fs::write(tmp.path().join("src.txt"), "let msg = \"it's done\";\n").unwrap();
665
666 let result = StrReplaceTool::new(tmp.path())
667 .execute(serde_json::json!({
668 "file_path": "src.txt",
669 "old_string": "let msg = \"it\u{2019}s done\";",
670 "new_string": "let msg = \"it's replaced\";"
671 }))
672 .await
673 .unwrap();
674
675 assert!(result.contains("updated successfully"));
676 }
677
678 #[tokio::test]
679 async fn trailing_whitespace_strip_matches() {
680 let tmp = TempDir::new().unwrap();
681 std::fs::write(tmp.path().join("src.txt"), "fn foo() {\n bar\n}\n").unwrap();
682
683 let result = StrReplaceTool::new(tmp.path())
684 .execute(serde_json::json!({
685 "file_path": "src.txt",
686 "old_string": "fn foo() { \n bar \n}\n",
687 "new_string": "fn foo() {\n baz\n}\n"
688 }))
689 .await
690 .unwrap();
691
692 assert!(result.contains("updated successfully"));
693 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
694 assert_eq!(content, "fn foo() {\n baz\n}\n");
695 }
696
697 #[tokio::test]
698 async fn replace_all_with_multiple_occurrences() {
699 let tmp = TempDir::new().unwrap();
700 std::fs::write(tmp.path().join("src.txt"), "aaa bbb aaa\n").unwrap();
701
702 let result = StrReplaceTool::new(tmp.path())
703 .execute(serde_json::json!({
704 "file_path": "src.txt",
705 "old_string": "aaa",
706 "new_string": "ccc",
707 "replace_all": true
708 }))
709 .await
710 .unwrap();
711
712 assert!(result.contains("All occurrences"));
713 let content = std::fs::read_to_string(tmp.path().join("src.txt")).unwrap();
714 assert_eq!(content, "ccc bbb ccc\n");
715 }
716}