1use std::collections::HashMap;
25
26#[derive(Debug)]
28pub struct FuzzyMatch {
29 pub new_content: String,
30 pub strategy: &'static str,
31 pub match_count: usize,
32}
33
34pub fn fuzzy_find_and_replace(
38 content: &str,
39 old_string: &str,
40 new_string: &str,
41) -> Result<FuzzyMatch, String> {
42 if old_string.is_empty() {
43 return Err("old_string cannot be empty".into());
44 }
45 if old_string == new_string {
46 return Err("old_string and new_string are identical".into());
47 }
48
49 type StrategyFn = fn(&str, &str) -> Vec<(usize, usize)>;
50 let strategies: &[(&str, StrategyFn)] = &[
51 ("exact", strategy_exact),
52 ("line_trimmed", strategy_line_trimmed),
53 ("whitespace_normalized", strategy_whitespace_normalized),
54 ("indentation_flexible", strategy_indentation_flexible),
55 ("escape_normalized", strategy_escape_normalized),
56 ("trimmed_boundary", strategy_trimmed_boundary),
57 ("unicode_normalized", strategy_unicode_normalized),
58 ("block_anchor", strategy_block_anchor),
59 ];
60
61 for (name, f) in strategies {
62 let matches = f(content, old_string);
63 if matches.is_empty() {
64 continue;
65 }
66
67 if matches.len() > 1 {
68 return Err(format!(
69 "Found {} matches for old_string (matched via `{}` strategy). \
70 Provide more context to make it unique.",
71 matches.len(),
72 name
73 ));
74 }
75
76 if *name != "exact" {
80 if let Some(err) = detect_escape_drift(content, &matches, old_string, new_string) {
81 return Err(err);
82 }
83 }
84
85 let new_content = apply_replacements(content, &matches, new_string);
86 return Ok(FuzzyMatch {
87 new_content,
88 strategy: name,
89 match_count: matches.len(),
90 });
91 }
92
93 Err(
94 "Could not find a match for old_string in the file (tried 8 strategies including \
95 indentation-flexible, whitespace-normalized, and unicode-normalized matching). \
96 Re-read the file with read_file to see the exact bytes."
97 .into(),
98 )
99}
100
101fn detect_escape_drift(
102 content: &str,
103 matches: &[(usize, usize)],
104 old_string: &str,
105 new_string: &str,
106) -> Option<String> {
107 if !new_string.contains("\\'") && !new_string.contains("\\\"") {
108 return None;
109 }
110 let matched: String = matches
111 .iter()
112 .map(|(s, e)| &content[*s..*e])
113 .collect::<Vec<_>>()
114 .join("");
115
116 for suspect in &["\\'", "\\\""] {
117 if new_string.contains(suspect)
118 && old_string.contains(suspect)
119 && !matched.contains(suspect)
120 {
121 let plain = &suspect[1..];
122 return Some(format!(
123 "Escape-drift detected: old_string and new_string contain the literal sequence \
124 `{suspect}` but the matched region of the file does not. This is almost \
125 always a tool-call serialization artifact where an apostrophe or quote got \
126 prefixed with a spurious backslash. Re-read the file with read_file and pass \
127 old_string/new_string without backslash-escaping `{plain}` characters."
128 ));
129 }
130 }
131 None
132}
133
134fn apply_replacements(content: &str, matches: &[(usize, usize)], new_string: &str) -> String {
135 let mut sorted: Vec<(usize, usize)> = matches.to_vec();
136 sorted.sort_by_key(|item| std::cmp::Reverse(item.0));
137 let mut result = content.to_string();
138 for (start, end) in sorted {
139 result.replace_range(start..end, new_string);
140 }
141 result
142}
143
144fn strategy_exact(content: &str, pattern: &str) -> Vec<(usize, usize)> {
150 if pattern.is_empty() {
151 return Vec::new();
152 }
153 let mut out = Vec::new();
154 let mut start = 0;
155 while let Some(pos) = content[start..].find(pattern) {
156 let abs = start + pos;
157 out.push((abs, abs + pattern.len()));
158 start = abs + 1;
159 }
160 out
161}
162
163fn strategy_line_trimmed(content: &str, pattern: &str) -> Vec<(usize, usize)> {
165 let normalized_pattern = pattern
166 .split('\n')
167 .map(|l| l.trim())
168 .collect::<Vec<_>>()
169 .join("\n");
170 line_block_match(
171 content,
172 pattern,
173 |line| line.trim().to_string(),
174 &normalized_pattern,
175 )
176}
177
178fn strategy_whitespace_normalized(content: &str, pattern: &str) -> Vec<(usize, usize)> {
180 let normalized_content = collapse_horizontal_ws(content);
181 let normalized_pattern = collapse_horizontal_ws(pattern);
182 if normalized_content == content && normalized_pattern == pattern {
183 return Vec::new();
184 }
185 let norm_matches = strategy_exact(&normalized_content, &normalized_pattern);
186 if norm_matches.is_empty() {
187 return Vec::new();
188 }
189 map_collapsed_positions(content, &normalized_content, &norm_matches)
190}
191
192fn collapse_horizontal_ws(s: &str) -> String {
193 let mut out = String::with_capacity(s.len());
194 let mut prev_was_h_ws = false;
195 for ch in s.chars() {
196 if ch == ' ' || ch == '\t' {
197 if !prev_was_h_ws {
198 out.push(' ');
199 prev_was_h_ws = true;
200 }
201 } else {
202 out.push(ch);
203 prev_was_h_ws = false;
204 }
205 }
206 out
207}
208
209fn map_collapsed_positions(
213 original: &str,
214 normalized: &str,
215 norm_matches: &[(usize, usize)],
216) -> Vec<(usize, usize)> {
217 let mut orig_to_norm: Vec<usize> = Vec::with_capacity(original.len() + 1);
219 let mut norm_pos = 0;
220 let mut prev_was_h_ws = false;
221 for (i, ch) in original.char_indices() {
222 orig_to_norm.push(norm_pos);
223 if ch == ' ' || ch == '\t' {
224 if !prev_was_h_ws {
225 norm_pos += 1;
226 prev_was_h_ws = true;
227 }
228 } else {
229 norm_pos += ch.len_utf8();
230 prev_was_h_ws = false;
231 }
232 let _ = i;
233 }
234 orig_to_norm.push(norm_pos);
235
236 if norm_pos != normalized.len() {
238 return Vec::new();
239 }
240
241 let mut out = Vec::new();
242 for &(ns, ne) in norm_matches {
243 let mut start_orig = None;
244 let mut end_orig = None;
245 for (orig_pos, &mapped) in orig_to_norm.iter().enumerate() {
246 if start_orig.is_none() && mapped >= ns {
247 start_orig = Some(orig_pos);
248 }
249 if mapped >= ne {
250 end_orig = Some(orig_pos);
251 break;
252 }
253 }
254 if let (Some(s), Some(e)) = (start_orig, end_orig) {
255 let s = floor_char_boundary(original, s);
257 let e = floor_char_boundary(original, e);
258 if e > s {
259 out.push((s, e));
260 }
261 }
262 }
263 out
264}
265
266fn floor_char_boundary(s: &str, mut idx: usize) -> usize {
267 if idx >= s.len() {
268 return s.len();
269 }
270 while !s.is_char_boundary(idx) && idx > 0 {
271 idx -= 1;
272 }
273 idx
274}
275
276fn strategy_indentation_flexible(content: &str, pattern: &str) -> Vec<(usize, usize)> {
278 let normalized_pattern = pattern
279 .split('\n')
280 .map(|l| l.trim_start())
281 .collect::<Vec<_>>()
282 .join("\n");
283 line_block_match(
284 content,
285 pattern,
286 |line| line.trim_start().to_string(),
287 &normalized_pattern,
288 )
289}
290
291fn strategy_escape_normalized(content: &str, pattern: &str) -> Vec<(usize, usize)> {
293 let unescaped = pattern
294 .replace("\\n", "\n")
295 .replace("\\t", "\t")
296 .replace("\\r", "\r");
297 if unescaped == pattern {
298 return Vec::new();
299 }
300 strategy_exact(content, &unescaped)
301}
302
303fn strategy_trimmed_boundary(content: &str, pattern: &str) -> Vec<(usize, usize)> {
305 let mut pat_lines: Vec<String> = pattern.split('\n').map(|s| s.to_string()).collect();
306 if pat_lines.is_empty() {
307 return Vec::new();
308 }
309 pat_lines[0] = pat_lines[0].trim().to_string();
310 let n = pat_lines.len();
311 if n > 1 {
312 pat_lines[n - 1] = pat_lines[n - 1].trim().to_string();
313 }
314 let modified_pattern = pat_lines.join("\n");
315
316 let content_lines: Vec<&str> = content.split('\n').collect();
317 let mut out = Vec::new();
318 let pat_count = pat_lines.len();
319 if pat_count == 0 || content_lines.len() < pat_count {
320 return out;
321 }
322
323 for i in 0..=content_lines.len() - pat_count {
324 let mut block: Vec<String> = content_lines[i..i + pat_count]
325 .iter()
326 .map(|s| s.to_string())
327 .collect();
328 block[0] = block[0].trim().to_string();
329 if pat_count > 1 {
330 block[pat_count - 1] = block[pat_count - 1].trim().to_string();
331 }
332 if block.join("\n") == modified_pattern {
333 if let Some((s, e)) =
334 line_range_to_byte_range(content, &content_lines, i, i + pat_count)
335 {
336 out.push((s, e));
337 }
338 }
339 }
340 out
341}
342
343fn strategy_unicode_normalized(content: &str, pattern: &str) -> Vec<(usize, usize)> {
345 let norm_content = unicode_normalize(content);
346 let norm_pattern = unicode_normalize(pattern);
347 if norm_content == content && norm_pattern == pattern {
348 return Vec::new();
349 }
350 let norm_matches = strategy_exact(&norm_content, &norm_pattern);
351 let norm_matches = if norm_matches.is_empty() {
352 let mp = norm_pattern
353 .split('\n')
354 .map(|l| l.trim())
355 .collect::<Vec<_>>()
356 .join("\n");
357 line_block_match(&norm_content, &norm_pattern, |l| l.trim().to_string(), &mp)
358 } else {
359 norm_matches
360 };
361 if norm_matches.is_empty() {
362 return Vec::new();
363 }
364
365 let orig_to_norm = build_orig_to_norm_map(content);
366 map_norm_to_orig(content, &orig_to_norm, &norm_matches)
367}
368
369fn unicode_normalize(s: &str) -> String {
370 let mut out = String::with_capacity(s.len());
371 for ch in s.chars() {
372 match ch {
373 '\u{201C}' | '\u{201D}' => out.push('"'),
374 '\u{2018}' | '\u{2019}' => out.push('\''),
375 '\u{2014}' => out.push_str("--"),
376 '\u{2013}' => out.push('-'),
377 '\u{2026}' => out.push_str("..."),
378 '\u{00A0}' => out.push(' '),
379 other => out.push(other),
380 }
381 }
382 out
383}
384
385fn build_orig_to_norm_map(original: &str) -> Vec<usize> {
389 let mut map = Vec::with_capacity(original.len() + 1);
390 let mut np = 0usize;
391 for (idx, ch) in original.char_indices() {
392 while map.len() < idx {
394 map.push(np);
395 }
396 map.push(np);
397 let replaced_len = match ch {
398 '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' | '\u{00A0}' => 1,
399 '\u{2014}' => 2,
400 '\u{2013}' => 1,
401 '\u{2026}' => 3,
402 other => other.len_utf8(),
403 };
404 np += replaced_len;
405 }
406 while map.len() < original.len() {
407 map.push(np);
408 }
409 map.push(np);
410 map
411}
412
413fn map_norm_to_orig(
414 original: &str,
415 orig_to_norm: &[usize],
416 norm_matches: &[(usize, usize)],
417) -> Vec<(usize, usize)> {
418 let mut norm_to_orig: HashMap<usize, usize> = HashMap::new();
420 for (orig_pos, &mapped) in orig_to_norm.iter().enumerate().take(orig_to_norm.len() - 1) {
421 norm_to_orig.entry(mapped).or_insert(orig_pos);
422 }
423 let orig_len = orig_to_norm.len() - 1;
424 let mut out = Vec::new();
425 for &(ns, ne) in norm_matches {
426 let Some(&start) = norm_to_orig.get(&ns) else {
427 continue;
428 };
429 let mut end = start;
430 while end < orig_len && orig_to_norm[end] < ne {
431 end += 1;
432 }
433 let start = floor_char_boundary(original, start);
434 let end = floor_char_boundary(original, end);
435 if end > start {
436 out.push((start, end));
437 }
438 }
439 out
440}
441
442fn strategy_block_anchor(content: &str, pattern: &str) -> Vec<(usize, usize)> {
445 let norm_content = unicode_normalize(content);
446 let norm_pattern = unicode_normalize(pattern);
447 let pat_lines: Vec<&str> = norm_pattern.split('\n').collect();
448 if pat_lines.len() < 2 {
449 return Vec::new();
450 }
451 let first = pat_lines[0].trim();
452 let last = pat_lines[pat_lines.len() - 1].trim();
453
454 let norm_lines: Vec<&str> = norm_content.split('\n').collect();
455 let orig_lines: Vec<&str> = content.split('\n').collect();
456 let pat_count = pat_lines.len();
457 if norm_lines.len() < pat_count {
458 return Vec::new();
459 }
460
461 let mut potential = Vec::new();
462 for i in 0..=norm_lines.len() - pat_count {
463 if norm_lines[i].trim() == first && norm_lines[i + pat_count - 1].trim() == last {
464 potential.push(i);
465 }
466 }
467
468 let threshold = if potential.len() == 1 { 0.50 } else { 0.70 };
469 let mut out = Vec::new();
470 for i in potential {
471 let similarity = if pat_count <= 2 {
472 1.0
473 } else {
474 let content_middle = norm_lines[i + 1..i + pat_count - 1].join("\n");
475 let pattern_middle = pat_lines[1..pat_count - 1].join("\n");
476 sequence_ratio(&content_middle, &pattern_middle)
477 };
478 if similarity >= threshold {
479 if let Some((s, e)) = line_range_to_byte_range(content, &orig_lines, i, i + pat_count) {
480 out.push((s, e));
481 }
482 }
483 }
484 out
485}
486
487fn line_block_match<F>(
495 content: &str,
496 pattern: &str,
497 norm_line: F,
498 normalized_pattern: &str,
499) -> Vec<(usize, usize)>
500where
501 F: Fn(&str) -> String,
502{
503 let pat_lines: Vec<String> = normalized_pattern
504 .split('\n')
505 .map(|s| s.to_string())
506 .collect();
507 let pat_count = pat_lines.len();
508 if pat_count == 0 {
509 return Vec::new();
510 }
511 let content_lines: Vec<&str> = content.split('\n').collect();
512 if content_lines.len() < pat_count {
513 return Vec::new();
514 }
515 let normalized_lines: Vec<String> = content_lines.iter().map(|l| norm_line(l)).collect();
516 let _ = pattern;
517
518 let mut out = Vec::new();
519 for i in 0..=content_lines.len() - pat_count {
520 let block: &[String] = &normalized_lines[i..i + pat_count];
521 if block == pat_lines.as_slice() {
522 if let Some((s, e)) =
523 line_range_to_byte_range(content, &content_lines, i, i + pat_count)
524 {
525 out.push((s, e));
526 }
527 }
528 }
529 out
530}
531
532fn line_range_to_byte_range(
537 content: &str,
538 content_lines: &[&str],
539 start_line: usize,
540 end_line: usize,
541) -> Option<(usize, usize)> {
542 if start_line >= content_lines.len() || end_line == 0 {
543 return None;
544 }
545 let mut offset = 0usize;
547 for (i, line) in content_lines.iter().enumerate() {
548 if i == start_line {
549 break;
550 }
551 offset += line.len() + 1; }
553 let start_byte = offset;
554
555 let mut end_byte = offset;
557 for (idx, line) in content_lines.iter().enumerate().skip(start_line) {
558 if idx >= end_line {
559 break;
560 }
561 end_byte += line.len();
562 if idx < end_line - 1 {
564 end_byte += 1;
565 }
566 }
567 if end_byte > content.len() {
569 end_byte = content.len();
570 }
571 Some((start_byte, end_byte))
572}
573
574fn sequence_ratio(a: &str, b: &str) -> f64 {
578 let a: Vec<char> = a.chars().collect();
579 let b: Vec<char> = b.chars().collect();
580 let total = a.len() + b.len();
581 if total == 0 {
582 return 1.0;
583 }
584 if a.len() > 4096 || b.len() > 4096 {
586 let common = a.iter().zip(b.iter()).filter(|(x, y)| x == y).count();
588 return 2.0 * common as f64 / total as f64;
589 }
590 let mut prev = vec![0usize; b.len() + 1];
591 let mut curr = vec![0usize; b.len() + 1];
592 for i in 1..=a.len() {
593 for j in 1..=b.len() {
594 if a[i - 1] == b[j - 1] {
595 curr[j] = prev[j - 1] + 1;
596 } else {
597 curr[j] = prev[j].max(curr[j - 1]);
598 }
599 }
600 std::mem::swap(&mut prev, &mut curr);
601 for v in curr.iter_mut() {
602 *v = 0;
603 }
604 }
605 let matches = prev[b.len()];
606 2.0 * matches as f64 / total as f64
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn exact_match_passes_through() {
615 let r = fuzzy_find_and_replace("hello world", "world", "rust").unwrap();
616 assert_eq!(r.new_content, "hello rust");
617 assert_eq!(r.strategy, "exact");
618 }
619
620 #[test]
621 fn empty_old_string_errors() {
622 assert!(fuzzy_find_and_replace("x", "", "y").is_err());
623 }
624
625 #[test]
626 fn identical_strings_error() {
627 assert!(fuzzy_find_and_replace("x", "y", "y").is_err());
628 }
629
630 #[test]
631 fn line_trimmed_recovers_from_trailing_ws() {
632 let content = "fn foo() { \n bar();\n}";
633 let pattern = "fn foo() {\n bar();\n}";
634 let r = fuzzy_find_and_replace(content, pattern, "fn baz() {\n qux();\n}").unwrap();
635 assert_eq!(r.strategy, "line_trimmed");
636 assert!(r.new_content.contains("baz"));
637 }
638
639 #[test]
640 fn indentation_flexible_recovers_wrong_indent() {
641 let content = " if x:\n return 1\n";
642 let pattern = "if x:\n return 1";
643 let r = fuzzy_find_and_replace(content, pattern, "if y:\n return 2").unwrap();
644 assert!(
647 matches!(r.strategy, "line_trimmed" | "indentation_flexible"),
648 "got strategy={}",
649 r.strategy
650 );
651 assert!(r.new_content.contains("if y"));
652 }
653
654 #[test]
655 fn escape_normalized_handles_literal_backslash_n() {
656 let content = "alpha\nbeta\ngamma";
657 let pattern = "alpha\\nbeta";
658 let r = fuzzy_find_and_replace(content, pattern, "ALPHA\nBETA").unwrap();
659 assert_eq!(r.strategy, "escape_normalized");
660 assert!(r.new_content.starts_with("ALPHA\nBETA"));
661 }
662
663 #[test]
664 fn unicode_normalized_smart_quotes() {
665 let content = "let s = \"hi\";";
666 let pattern = "let s = \u{201C}hi\u{201D};";
668 let r = fuzzy_find_and_replace(content, pattern, "let s = \"bye\";").unwrap();
669 assert_eq!(r.strategy, "unicode_normalized");
670 assert!(r.new_content.contains("bye"));
671 }
672
673 #[test]
674 fn whitespace_normalized_collapses_runs() {
675 let content = "foo bar baz";
676 let pattern = "foo bar baz";
677 let r = fuzzy_find_and_replace(content, pattern, "FOO BAR BAZ").unwrap();
678 assert_eq!(r.strategy, "whitespace_normalized");
679 assert!(r.new_content.contains("FOO BAR BAZ"));
681 }
682
683 #[test]
684 fn block_anchor_with_similar_middle() {
685 let content = "fn foo() {\n let x = 1;\n let y = 2;\n let z = 3;\n}\n";
686 let pattern = "fn foo() {\n let x = 1;\n let y = 22;\n let z = 3;\n}";
688 let r = fuzzy_find_and_replace(content, pattern, "fn bar() {}").unwrap();
689 assert_eq!(r.strategy, "block_anchor");
690 assert!(r.new_content.contains("fn bar"));
691 }
692
693 #[test]
694 fn multi_match_errors_with_strategy_name() {
695 let content = "aaa bbb aaa";
696 let err = fuzzy_find_and_replace(content, "aaa", "ccc").unwrap_err();
697 assert!(err.contains("2 matches"));
698 assert!(err.contains("exact"));
699 }
700
701 #[test]
702 fn no_match_error_mentions_strategies() {
703 let err = fuzzy_find_and_replace("hello", "completely different", "x").unwrap_err();
704 assert!(err.contains("8 strategies"));
705 }
706
707 #[test]
708 fn escape_drift_blocked() {
709 let content = "fn foo() {\n println!('hi');\n}\n";
713 let pattern = "fn foo() {\n println!(\\'hi\\');\n}";
714 let new_str = "fn bar() {\n println!(\\'bye\\');\n}";
715 let err = fuzzy_find_and_replace(content, pattern, new_str).unwrap_err();
716 assert!(err.contains("Escape-drift detected"), "got: {err}");
717 }
718
719 #[test]
720 fn trimmed_boundary_recovers_first_line_ws() {
721 let content = " fn foo() {\n pass\n }\n";
722 let pattern = "fn foo() {\n pass\n}";
723 let r = fuzzy_find_and_replace(content, pattern, "fn bar() {\n pass\n}").unwrap();
724 assert!(r.strategy == "line_trimmed" || r.strategy == "trimmed_boundary");
727 assert!(r.new_content.contains("fn bar"));
728 }
729
730 #[test]
731 fn sequence_ratio_basic() {
732 assert!((sequence_ratio("abc", "abc") - 1.0).abs() < 1e-9);
733 assert!((sequence_ratio("abc", "xyz") - 0.0).abs() < 1e-9);
734 assert!(sequence_ratio("abcd", "abce") > 0.5);
735 }
736}