Skip to main content

aft/patch/
matcher.rs

1//! Patch-specific line-sequence matcher ported from the TypeScript apply_patch engine.
2//!
3//! This module intentionally does not reuse `fuzzy_match`: edit matching works in byte
4//! ranges, while apply_patch needs line indexes, EOF anchoring, and unique-only reflow.
5
6use std::collections::{HashMap, HashSet};
7
8/// Allow candidate reflow windows to differ by up to eight non-whitespace characters before exact normalized comparison.
9pub const REFLOW_NON_WS_TOLERANCE: usize = 8;
10/// Avoid spending diagnostic work or memory on files too large to render usefully in an error.
11pub const NEAREST_MISS_MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
12
13const NEAREST_MISS_ANCHOR_COUNT: usize = 3;
14const NEAREST_MISS_MAX_CANDIDATES: usize = 512;
15const NEAREST_MISS_MAX_POSITIONS_PER_ANCHOR: usize = 192;
16const NEAREST_MISS_MAX_LINE_COMPARISONS: usize = 100_000;
17const NEAREST_MISS_MAX_FUZZY_CHARS: usize = 256;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct NearestMiss {
21    /// Zero-based first line of the candidate window.
22    pub start: usize,
23    /// Zero-based exclusive end of the available candidate window.
24    pub end: usize,
25    pub matched_lines: usize,
26    /// Zero-based wanted-line offset of the first mismatch.
27    pub first_divergence: usize,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum NearestMissSearch {
32    Found(NearestMiss),
33    NoSimilarRegion,
34    SkippedLargeFile,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum MatchTier {
39    Exact,
40    Rstrip,
41    Trim,
42    Indent,
43    Unicode,
44    Reflow,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct SequenceMatch {
49    pub found: usize,
50    pub tier: MatchTier,
51    pub line_count: usize,
52}
53
54/// Convert smart quotes, dash variants, ellipsis, and NBSP to their ASCII forms; mirrors `patch-parser.ts:207-214`.
55pub fn normalize_unicode(input: &str) -> String {
56    let mut normalized = String::with_capacity(input.len());
57    for ch in input.chars() {
58        match ch {
59            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => normalized.push('\''),
60            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => normalized.push('"'),
61            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}' => {
62                normalized.push('-');
63            }
64            '\u{2026}' => normalized.push_str("..."),
65            '\u{00A0}' => normalized.push(' '),
66            _ => normalized.push(ch),
67        }
68    }
69    normalized
70}
71
72/// Replace a leading run of tabs and spaces with the same number of plain spaces; mirrors `patch-parser.ts:227-229`.
73pub fn normalize_indent(input: &str) -> String {
74    let mut leading_chars = 0;
75    let mut leading_bytes = 0;
76
77    for ch in input.chars() {
78        if ch != '\t' && ch != ' ' {
79            break;
80        }
81        leading_chars += 1;
82        leading_bytes += ch.len_utf8();
83    }
84
85    if leading_chars == 0 {
86        return input.to_owned();
87    }
88
89    let mut normalized = String::with_capacity(input.len());
90    normalized.push_str(&" ".repeat(leading_chars));
91    normalized.push_str(&input[leading_bytes..]);
92    normalized
93}
94
95/// Collapse every Unicode whitespace run to one space and trim the ends; mirrors `patch-parser.ts:233-235`.
96pub fn normalize_reflow_whitespace(input: &str) -> String {
97    let mut collapsed = String::with_capacity(input.len());
98    let mut in_whitespace = false;
99
100    for ch in input.chars() {
101        if ch.is_whitespace() {
102            if !in_whitespace {
103                collapsed.push(' ');
104                in_whitespace = true;
105            }
106        } else {
107            collapsed.push(ch);
108            in_whitespace = false;
109        }
110    }
111
112    collapsed.trim().to_owned()
113}
114
115/// Remove every Unicode whitespace character; mirrors `patch-parser.ts:237-239`.
116pub fn strip_reflow_whitespace(input: &str) -> String {
117    input.chars().filter(|ch| !ch.is_whitespace()).collect()
118}
119
120/// Return true when a line has any non-whitespace content; mirrors `patch-parser.ts:241-243`.
121pub fn has_reflow_content(input: &str) -> bool {
122    input.chars().any(|ch| !ch.is_whitespace())
123}
124
125fn matches_at<F>(lines: &[&str], pattern: &[&str], start: usize, compare: &F) -> bool
126where
127    F: Fn(&str, &str) -> bool,
128{
129    pattern
130        .iter()
131        .enumerate()
132        .all(|(offset, expected)| compare(lines[start + offset], expected))
133}
134
135/// Search for a full pattern with a caller-supplied comparator, optionally anchored at EOF; mirrors `patch-parser.ts:247-281`.
136pub fn try_match<F>(
137    lines: &[&str],
138    pattern: &[&str],
139    start_index: usize,
140    compare: F,
141    eof: bool,
142) -> Option<usize>
143where
144    F: Fn(&str, &str) -> bool,
145{
146    if pattern.is_empty() || pattern.len() > lines.len() {
147        return None;
148    }
149
150    if eof {
151        let from_end = lines.len() - pattern.len();
152        if from_end >= start_index && matches_at(lines, pattern, from_end, &compare) {
153            return Some(from_end);
154        }
155        return None;
156    }
157
158    let last_start = lines.len() - pattern.len();
159    if start_index > last_start {
160        return None;
161    }
162
163    (start_index..=last_start).find(|&start| matches_at(lines, pattern, start, &compare))
164}
165
166fn non_whitespace_unit_count(input: &str) -> usize {
167    // TypeScript uses UTF-16 code units for `.length`; Rust has no direct equivalent on `str`.
168    // The length check only bounds candidate windows before exact string equality, so counting
169    // Unicode scalar values keeps non-ASCII text from being over-weighted by UTF-8 byte length.
170    strip_reflow_whitespace(input).chars().count()
171}
172
173/// Find one unique whitespace-reflowed window, returning `(found_line, line_count)`; mirrors `patch-parser.ts:310-351`.
174pub fn find_reflow_match(
175    lines: &[&str],
176    pattern: &[&str],
177    start_index: usize,
178) -> Option<(usize, usize)> {
179    let needle_text = pattern.join("\n");
180    let normalized_needle = normalize_reflow_whitespace(&needle_text);
181    let needle_non_whitespace = strip_reflow_whitespace(&needle_text);
182    if normalized_needle.is_empty() || needle_non_whitespace.is_empty() {
183        return None;
184    }
185
186    let needle_non_whitespace_len = needle_non_whitespace.chars().count();
187    let min_non_whitespace = needle_non_whitespace_len.saturating_sub(REFLOW_NON_WS_TOLERANCE);
188    let max_non_whitespace = needle_non_whitespace_len + REFLOW_NON_WS_TOLERANCE;
189    let mut matches = Vec::new();
190    let mut seen = HashSet::new();
191
192    for start in start_index..lines.len() {
193        if !has_reflow_content(lines[start]) {
194            continue;
195        }
196
197        let mut window_non_whitespace_len = 0;
198        for end in (start + 1)..=lines.len() {
199            let line = lines[end - 1];
200            window_non_whitespace_len += non_whitespace_unit_count(line);
201
202            if window_non_whitespace_len > max_non_whitespace {
203                break;
204            }
205            if window_non_whitespace_len < min_non_whitespace {
206                continue;
207            }
208            if !has_reflow_content(line) {
209                continue;
210            }
211
212            let window_text = lines[start..end].join("\n");
213            let window_non_whitespace = strip_reflow_whitespace(&window_text);
214            if window_non_whitespace != needle_non_whitespace {
215                continue;
216            }
217            if normalize_reflow_whitespace(&window_text) != normalized_needle {
218                continue;
219            }
220
221            if seen.insert((start, end)) {
222                matches.push((start, end - start));
223            }
224        }
225    }
226
227    if matches.len() == 1 {
228        Some(matches[0])
229    } else {
230        None
231    }
232}
233
234/// Run the first-hit-wins Exact/Rstrip/Trim/Indent/Unicode/Reflow ladder; mirrors `patch-parser.ts:353-399`.
235pub fn seek_sequence_tiered(
236    lines: &[&str],
237    pattern: &[&str],
238    start_index: usize,
239    eof: bool,
240) -> Option<SequenceMatch> {
241    if pattern.is_empty() {
242        return None;
243    }
244
245    if let Some(found) = try_match(lines, pattern, start_index, |a, b| a == b, eof) {
246        return Some(SequenceMatch {
247            found,
248            tier: MatchTier::Exact,
249            line_count: pattern.len(),
250        });
251    }
252
253    if let Some(found) = try_match(
254        lines,
255        pattern,
256        start_index,
257        |a, b| a.trim_end() == b.trim_end(),
258        eof,
259    ) {
260        return Some(SequenceMatch {
261            found,
262            tier: MatchTier::Rstrip,
263            line_count: pattern.len(),
264        });
265    }
266
267    if let Some(found) = try_match(
268        lines,
269        pattern,
270        start_index,
271        |a, b| a.trim() == b.trim(),
272        eof,
273    ) {
274        return Some(SequenceMatch {
275            found,
276            tier: MatchTier::Trim,
277            line_count: pattern.len(),
278        });
279    }
280
281    if let Some(found) = try_match(
282        lines,
283        pattern,
284        start_index,
285        |a, b| normalize_indent(a).trim_end() == normalize_indent(b).trim_end(),
286        eof,
287    ) {
288        return Some(SequenceMatch {
289            found,
290            tier: MatchTier::Indent,
291            line_count: pattern.len(),
292        });
293    }
294
295    if let Some(found) = try_match(
296        lines,
297        pattern,
298        start_index,
299        |a, b| normalize_unicode(a.trim()) == normalize_unicode(b.trim()),
300        eof,
301    ) {
302        return Some(SequenceMatch {
303            found,
304            tier: MatchTier::Unicode,
305            line_count: pattern.len(),
306        });
307    }
308
309    if eof {
310        return None;
311    }
312
313    find_reflow_match(lines, pattern, start_index).map(|(found, line_count)| SequenceMatch {
314        found,
315        tier: MatchTier::Reflow,
316        line_count,
317    })
318}
319
320fn add_sampled_candidates(
321    candidates: &mut HashSet<usize>,
322    positions: &[usize],
323    wanted_offset: usize,
324    candidate_limit: usize,
325) {
326    let remaining = candidate_limit.saturating_sub(candidates.len());
327    let sample_count = positions
328        .len()
329        .min(NEAREST_MISS_MAX_POSITIONS_PER_ANCHOR)
330        .min(remaining);
331    if sample_count == 0 {
332        return;
333    }
334
335    for sample in 0..sample_count {
336        let position_index = if sample_count == 1 {
337            0
338        } else {
339            sample * (positions.len() - 1) / (sample_count - 1)
340        };
341        let file_position = positions[position_index];
342        if let Some(start) = file_position.checked_sub(wanted_offset) {
343            candidates.insert(start);
344        }
345    }
346}
347
348fn score_nearest_miss(lines: &[&str], pattern: &[&str], start: usize) -> NearestMiss {
349    let end = (start + pattern.len()).min(lines.len());
350    let matched_lines = pattern
351        .iter()
352        .enumerate()
353        .filter(|(offset, expected)| {
354            lines
355                .get(start + offset)
356                .is_some_and(|actual| actual.trim() == expected.trim())
357        })
358        .count();
359    let first_divergence = pattern
360        .iter()
361        .enumerate()
362        .find(|(offset, expected)| {
363            lines
364                .get(start + offset)
365                .is_none_or(|actual| actual.trim() != expected.trim())
366        })
367        .map_or(pattern.len(), |(offset, _)| offset);
368
369    NearestMiss {
370        start,
371        end,
372        matched_lines,
373        first_divergence,
374    }
375}
376
377fn is_better_nearest_miss(candidate: NearestMiss, current: NearestMiss) -> bool {
378    candidate.matched_lines > current.matched_lines
379        || (candidate.matched_lines == current.matched_lines
380            && (candidate.first_divergence > current.first_divergence
381                || (candidate.first_divergence == current.first_divergence
382                    && candidate.start < current.start)))
383}
384
385fn best_scored_candidate(
386    lines: &[&str],
387    pattern: &[&str],
388    candidates: HashSet<usize>,
389) -> Option<NearestMiss> {
390    candidates
391        .into_iter()
392        .filter(|start| *start < lines.len())
393        .map(|start| score_nearest_miss(lines, pattern, start))
394        .fold(None, |best, candidate| match best {
395            Some(current) if !is_better_nearest_miss(candidate, current) => Some(current),
396            _ => Some(candidate),
397        })
398}
399
400fn normalize_fuzzy_line(line: &str) -> String {
401    normalize_unicode(&normalize_reflow_whitespace(line))
402}
403
404fn normalized_prefix_score(normalized_wanted: &str, actual: &str) -> Option<usize> {
405    let actual = normalize_fuzzy_line(actual);
406    let wanted_len = normalized_wanted
407        .chars()
408        .take(NEAREST_MISS_MAX_FUZZY_CHARS)
409        .count();
410    if wanted_len < 4 {
411        return None;
412    }
413
414    let common = normalized_wanted
415        .chars()
416        .zip(actual.chars())
417        .take(NEAREST_MISS_MAX_FUZZY_CHARS)
418        .take_while(|(wanted_char, actual_char)| wanted_char == actual_char)
419        .count();
420    (common >= 4 && common * 2 >= wanted_len).then_some(common)
421}
422
423fn rarest_wanted_line<'a>(pattern: &'a [&'a str]) -> Option<(usize, &'a str)> {
424    let mut frequencies: HashMap<&str, usize> = HashMap::new();
425    for line in pattern
426        .iter()
427        .map(|line| line.trim())
428        .filter(|line| !line.is_empty())
429    {
430        *frequencies.entry(line).or_default() += 1;
431    }
432
433    pattern
434        .iter()
435        .enumerate()
436        .map(|(offset, line)| (offset, line.trim()))
437        .filter(|(_, line)| !line.is_empty())
438        .min_by_key(|(offset, line)| {
439            (
440                frequencies.get(line).copied().unwrap_or(usize::MAX),
441                std::cmp::Reverse(line.chars().count()),
442                *offset,
443            )
444        })
445}
446
447/// Find a bounded best-effort candidate after every accepted match tier has failed.
448pub fn find_nearest_miss(
449    lines: &[&str],
450    pattern: &[&str],
451    file_size_bytes: usize,
452) -> NearestMissSearch {
453    if file_size_bytes > NEAREST_MISS_MAX_FILE_BYTES {
454        return NearestMissSearch::SkippedLargeFile;
455    }
456    if lines.is_empty() || pattern.is_empty() {
457        return NearestMissSearch::NoSimilarRegion;
458    }
459
460    let mut line_index: HashMap<&str, Vec<usize>> = HashMap::new();
461    for (position, line) in lines.iter().enumerate() {
462        let trimmed = line.trim();
463        if !trimmed.is_empty() {
464            line_index.entry(trimmed).or_default().push(position);
465        }
466    }
467
468    let candidate_limit = NEAREST_MISS_MAX_CANDIDATES
469        .min((NEAREST_MISS_MAX_LINE_COMPARISONS / pattern.len().max(1)).max(1));
470    let mut anchor_positions: Vec<(usize, &[usize])> = pattern
471        .iter()
472        .enumerate()
473        .filter(|(_, line)| !line.trim().is_empty())
474        .take(NEAREST_MISS_ANCHOR_COUNT)
475        .filter_map(|(offset, line)| {
476            line_index
477                .get(line.trim())
478                .map(|positions| (offset, positions.as_slice()))
479        })
480        .collect();
481    anchor_positions.sort_by_key(|(offset, positions)| (positions.len(), *offset));
482
483    let mut candidates = HashSet::new();
484    for (wanted_offset, positions) in anchor_positions {
485        add_sampled_candidates(&mut candidates, positions, wanted_offset, candidate_limit);
486        if candidates.len() >= candidate_limit {
487            break;
488        }
489    }
490    if let Some(best) = best_scored_candidate(lines, pattern, candidates) {
491        return NearestMissSearch::Found(best);
492    }
493
494    let Some((wanted_offset, wanted_line)) = rarest_wanted_line(pattern) else {
495        return NearestMissSearch::NoSimilarRegion;
496    };
497    let normalized_wanted = normalize_fuzzy_line(wanted_line);
498    let mut best_prefix = 0;
499    let mut fuzzy_candidates = HashSet::new();
500    for (file_position, actual_line) in lines.iter().enumerate() {
501        let Some(start) = file_position.checked_sub(wanted_offset) else {
502            continue;
503        };
504        let Some(prefix_score) = normalized_prefix_score(&normalized_wanted, actual_line) else {
505            continue;
506        };
507        if prefix_score > best_prefix {
508            best_prefix = prefix_score;
509            fuzzy_candidates.clear();
510        }
511        if prefix_score == best_prefix && fuzzy_candidates.len() < candidate_limit {
512            fuzzy_candidates.insert(start);
513        }
514    }
515
516    best_scored_candidate(lines, pattern, fuzzy_candidates)
517        .map_or(NearestMissSearch::NoSimilarRegion, NearestMissSearch::Found)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    fn assert_match(
525        actual: Option<SequenceMatch>,
526        found: usize,
527        tier: MatchTier,
528        line_count: usize,
529    ) {
530        assert_eq!(
531            actual,
532            Some(SequenceMatch {
533                found,
534                tier,
535                line_count,
536            })
537        );
538    }
539
540    #[test]
541    fn normalization_helpers_match_patch_parser_sources() {
542        assert_eq!(
543            normalize_unicode("‘’‚‛“”„‟‐‑‒–—―…\u{00A0}"),
544            "''''\"\"\"\"------... "
545        );
546        assert_eq!(normalize_indent("\t  alpha\t beta  "), "   alpha\t beta  ");
547        assert_eq!(normalize_indent(""), "");
548        assert_eq!(
549            normalize_reflow_whitespace(" \talpha\n\u{00A0} beta  "),
550            "alpha beta"
551        );
552        assert_eq!(
553            strip_reflow_whitespace(" \talpha\n\u{00A0} beta  "),
554            "alphabeta"
555        );
556        assert!(has_reflow_content("\u{00A0}x"));
557        assert!(!has_reflow_content(" \t\n"));
558    }
559
560    #[test]
561    fn exact_tier_wins_without_upgrading_to_later_tiers() {
562        assert_match(
563            seek_sequence_tiered(&["alpha", "beta"], &["beta"], 0, false),
564            1,
565            MatchTier::Exact,
566            1,
567        );
568    }
569
570    #[test]
571    fn rstrip_tier_wins_before_trim() {
572        assert_match(
573            seek_sequence_tiered(&["alpha   "], &["alpha"], 0, false),
574            0,
575            MatchTier::Rstrip,
576            1,
577        );
578    }
579
580    #[test]
581    fn trim_tier_wins_before_indent_and_unicode() {
582        assert_match(
583            seek_sequence_tiered(&["  alpha  "], &["alpha"], 0, false),
584            0,
585            MatchTier::Trim,
586            1,
587        );
588    }
589
590    #[test]
591    fn indent_normalization_matches_tab_space_drift_but_trim_shadows_the_tier() {
592        assert_eq!(normalize_indent("\treturn 42;"), " return 42;");
593        assert_eq!(normalize_indent(" return 42;"), " return 42;");
594        assert_eq!(
595            try_match(
596                &["\treturn 42;"],
597                &[" return 42;"],
598                0,
599                |a, b| normalize_indent(a).trim_end() == normalize_indent(b).trim_end(),
600                false,
601            ),
602            Some(0)
603        );
604        // Expect Trim, not Indent, for tab-vs-space input: a leading tab-vs-space drift
605        // is already accepted by the earlier trim tier, so the nominal indent tier is shadowed.
606        assert_match(
607            seek_sequence_tiered(&["\treturn 42;"], &["    return 42;"], 0, false),
608            0,
609            MatchTier::Trim,
610            1,
611        );
612    }
613
614    #[test]
615    fn unicode_tier_normalizes_smart_punctuation_after_stricter_tiers_fail() {
616        assert_match(
617            seek_sequence_tiered(
618                &["const label = “alpha”—beta…;"],
619                &["const label = \"alpha\"-beta...;"],
620                0,
621                false,
622            ),
623            0,
624            MatchTier::Unicode,
625            1,
626        );
627    }
628
629    #[test]
630    fn reflow_tier_matches_one_line_hunk_against_three_line_formatter_split() {
631        let lines = [
632            "function demo() {",
633            "  const value = alpha +",
634            "    beta +",
635            "    gamma;",
636            "  return value;",
637            "}",
638        ];
639        let pattern = ["  const value = alpha + beta + gamma;"];
640
641        assert_match(
642            seek_sequence_tiered(&lines, &pattern, 0, false),
643            1,
644            MatchTier::Reflow,
645            3,
646        );
647    }
648
649    #[test]
650    fn rejects_ambiguous_reflow_matches_instead_of_choosing_a_window() {
651        // Reject a reflow match when the pattern could match more than one distinct window.
652        let lines = [
653            "const value = alpha +",
654            "  beta +",
655            "  gamma;",
656            "",
657            "const value = alpha +",
658            "  beta +",
659            "  gamma;",
660        ];
661        let pattern = ["const value = alpha + beta + gamma;"];
662
663        assert_eq!(find_reflow_match(&lines, &pattern, 0), None);
664        assert_eq!(seek_sequence_tiered(&lines, &pattern, 0, false), None);
665    }
666
667    #[test]
668    fn uses_line_contiguous_match_before_considering_reflow_candidate() {
669        // A line-contiguous match wins before any reflow candidate is considered.
670        let lines = [
671            "const value = alpha +",
672            "  beta +",
673            "  gamma;",
674            "const value = alpha + beta + gamma;",
675        ];
676        let pattern = ["const value = alpha + beta + gamma;"];
677
678        assert_match(
679            seek_sequence_tiered(&lines, &pattern, 0, false),
680            3,
681            MatchTier::Exact,
682            1,
683        );
684    }
685
686    #[test]
687    fn eof_hunk_only_matches_the_tail_and_never_forward_scans() {
688        // EOF-anchored hunks match only the tail and never forward-scan.
689        let pattern = ["marker", "old"];
690
691        assert_match(
692            seek_sequence_tiered(
693                &["header", "marker", "old", "middle", "marker", "old"],
694                &pattern,
695                0,
696                true,
697            ),
698            4,
699            MatchTier::Exact,
700            2,
701        );
702        assert_eq!(
703            seek_sequence_tiered(
704                &["header", "marker", "old", "middle", "marker", "changed"],
705                &pattern,
706                0,
707                true,
708            ),
709            None
710        );
711    }
712
713    #[test]
714    fn eof_hunk_skips_reflow_even_when_the_tail_would_reflow_match() {
715        let lines = ["header", "const value = alpha +", "  beta +", "  gamma;"];
716        let pattern = ["const value = alpha + beta + gamma;"];
717
718        assert_eq!(find_reflow_match(&lines, &pattern, 0), Some((1, 3)));
719        assert_eq!(seek_sequence_tiered(&lines, &pattern, 0, true), None);
720    }
721
722    #[test]
723    fn try_match_honors_start_index_for_forward_scans_and_eof_anchor() {
724        assert_eq!(
725            try_match(&["a", "b", "a", "b"], &["a", "b"], 1, |a, b| a == b, false),
726            Some(2)
727        );
728        assert_eq!(
729            try_match(&["a", "b", "a", "b"], &["a", "b"], 3, |a, b| a == b, false),
730            None
731        );
732        assert_eq!(
733            try_match(&["a", "b", "a", "b"], &["a", "b"], 3, |a, b| a == b, true),
734            None
735        );
736    }
737
738    #[test]
739    fn nearest_miss_scores_matching_lines_across_the_candidate_window() {
740        let lines = [
741            "header",
742            "  const first = 1;",
743            "  const actual = 2;",
744            "  return first;",
745            "separator",
746            "  const first = 1;",
747            "  unrelated",
748            "  unrelated",
749        ];
750        let pattern = [
751            "  const first = 1;",
752            "  const expected = 2;",
753            "  return first;",
754        ];
755
756        assert_eq!(
757            find_nearest_miss(&lines, &pattern, 128),
758            NearestMissSearch::Found(NearestMiss {
759                start: 1,
760                end: 4,
761                matched_lines: 2,
762                first_divergence: 1,
763            })
764        );
765    }
766
767    #[test]
768    fn nearest_miss_uses_a_strong_prefix_when_no_anchor_matches_exactly() {
769        assert_eq!(
770            find_nearest_miss(
771                &["header", "const expected_value = 2;", "footer"],
772                &["const expected_value = 1;"],
773                42,
774            ),
775            NearestMissSearch::Found(NearestMiss {
776                start: 1,
777                end: 2,
778                matched_lines: 0,
779                first_divergence: 0,
780            })
781        );
782    }
783
784    #[test]
785    fn nearest_miss_reports_no_region_when_anchors_and_prefixes_are_absent() {
786        assert_eq!(
787            find_nearest_miss(
788                &["alpha", "beta", "gamma"],
789                &["completely unrelated line"],
790                17,
791            ),
792            NearestMissSearch::NoSimilarRegion
793        );
794    }
795
796    #[test]
797    fn nearest_miss_skips_files_larger_than_the_diagnostic_limit() {
798        let synthetic_file = "x".repeat(NEAREST_MISS_MAX_FILE_BYTES + 1);
799        assert_eq!(
800            find_nearest_miss(
801                &[synthetic_file.as_str()],
802                &["wanted content"],
803                synthetic_file.len(),
804            ),
805            NearestMissSearch::SkippedLargeFile
806        );
807    }
808}