Skip to main content

cosh_tools/fs/
fuzzy.rs

1//! Fuzzy closest-match search — a behavior port of oh-my-pi's
2//! `crates/pi-edit/src/fuzzy.rs` (which itself ports the TypeScript
3//! `packages/coding-agent/src/edit/fuzzy.ts`).
4//!
5//! # Why this exists here
6//!
7//! The `edits` (content replace) engine matches `old_string` EXACTLY and
8//! uniquely — a wrong `old_string` is rejected, never guessed away. But a
9//! rejection that only says "not found" forces the model into an expensive
10//! recovery loop (re-read the file, re-copy the text). This module gives the
11//! rejection a memory: locate the CLOSEST near-match and describe it
12//! (similarity, line, `-`/`+` of the first differing line) so the model can
13//! re-issue with the correct text in a single round trip.
14//!
15//! # The one rule that makes fuzzy safe here
16//!
17//! The fuzzy result is **advisory only** — it never selects what gets
18//! replaced. oh-my-pi uses the same machinery to auto-substitute a
19//! high-confidence fuzzy hit; we deliberately do not. The worst case of a
20//! diagnostic mismatch is a slightly wrong hint; the worst case of a
21//! substitution mismatch is silently editing the wrong lines.
22//!
23//! # Behavioral fidelity
24//!
25//! Outputs are pinned byte-for-byte against oh-my-pi by ported tests
26//! (`fuzzy_equivalence.rs`) and by a differential dump example; a criterion
27//! benchmark (`benches/fuzzy.rs`) guards the miss-path cost on
28//! representative shapes. Where the TS original used UTF-16
29//! code units, the Rust port compares Unicode scalar values (astral
30//! characters count as one element) — a deliberate upstream decision kept
31//! here so similarity scores agree with the Rust reference.
32//!
33//! The `js_*` helpers reproduce JavaScript's `String.prototype.trim`
34//! whitespace set. That is behavioral fidelity, not a TS habit: models are
35//! trained on JS-based editing tools, so "trimmed" must mean what those
36//! tools meant.
37
38/// Default similarity threshold for fuzzy matching.
39pub const DEFAULT_FUZZY_THRESHOLD: f64 = 0.95;
40/// Fallback threshold for line-based matching without indentation depth.
41pub const FALLBACK_THRESHOLD: f64 = 0.8;
42/// Number of surrounding lines in occurrence previews.
43pub const OCCURRENCE_PREVIEW_CONTEXT: usize = 5;
44/// Maximum displayed line length in occurrence previews.
45pub const OCCURRENCE_PREVIEW_MAX_LEN: usize = 80;
46/// Occurrence previews and indices recorded before truncation.
47pub const MAX_RECORDED_MATCHES: usize = 5;
48/// A fuzzy hit at or above this confidence can dominate weaker siblings.
49pub const DOMINANT_FUZZY_MIN_CONFIDENCE: f64 = 0.97;
50/// Upper bound on approximate Levenshtein DP cells spent by one
51/// [`find_match`] call. The diagnostic scan must never become a latency
52/// hazard: a single-line snapshot (minified JS/JSON) times a long
53/// single-line `old_string` would otherwise run one O(n·m) DP with ~10¹⁰
54/// cells. When the estimate exceeds the budget, the fuzzy pass is skipped
55/// entirely (the outcome degrades to "no closest match" — a weaker message,
56/// never a wrong one).
57pub const MAX_FUZZY_WORK: u64 = 50_000_000;
58/// Minimum confidence gap for a dominant fuzzy hit.
59pub const DOMINANT_FUZZY_DELTA: f64 = 0.08;
60
61/// A located block of text.
62#[derive(Debug, Clone, PartialEq)]
63pub struct FuzzyMatch {
64    pub actual_text: String,
65    /// Byte offset of the match start in the searched content.
66    pub start_index: usize,
67    /// 1-indexed line of the match start.
68    pub start_line: u32,
69    pub confidence: f64,
70}
71
72/// Outcome of [`find_match`].
73#[derive(Debug, Clone, Default, PartialEq)]
74pub struct MatchOutcome {
75    pub matched: Option<FuzzyMatch>,
76    pub closest: Option<FuzzyMatch>,
77    pub occurrences: Option<usize>,
78    pub occurrence_lines: Option<Vec<u32>>,
79    pub occurrence_previews: Option<Vec<String>>,
80    pub fuzzy_matches: Option<usize>,
81    pub dominant_fuzzy: Option<bool>,
82}
83
84/// A byte range excluded from matching.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct ExcludedRange {
87    pub start_index: usize,
88    pub end_index: usize,
89}
90
91/// Knobs for [`find_match`].
92#[derive(Debug, Clone, Default)]
93pub struct FindMatchOptions<'a> {
94    pub allow_fuzzy: bool,
95    /// Defaults to [`DEFAULT_FUZZY_THRESHOLD`].
96    pub threshold: Option<f64>,
97    pub excluded_ranges: &'a [ExcludedRange],
98}
99
100/// JavaScript's `\s` / `String.prototype.trim` whitespace set (`WhiteSpace` +
101/// `LineTerminator` productions).
102pub const fn is_js_whitespace(ch: char) -> bool {
103    matches!(
104        ch,
105        '\t' | '\n' | '\u{0B}' | '\u{0C}' | '\r' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
106            ..='\u{200A}'
107                | '\u{2028}'
108                | '\u{2029}'
109                | '\u{202F}'
110                | '\u{205F}'
111                | '\u{3000}'
112                | '\u{FEFF}'
113    )
114}
115
116/// `String.prototype.trim` equivalent.
117pub fn js_trim(text: &str) -> &str {
118    text.trim_matches(is_js_whitespace)
119}
120
121/// Length in UTF-16 code units — JS `string.length`. Preview truncation uses
122/// it so astral characters never get cut in half.
123pub fn utf16_len(text: &str) -> usize {
124    text.encode_utf16().count()
125}
126
127/// True when `line` has any non-whitespace content.
128pub fn is_non_empty_line(line: &str) -> bool {
129    !js_trim(line).is_empty()
130}
131
132/// Count leading space/tab characters.
133pub fn count_leading_whitespace(line: &str) -> usize {
134    line.bytes()
135        .take_while(|b| *b == b' ' || *b == b'\t')
136        .count()
137}
138
139/// Normalize a line for fuzzy comparison: trim, fold quotes/dashes to ASCII,
140/// collapse runs of spaces and tabs. Pure per line — the result never
141/// depends on neighboring lines, which is what makes the window pre-compute
142/// in the applier's hot loop safe.
143pub fn normalize_for_fuzzy(line: &str) -> String {
144    let trimmed = js_trim(line);
145    if trimmed.is_empty() {
146        return String::new();
147    }
148    let mut out = String::with_capacity(trimmed.len());
149    let mut in_space = false;
150    for ch in trimmed.chars() {
151        let mapped = match ch {
152            '"' | '\u{201E}' | '\u{201F}' | '\u{AB}' | '\u{BB}' => '"',
153            '\'' | '\u{201A}' | '\u{201B}' | '`' | '\u{B4}' => '\'',
154            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2212}' => '-',
155            ' ' | '\t' => ' ',
156            other => other,
157        };
158        if mapped == ' ' {
159            if in_space {
160                continue;
161            }
162            in_space = true;
163        } else {
164            in_space = false;
165        }
166        out.push(mapped);
167    }
168    out
169}
170
171#[allow(clippy::suspicious_operation_groupings)]
172fn levenshtein_chars(a: &[char], b: &[char]) -> usize {
173    if a == b {
174        return 0;
175    }
176    // Trim the shared prefix/suffix first: most comparisons differ in a few
177    // characters, so the quadratic DP runs on a much smaller slice.
178    let mut start = 0;
179    let shared_limit = a.len().min(b.len());
180    while start < shared_limit && a[start] == b[start] {
181        start += 1;
182    }
183    let mut a_end = a.len();
184    let mut b_end = b.len();
185    while a_end > start && b_end > start && a[a_end - 1] == b[b_end - 1] {
186        a_end -= 1;
187        b_end -= 1;
188    }
189    let mut longer = &a[start..a_end];
190    let mut shorter = &b[start..b_end];
191    if longer.is_empty() {
192        return shorter.len();
193    }
194    if shorter.is_empty() {
195        return longer.len();
196    }
197    if shorter.len() > longer.len() {
198        std::mem::swap(&mut longer, &mut shorter);
199    }
200
201    // Single-row DP: `row` holds the previous row; `diagonal` carries the
202    // cell above-left across the column step.
203    let mut row: Vec<usize> = (0..=shorter.len()).collect();
204    for (line, &a_char) in longer.iter().enumerate() {
205        let mut diagonal = row[0];
206        row[0] = line + 1;
207        for (column, &b_char) in shorter.iter().enumerate() {
208            let cell = column + 1;
209            let above = row[cell];
210            row[cell] = if a_char == b_char {
211                diagonal
212            } else {
213                (above + 1).min(row[cell - 1] + 1).min(diagonal + 1)
214            };
215            diagonal = above;
216        }
217    }
218    row[shorter.len()]
219}
220
221/// Levenshtein edit distance over Unicode scalar values.
222///
223/// The TypeScript source used UTF-16 code units. Rust deliberately uses
224/// Unicode scalar values, so astral characters count as one element.
225pub fn levenshtein_distance(a: &str, b: &str) -> usize {
226    let a_chars: Vec<char> = a.chars().collect();
227    let b_chars: Vec<char> = b.chars().collect();
228    levenshtein_chars(&a_chars, &b_chars)
229}
230
231/// Similarity in `[0, 1]`: `1 - distance / max_len`.
232pub fn similarity(a: &str, b: &str) -> f64 {
233    let a_chars: Vec<char> = a.chars().collect();
234    let b_chars: Vec<char> = b.chars().collect();
235    let max_len = a_chars.len().max(b_chars.len());
236    if max_len == 0 {
237        return 1.0;
238    }
239    1.0 - levenshtein_chars(&a_chars, &b_chars) as f64 / max_len as f64
240}
241
242fn format_preview_window(lines: &[&str], center_index: usize) -> String {
243    let start = center_index.saturating_sub(OCCURRENCE_PREVIEW_CONTEXT);
244    let end = lines
245        .len()
246        .min(center_index + OCCURRENCE_PREVIEW_CONTEXT + 1);
247    lines[start..end]
248        .iter()
249        .enumerate()
250        .map(|(offset, line)| {
251            let truncated = if utf16_len(line) > OCCURRENCE_PREVIEW_MAX_LEN {
252                let mut units = 0;
253                let mut text = String::new();
254                for ch in line.chars() {
255                    let width = ch.len_utf16();
256                    if units + width > OCCURRENCE_PREVIEW_MAX_LEN - 1 {
257                        break;
258                    }
259                    text.push(ch);
260                    units += width;
261                }
262                text.push('…');
263                text
264            } else {
265                (*line).to_owned()
266            };
267            format!("  {} | {truncated}", start + offset + 1)
268        })
269        .collect::<Vec<_>>()
270        .join("\n")
271}
272
273fn overlaps_excluded(start: usize, end: usize, ranges: &[ExcludedRange]) -> bool {
274    ranges
275        .iter()
276        .any(|range| start < range.end_index && end > range.start_index)
277}
278
279fn find_exact_match_outcome(
280    content: &str,
281    target: &str,
282    excluded_ranges: &[ExcludedRange],
283) -> Option<MatchOutcome> {
284    let mut first_index = None;
285    let mut occurrences = 0;
286    let mut recorded_indices = Vec::new();
287    let mut search_start = 0;
288    while search_start <= content.len().saturating_sub(target.len()) {
289        let Some(relative) = content[search_start..].find(target) else {
290            break;
291        };
292        let index = search_start + relative;
293        let end_index = index + target.len();
294        if !overlaps_excluded(index, end_index, excluded_ranges) {
295            first_index.get_or_insert(index);
296            occurrences += 1;
297            if recorded_indices.len() < MAX_RECORDED_MATCHES {
298                recorded_indices.push(index);
299            }
300        }
301        search_start = end_index;
302    }
303    let first_index = first_index?;
304    if occurrences > 1 {
305        let content_lines: Vec<&str> = content.split('\n').collect();
306        let mut occurrence_lines = Vec::with_capacity(recorded_indices.len());
307        let mut occurrence_previews = Vec::with_capacity(recorded_indices.len());
308        for index in recorded_indices {
309            let line_number = content[..index]
310                .bytes()
311                .filter(|byte| *byte == b'\n')
312                .count()
313                + 1;
314            occurrence_lines.push(line_number as u32);
315            occurrence_previews.push(format_preview_window(&content_lines, line_number - 1));
316        }
317        return Some(MatchOutcome {
318            occurrences: Some(occurrences),
319            occurrence_lines: Some(occurrence_lines),
320            occurrence_previews: Some(occurrence_previews),
321            ..MatchOutcome::default()
322        });
323    }
324    let start_line = content[..first_index]
325        .bytes()
326        .filter(|byte| *byte == b'\n')
327        .count() as u32
328        + 1;
329    Some(MatchOutcome {
330        matched: Some(FuzzyMatch {
331            actual_text: target.to_owned(),
332            start_index: first_index,
333            start_line,
334            confidence: 1.0,
335        }),
336        ..MatchOutcome::default()
337    })
338}
339
340/// Relative indentation depth of each line, measured in the smallest
341/// non-zero indent step present in the block. Blank lines have depth 0.
342/// The depth prefixes are what let the fuzzy score tell "same code at a
343/// different nesting" from "different code".
344fn relative_indent_depths(lines: &[&str]) -> Vec<usize> {
345    let indents: Vec<usize> = lines
346        .iter()
347        .map(|line| count_leading_whitespace(line))
348        .collect();
349    let non_empty_indents: Vec<usize> = lines
350        .iter()
351        .zip(&indents)
352        .filter_map(|(line, indent)| is_non_empty_line(line).then_some(*indent))
353        .collect();
354    let min_indent = non_empty_indents.iter().copied().min().unwrap_or(0);
355    let indent_unit = non_empty_indents
356        .iter()
357        .filter_map(|indent| indent.checked_sub(min_indent))
358        .filter(|step| *step > 0)
359        .min()
360        .unwrap_or(1);
361    lines
362        .iter()
363        .zip(indents)
364        .map(|(line, indent)| {
365            if !is_non_empty_line(line) || indent_unit == 0 {
366                0
367            } else {
368                ((indent - min_indent) as f64 / indent_unit as f64).round() as usize
369            }
370        })
371        .collect()
372}
373
374/// Build the comparison form of one line: a depth (or `|`) prefix followed by
375/// the whitespace-folded content. The prefix `|` form (no depth) is the
376/// fallback pass used when indentation is inconsistent.
377fn normalize_line(depth: Option<usize>, trimmed_normalized: &str) -> String {
378    match depth {
379        Some(depth) => format!("{depth}|{trimmed_normalized}"),
380        None => format!("|{trimmed_normalized}"),
381    }
382}
383
384/// Byte offset of the start of each line (`content_lines` must be the split
385/// of `content` on `'\n'`).
386fn line_offsets(lines: &[&str]) -> Vec<usize> {
387    let mut offsets = Vec::with_capacity(lines.len());
388    let mut offset = 0;
389    for (index, line) in lines.iter().enumerate() {
390        offsets.push(offset);
391        offset += line.len() + usize::from(index + 1 < lines.len());
392    }
393    offsets
394}
395
396#[derive(Debug)]
397struct BestFuzzyMatch {
398    best: Option<FuzzyMatch>,
399    above_threshold_count: usize,
400    second_best_score: f64,
401}
402
403/// One sliding-window pass over the content. Each window of
404/// `content_lines.len() == target_lines.len()` lines is scored as the MEAN
405/// per-line similarity of the normalized forms; windows at/above the
406/// threshold are counted, and the best/second-best scores feed the
407/// dominant-match rule.
408///
409/// Unlike upstream, the whitespace-folded form of every content line is
410/// computed ONCE (via `normalized_lines`) instead of once per window — the
411/// strings are pure per line, so the results are identical while the hot
412/// loop stops re-running the character-level fold.
413fn best_fuzzy_match_core(
414    content_lines: &[&str],
415    target_lines: &[&str],
416    offsets: &[usize],
417    normalized_lines: &[String],
418    threshold: f64,
419    include_depth: bool,
420    excluded_ranges: &[ExcludedRange],
421) -> BestFuzzyMatch {
422    let target_normalized = normalize_lines(target_lines, include_depth, None);
423    let mut best = None;
424    let mut best_score = -1.0;
425    let mut second_best_score = -1.0;
426    let mut above_threshold_count = 0;
427    for start in 0..=content_lines.len() - target_lines.len() {
428        let start_index = offsets[start];
429        let end_line = start + target_lines.len() - 1;
430        let end_index = (offsets[end_line] + content_lines[end_line].len()).max(start_index + 1);
431        if overlaps_excluded(start_index, end_index, excluded_ranges) {
432            continue;
433        }
434        let window_normalized = normalize_lines(
435            &content_lines[start..start + target_lines.len()],
436            include_depth,
437            Some(&normalized_lines[start..start + target_lines.len()]),
438        );
439        let score = target_normalized
440            .iter()
441            .zip(&window_normalized)
442            .map(|(target, actual)| similarity(target, actual))
443            .sum::<f64>()
444            / target_lines.len() as f64;
445        if score >= threshold {
446            above_threshold_count += 1;
447        }
448        if score > best_score {
449            second_best_score = best_score;
450            best_score = score;
451            best = Some(FuzzyMatch {
452                actual_text: content_lines[start..start + target_lines.len()].join("\n"),
453                start_index,
454                start_line: start as u32 + 1,
455                confidence: score,
456            });
457        } else if score > second_best_score {
458            second_best_score = score;
459        }
460    }
461    BestFuzzyMatch {
462        best,
463        above_threshold_count,
464        second_best_score,
465    }
466}
467
468/// Normalize every line of a block. `precomputed` (content side only) may
469/// carry the per-line `normalize_for_fuzzy(js_trim(line))` results — the
470/// depth prefix still depends on the whole window, so only the prefix is
471/// built per call.
472fn normalize_lines(
473    lines: &[&str],
474    include_depth: bool,
475    precomputed: Option<&[String]>,
476) -> Vec<String> {
477    let depths = include_depth.then(|| relative_indent_depths(lines));
478    lines
479        .iter()
480        .enumerate()
481        .map(|(index, line)| {
482            let trimmed_normalized = match precomputed {
483                Some(precomputed) => precomputed[index].clone(),
484                None => normalize_for_fuzzy(js_trim(line)),
485            };
486            let depth = depths.as_ref().map(|values| values[index]);
487            normalize_line(depth, &trimmed_normalized)
488        })
489        .collect()
490}
491
492/// Best fuzzy location of `target` inside `content` (whole-block windows).
493///
494/// Two passes: with indentation depths first; if the best score is stuck in
495/// the `[FALLBACK_THRESHOLD, threshold)` band, a second pass WITHOUT depth
496/// runs (indentation-heavy drift, e.g. a block pasted at a different
497/// nesting) and wins only when it scores strictly higher.
498fn best_fuzzy_match(
499    content: &str,
500    target: &str,
501    threshold: f64,
502    excluded_ranges: &[ExcludedRange],
503) -> BestFuzzyMatch {
504    let content_lines: Vec<&str> = content.split('\n').collect();
505    let target_lines: Vec<&str> = target.split('\n').collect();
506    if target.is_empty() || target_lines.len() > content_lines.len() {
507        return BestFuzzyMatch {
508            best: None,
509            above_threshold_count: 0,
510            second_best_score: 0.0,
511        };
512    }
513    // Work estimate: each window scores every target line against its
514    // counterpart, costing ~len(search line) × len(target line) DP cells.
515    // The sum telescopes to content length × the longest target line.
516    let max_target_line_len = target_lines
517        .iter()
518        .map(|line| line.len())
519        .max()
520        .unwrap_or(0);
521    let work = (content.len() as u64) * (max_target_line_len as u64);
522    if work > MAX_FUZZY_WORK {
523        return BestFuzzyMatch {
524            best: None,
525            above_threshold_count: 0,
526            second_best_score: 0.0,
527        };
528    }
529    let offsets = line_offsets(&content_lines);
530    // The per-line fold is window-independent: compute the expensive part of
531    // the hot loop exactly once for the whole file.
532    let normalized_lines: Vec<String> = content_lines
533        .iter()
534        .map(|line| normalize_for_fuzzy(js_trim(line)))
535        .collect();
536    let mut result = best_fuzzy_match_core(
537        &content_lines,
538        &target_lines,
539        &offsets,
540        &normalized_lines,
541        threshold,
542        true,
543        excluded_ranges,
544    );
545    if result
546        .best
547        .as_ref()
548        .is_some_and(|best| best.confidence < threshold && best.confidence >= FALLBACK_THRESHOLD)
549    {
550        let without_depth = best_fuzzy_match_core(
551            &content_lines,
552            &target_lines,
553            &offsets,
554            &normalized_lines,
555            threshold,
556            false,
557            excluded_ranges,
558        );
559        if without_depth.best.as_ref().is_some_and(|candidate| {
560            result
561                .best
562                .as_ref()
563                .is_none_or(|best| candidate.confidence > best.confidence)
564        }) {
565            result = without_depth;
566        }
567    }
568    result
569}
570
571/// Locate `target` in `content`: exact first (non-overlapping scan;
572/// multiple occurrences are reported, never auto-chosen), then fuzzy when
573/// allowed. Excluded ranges are invisible to both passes.
574///
575/// In this crate the outcome is consumed by the content-replace engine's
576/// REJECTION diagnostics only — `matched` here never means "substitute it".
577pub fn find_match(content: &str, target: &str, options: &FindMatchOptions<'_>) -> MatchOutcome {
578    if target.is_empty() {
579        return MatchOutcome::default();
580    }
581    if let Some(exact) = find_exact_match_outcome(content, target, options.excluded_ranges) {
582        return exact;
583    }
584    let threshold = options.threshold.unwrap_or(DEFAULT_FUZZY_THRESHOLD);
585    let result = best_fuzzy_match(content, target, threshold, options.excluded_ranges);
586    let Some(best) = result.best else {
587        return MatchOutcome::default();
588    };
589    if options.allow_fuzzy && best.confidence >= threshold {
590        if result.above_threshold_count == 1 {
591            return MatchOutcome {
592                matched: Some(best.clone()),
593                closest: Some(best),
594                ..MatchOutcome::default()
595            };
596        }
597        // Several windows scored at/above the threshold: only a DOMINANT
598        // best (≥ 0.97 and ≥ 0.08 ahead of the runner-up) can be singled
599        // out; otherwise the ambiguity is reported, never resolved.
600        if result.above_threshold_count > 1
601            && best.confidence >= DOMINANT_FUZZY_MIN_CONFIDENCE
602            && best.confidence - result.second_best_score >= DOMINANT_FUZZY_DELTA
603        {
604            return MatchOutcome {
605                matched: Some(best.clone()),
606                closest: Some(best),
607                fuzzy_matches: Some(result.above_threshold_count),
608                dominant_fuzzy: Some(true),
609                ..MatchOutcome::default()
610            };
611        }
612    }
613    MatchOutcome {
614        closest: Some(best),
615        fuzzy_matches: Some(result.above_threshold_count),
616        ..MatchOutcome::default()
617    }
618}
619
620pub fn first_different_line<'a>(
621    old_lines: &'a [&str],
622    new_lines: &'a [&str],
623) -> (&'a str, &'a str) {
624    for index in 0..old_lines.len().max(new_lines.len()) {
625        let old = old_lines.get(index).copied().unwrap_or("");
626        let new = new_lines.get(index).copied().unwrap_or("");
627        if old != new {
628            return (old, new);
629        }
630    }
631    (
632        old_lines.first().copied().unwrap_or(""),
633        new_lines.first().copied().unwrap_or(""),
634    )
635}
636
637/// Format the no-match rejection, byte-for-byte compatible with upstream's
638/// `EditMatchError.formatMessage` (pinned by ported tests): heading, then —
639/// when a closest candidate exists — its similarity, line, and the first
640/// differing line as `-`/`+`, then the discriminating hint.
641pub fn format_match_error(
642    path: &str,
643    search_text: &str,
644    closest: Option<&FuzzyMatch>,
645    allow_fuzzy: bool,
646    threshold: f64,
647    fuzzy_matches: Option<usize>,
648) -> String {
649    let Some(closest) = closest else {
650        return if allow_fuzzy {
651            format!("Could not find a close enough match in {path}.")
652        } else {
653            format!(
654                "Could not find the exact text in {path}. The old text must match exactly including \
655                 all whitespace and newlines."
656            )
657        };
658    };
659    let similarity_percent = (closest.confidence * 100.0).round() as i64;
660    let threshold_percent = (threshold * 100.0).round() as i64;
661    let search_lines: Vec<&str> = search_text.split('\n').collect();
662    let actual_lines: Vec<&str> = closest.actual_text.split('\n').collect();
663    let (old_line, new_line) = first_different_line(&search_lines, &actual_lines);
664    let hint = if allow_fuzzy {
665        if fuzzy_matches.is_some_and(|count| count > 1) {
666            format!(
667                "Found {} high-confidence matches. Provide more context to make it unique.",
668                fuzzy_matches.unwrap_or(0)
669            )
670        } else {
671            format!("Closest match was below the {threshold_percent}% similarity threshold.")
672        }
673    } else {
674        "Fuzzy matching is disabled. Enable 'Edit fuzzy match' in settings to accept high-confidence \
675         matches."
676            .to_owned()
677    };
678    let heading = if allow_fuzzy {
679        format!("Could not find a close enough match in {path}.")
680    } else {
681        format!("Could not find the exact text in {path}.")
682    };
683    format!(
684        "{heading}\n\nClosest match ({similarity_percent}% similar) at line {}:\n  - {old_line}\n  \
685         + {new_line}\n{hint}",
686        closest.start_line
687    )
688}
689
690/// Format the ambiguous-exact-match rejection, byte-for-byte compatible with
691/// upstream: occurrence count, bounded preview windows around each recorded
692/// occurrence, and the disambiguation instruction.
693pub fn format_occurrence_error(path: &str, outcome: &MatchOutcome) -> String {
694    let occurrences = outcome.occurrences.unwrap_or(0);
695    let previews = outcome
696        .occurrence_previews
697        .as_ref()
698        .map_or_else(String::new, |items| items.join("\n\n"));
699    let more = if occurrences > MAX_RECORDED_MATCHES {
700        format!(" (showing first {MAX_RECORDED_MATCHES} of {occurrences})")
701    } else {
702        String::new()
703    };
704    format!(
705        "Found {occurrences} occurrences in {path}{more}:\n\n{previews}\n\nAdd more context lines \
706         to disambiguate."
707    )
708}