Skip to main content

microclaw_engine/tools/
fuzzy_match.rs

1//! Multi-strategy fuzzy matching for `edit_file`.
2//!
3//! LLMs frequently produce `old_string` arguments that don't byte-exactly match
4//! the file contents — wrong indentation, smart quotes pasted from a doc,
5//! collapsed whitespace, literal `\n` instead of newlines, etc. This module
6//! tries an ordered chain of increasingly-fuzzy strategies, returning the
7//! first that matches uniquely. The caller learns which strategy fired so it
8//! can surface that to the model (and the user).
9//!
10//! Hermes reference: `tools/fuzzy_match.py` (originally from OpenCode).
11//! Strategies (in order):
12//!  1. exact
13//!  2. line_trimmed     — strip leading/trailing ws per line
14//!  3. whitespace_normalized — collapse runs of spaces/tabs
15//!  4. indentation_flexible — lstrip per line (the most common LLM mistake)
16//!  5. escape_normalized — `\n`/`\t`/`\r` escape sequences → real chars
17//!  6. trimmed_boundary  — trim only first and last lines
18//!  7. unicode_normalized — smart quotes, em/en-dash, ellipsis, NBSP
19//!  8. block_anchor      — first+last line anchor + middle similarity
20//!
21//! `context_aware` (50% line similarity) is intentionally NOT ported — it's
22//! too loose and produces dangerous false positives.
23
24use std::collections::HashMap;
25
26/// Result of a fuzzy find/replace attempt.
27#[derive(Debug)]
28pub struct FuzzyMatch {
29    pub new_content: String,
30    pub strategy: &'static str,
31    pub match_count: usize,
32}
33
34/// Try the strategy chain. Returns Ok if exactly one match was found via some
35/// strategy. Returns Err with a human-readable explanation on no match,
36/// multi-match, or escape-drift.
37pub 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        // Escape-drift guard: when not the exact strategy, check for
77        // tool-call serialization drift (\\' or \\" copied as literal
78        // backslash-escapes the file doesn't contain).
79        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
144// =============================================================================
145// Strategies
146// =============================================================================
147
148/// 1. Exact match — multiple non-overlapping occurrences.
149fn 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
163/// 2. Trim each line of leading/trailing whitespace before matching.
164fn 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
178/// 3. Collapse runs of spaces/tabs to a single space (preserve newlines).
179fn 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
209/// Build a map from byte index in normalised string back to byte index in
210/// original when normalisation only DELETES bytes (i.e. it never inserts or
211/// substitutes — ws collapse can do both, but we approximate by walking).
212fn map_collapsed_positions(
213    original: &str,
214    normalized: &str,
215    norm_matches: &[(usize, usize)],
216) -> Vec<(usize, usize)> {
217    // Build byte-position correspondence.
218    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    // Sentinel safety: if our reconstruction differs in length, bail.
237    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            // Snap to char boundaries.
256            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
276/// 4. Strip leading whitespace from every line — handles indent drift.
277fn 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
291/// 5. Convert `\\n`, `\\t`, `\\r` escape sequences to real chars.
292fn 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
303/// 6. Trim whitespace from first and last lines only.
304fn 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
343/// 7. Unicode-normalise smart quotes, em/en-dashes, ellipsis, NBSP.
344fn 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
385/// For each char in `original` (by byte index), record the byte offset in the
386/// unicode-normalised string. Length = original.len() + 1; final entry is the
387/// total normalised byte length.
388fn 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        // Pad bytes inside a multibyte char with the same starting np.
393        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    // Invert: norm_pos -> first orig_pos that maps to it.
419    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
442/// 8. Anchor on first/last line + similarity on the middle. Threshold 0.50
443///    when there's exactly one anchor match, 0.70 otherwise.
444fn 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
487// =============================================================================
488// Helpers shared across strategies
489// =============================================================================
490
491/// Generic line-block scanner: normalise each line via `norm_line`, look for
492/// `normalized_pattern` as a contiguous slice of normalised lines, and report
493/// byte ranges in `content`.
494fn 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
532/// Convert (start_line, end_line_exclusive) into a byte range over `content`.
533/// The range covers [start_of_line(start), end_of_line(end-1)) — i.e. it does
534/// NOT include the trailing newline of the last line, which matches Hermes's
535/// `_calculate_line_positions` behavior.
536fn 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    // Compute byte offset to start of `start_line`.
546    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; // +1 for the '\n' separator
552    }
553    let start_byte = offset;
554
555    // Walk to end_line.
556    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        // Add the trailing newline EXCEPT for the last line of the block.
563        if idx < end_line - 1 {
564            end_byte += 1;
565        }
566    }
567    // Don't run past content (empty trailing lines from split).
568    if end_byte > content.len() {
569        end_byte = content.len();
570    }
571    Some((start_byte, end_byte))
572}
573
574/// difflib.SequenceMatcher.ratio() approximation: 2 * matches / (len(a) + len(b)).
575/// "matches" computed via LCS over the chars of both strings. O(n*m) memory —
576/// fine for typical edit_file payloads which are small.
577fn 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    // Cap to avoid pathological mem use.
585    if a.len() > 4096 || b.len() > 4096 {
586        // Fall back to length-based heuristic.
587        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        // line_trimmed is more permissive than indentation_flexible and may
645        // win first; we only require that SOME normalising strategy succeeds.
646        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        // smart double quotes
667        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        // After replace, original spans (foo...baz) become FOO BAR BAZ.
680        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        // Same anchor lines, slightly different middle (one renamed var).
687        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        // Drift only fires when SOME non-exact strategy matched and the
710        // matched region of the file lacks the suspect escape. Use
711        // block_anchor: matching first/last line + similar middle.
712        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        // line_trimmed already covers strict per-line strip; we just want SOME
725        // strategy to succeed.
726        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}