Skip to main content

recursive/tools/
str_replace.rs

1//! `str_replace`: edit a file by replacing an exact string.
2//!
3//! This is the recommended editing tool for single-file changes. The LLM
4//! provides an `old_string` (text to find) and `new_string` (replacement),
5//! and the tool does a precise search-and-replace with a fuzzy-match
6//! fallback chain that recovers from common LLM output quirks.
7//!
8//! Fuzzy-match chain (first success wins):
9//!   1. Exact match
10//!   2. Quote normalization (curly to straight quotes)
11//!   3. Trailing whitespace strip (rstrip each line of old_string)
12//!   4. Quote normalization + trailing whitespace strip combined
13//!   5. XML-tag desanitization (model-escaped tags to real tags)
14//!
15//! When the match succeeds via quote normalization, curly-quote style is
16//! preserved in `new_string` so the edit does not silently change typography.
17
18use 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
37// ---------------------------------------------------------------------------
38// Curly-quote helpers
39// ---------------------------------------------------------------------------
40
41const LEFT_SINGLE: char = '\u{2018}';
42const RIGHT_SINGLE: char = '\u{2019}';
43const LEFT_DOUBLE: char = '\u{201c}';
44const RIGHT_DOUBLE: char = '\u{201d}';
45
46/// Replace curly quotes with their ASCII equivalents.
47fn 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
62/// Apply curly double-quote style to ASCII double-quotes in `s`.
63fn 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
80/// Apply curly single-quote style to ASCII single-quotes in `s`.
81fn 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            // Apostrophe in a contraction uses right single curly quote.
89            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
103/// When `old_string` matched via quote normalization (curly quotes in file,
104/// straight quotes from model), apply the same curly-quote style to `new_string`
105/// so the edit preserves the file typography.
106fn 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
125// ---------------------------------------------------------------------------
126// Trailing-whitespace normalization
127// ---------------------------------------------------------------------------
128
129/// Strip trailing whitespace from every line while preserving newlines.
130fn 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
141// ---------------------------------------------------------------------------
142// Desanitization
143// ---------------------------------------------------------------------------
144
145/// Apply XML-tag desanitization: models sometimes emit placeholder forms
146/// because the training pipeline has escaped the real tag names.
147fn 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
191// ---------------------------------------------------------------------------
192// Match chain
193// ---------------------------------------------------------------------------
194
195/// Try to find `needle` in `haystack` using the fuzzy-match chain.
196/// Returns the effective needle (possibly normalised) on success.
197fn try_match(haystack: &str, needle: &str) -> Option<String> {
198    // 1. Exact
199    if haystack.contains(needle) {
200        return Some(needle.to_string());
201    }
202
203    // 2. Quote normalization
204    let qn = normalize_quotes(needle);
205    if qn != needle && haystack.contains(qn.as_str()) {
206        return Some(qn);
207    }
208
209    // 3. Trailing whitespace strip
210    let tws = strip_trailing_whitespace(needle);
211    if tws != needle && haystack.contains(tws.as_str()) {
212        return Some(tws);
213    }
214
215    // 4. Quote normalization + trailing whitespace strip combined
216    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    // 5. Desanitization
222    let ds = desanitize(needle);
223    if ds != needle && haystack.contains(ds.as_str()) {
224        return Some(ds);
225    }
226
227    None
228}
229
230// ---------------------------------------------------------------------------
231// Tool implementation
232// ---------------------------------------------------------------------------
233
234#[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        // ── Empty old_string: create new file or overwrite empty file ───
310        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        // ── Read file ───────────────────────────────────────────────────
333        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        // ── Guard: identical strings do nothing ─────────────────────────
341        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        // ── Find old_string via fuzzy-match chain ───────────────────────
350        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        // ── Count occurrences ───────────────────────────────────────────
356        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        // ── Preserve curly-quote style in new_string ────────────────────
370        let actual_new = preserve_quote_style(old_string, &actual_old, new_string);
371
372        // ── Apply replacement ───────────────────────────────────────────
373        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// ---------------------------------------------------------------------------
402// Tests
403// ---------------------------------------------------------------------------
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use tempfile::TempDir;
409
410    // ── normalize_quotes ─────────────────────────────────────────────────
411
412    #[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('\'')); // single quote
421        assert!(output.contains('"')); // double quote
422    }
423
424    // ── strip_trailing_whitespace ────────────────────────────────────────
425
426    #[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    // ── preserve_quote_style ─────────────────────────────────────────────
434
435    #[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        // actual_old contains curly double quotes (file uses them)
444        let actual_old = "\u{201c}quoted\u{201d}";
445        let result = preserve_quote_style("\"quoted\"", actual_old, "\"replaced\"");
446        // new_string should have curly quotes applied
447        assert!(result.contains('\u{201c}') || result.contains('\u{201d}'));
448    }
449
450    // ── desanitize ────────────────────────────────────────────────────────
451
452    #[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    // ── try_match ─────────────────────────────────────────────────────────
467
468    #[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    // ── Tool (end-to-end) ─────────────────────────────────────────────────
509
510    #[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}