Skip to main content

rpi_tools/tools/
edit_diff.rs

1//! Mirrors `packages/agent/src/harness/tools/edit-diff.ts` — the diff/fuzzy/apply
2//! engine backing the `edit` tool.
3//!
4//! Pipeline: `detect_line_ending` → `strip_bom` → `normalize_to_lf` →
5//! `apply_edits_to_normalized_content` (fuzzy find, not-found/duplicate/overlap/
6//! no-change errors, line-preserving replacement when fuzzy) →
7//! `restore_line_endings` → `generate_diff_string` + `generate_unified_patch`.
8
9use similar::{udiff::unified_diff, Algorithm, TextDiff};
10
11use crate::error::FileError;
12
13/// Result of [`strip_bom`].
14pub struct BomResult<'a> {
15    pub bom: &'static str,
16    pub text: &'a str,
17}
18
19/// Strip a leading U+FEFF BOM. Mirrors `stripBom` (returns the BOM string +
20/// the text without it).
21pub fn strip_bom(content: &str) -> BomResult<'_> {
22    const BOM: &str = "\u{FEFF}";
23    if let Some(rest) = content.strip_prefix(BOM) {
24        BomResult {
25            bom: BOM,
26            text: rest,
27        }
28    } else {
29        BomResult {
30            bom: "",
31            text: content,
32        }
33    }
34}
35
36/// Detect the dominant line ending: `\r\n` or `\n`. Mirrors `detectLineEnding`.
37/// Lone `\r` is treated as `\n` (the normalizer maps both to `\n`).
38pub fn detect_line_ending(content: &str) -> &'static str {
39    let first_crlf = content.find("\r\n");
40    let first_lf = content.find('\n');
41    match (first_crlf, first_lf) {
42        (None, None) => "\n",
43        (Some(_), None) => "\n",
44        (Some(crlf), Some(lf)) => {
45            if crlf <= lf {
46                "\r\n"
47            } else {
48                "\n"
49            }
50        }
51        (None, Some(_)) => "\n",
52    }
53}
54
55/// `\r\n` and lone `\r` → `\n`. Mirrors `normalizeToLF`.
56pub fn normalize_to_lf(text: &str) -> String {
57    text.replace("\r\n", "\n").replace('\r', "\n")
58}
59
60/// `\n` → `ending`. Mirrors `restoreLineEndings`.
61pub fn restore_line_endings(text: &str, ending: &str) -> String {
62    if ending == "\r\n" {
63        text.replace('\n', "\r\n")
64    } else {
65        text.to_string()
66    }
67}
68
69/// NFKC + per-line trimEnd + smart-quote/dash/space normalization. Mirrors
70/// `normalizeForFuzzyMatch`.
71pub fn normalize_for_fuzzy_match(text: &str) -> String {
72    use unicode_normalization::UnicodeNormalization;
73    let nfkc: String = text.nfkc().collect();
74    // Per-line trimEnd.
75    let trimmed: String = nfkc
76        .split('\n')
77        .map(|line| line.trim_end())
78        .collect::<Vec<_>>()
79        .join("\n");
80    let mut out = String::with_capacity(trimmed.len());
81    for c in trimmed.chars() {
82        match c {
83            '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => out.push('\''),
84            '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
85            '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
86            | '\u{2212}' => out.push('-'),
87            '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
88            | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
89            | '\u{3000}' => out.push(' '),
90            other => out.push(other),
91        }
92    }
93    out
94}
95
96/// Result of [`fuzzy_find_text`].
97#[derive(Clone)]
98pub struct FuzzyMatchResult {
99    pub found: bool,
100    pub index: usize,
101    pub match_length: usize,
102    pub used_fuzzy_match: bool,
103    /// The content to splice into (original when exact, normalized when fuzzy).
104    pub content_for_replacement: String,
105}
106
107/// Find `old_text` in `content`: exact first, then fuzzy-normalized. Mirrors
108/// `fuzzyFindText`.
109pub fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
110    if let Some(idx) = content.find(old_text) {
111        return FuzzyMatchResult {
112            found: true,
113            index: idx,
114            match_length: old_text.len(),
115            used_fuzzy_match: false,
116            content_for_replacement: content.to_string(),
117        };
118    }
119    let fuzzy_content = normalize_for_fuzzy_match(content);
120    let fuzzy_old = normalize_for_fuzzy_match(old_text);
121    if let Some(idx) = fuzzy_content.find(&fuzzy_old) {
122        return FuzzyMatchResult {
123            found: true,
124            index: idx,
125            match_length: fuzzy_old.len(),
126            used_fuzzy_match: true,
127            content_for_replacement: fuzzy_content,
128        };
129    }
130    FuzzyMatchResult {
131        found: false,
132        index: usize::MAX,
133        match_length: 0,
134        used_fuzzy_match: false,
135        content_for_replacement: content.to_string(),
136    }
137}
138
139/// Count occurrences of `old_text` in `content` (both fuzzy-normalized). Mirrors
140/// `countOccurrences`.
141pub fn count_occurrences(content: &str, old_text: &str) -> usize {
142    let fc = normalize_for_fuzzy_match(content);
143    let fo = normalize_for_fuzzy_match(old_text);
144    if fo.is_empty() {
145        return 0;
146    }
147    fc.matches(&fo).count()
148}
149
150/// A single replacement `{ old_text, new_text }`.
151#[derive(Clone)]
152pub struct ReplaceEdit {
153    pub old_text: String,
154    pub new_text: String,
155}
156
157/// A matched replacement ready to apply.
158#[derive(Clone)]
159struct MatchedEdit {
160    edit_index: usize,
161    match_index: usize,
162    match_length: usize,
163    new_text: String,
164}
165
166/// Result of [`apply_edits_to_normalized_content`].
167#[derive(Debug)]
168pub struct ApplyResult {
169    pub base_content: String,
170    pub new_content: String,
171    pub used_fuzzy_match: bool,
172}
173
174/// Empty-`old_text` / not-found / duplicate / overlap / no-change → `Err`.
175/// Mirrors `applyEditsToNormalizedContent`.
176pub fn apply_edits_to_normalized_content(
177    normalized_content: &str,
178    edits: &[ReplaceEdit],
179    path: &str,
180) -> Result<ApplyResult, FileError> {
181    let total = edits.len();
182    let normalized_edits: Vec<ReplaceEdit> = edits
183        .iter()
184        .map(|e| ReplaceEdit {
185            old_text: normalize_to_lf(&e.old_text),
186            new_text: normalize_to_lf(&e.new_text),
187        })
188        .collect();
189
190    // Empty oldText check.
191    for (i, e) in normalized_edits.iter().enumerate() {
192        if e.old_text.is_empty() {
193            return Err(FileError::new(
194                crate::error::FileErrorCode::Invalid,
195                empty_old_text_error(path, i, total),
196            )
197            .with_path(path));
198        }
199    }
200
201    let initial_matches: Vec<FuzzyMatchResult> = normalized_edits
202        .iter()
203        .map(|e| fuzzy_find_text(normalized_content, &e.old_text))
204        .collect();
205    let used_fuzzy_match = initial_matches.iter().any(|m| m.used_fuzzy_match);
206    let replacement_base_content = if used_fuzzy_match {
207        normalize_for_fuzzy_match(normalized_content)
208    } else {
209        normalized_content.to_string()
210    };
211
212    let mut matched_edits: Vec<MatchedEdit> = Vec::new();
213    for (i, e) in normalized_edits.iter().enumerate() {
214        let m = fuzzy_find_text(&replacement_base_content, &e.old_text);
215        if !m.found {
216            return Err(FileError::new(
217                crate::error::FileErrorCode::NotFound,
218                not_found_error(path, i, total),
219            )
220            .with_path(path));
221        }
222        let occurrences = count_occurrences(&replacement_base_content, &e.old_text);
223        if occurrences > 1 {
224            return Err(FileError::new(
225                crate::error::FileErrorCode::Invalid,
226                format!(
227                    "{}{}",
228                    duplicate_error(path, i, total, occurrences),
229                    duplicate_match_context(normalized_content, &e.old_text),
230                ),
231            )
232            .with_path(path));
233        }
234        matched_edits.push(MatchedEdit {
235            edit_index: i,
236            match_index: m.index,
237            match_length: m.match_length,
238            new_text: e.new_text.clone(),
239        });
240    }
241
242    // Sort by match_index, check overlap.
243    matched_edits.sort_by_key(|m| m.match_index);
244    for pair in matched_edits.windows(2) {
245        let prev = &pair[0];
246        let cur = &pair[1];
247        if prev.match_index + prev.match_length > cur.match_index {
248            return Err(FileError::new(
249                crate::error::FileErrorCode::Invalid,
250                format!(
251                    "edits[{}] and edits[{}] overlap in {}. Merge them into one edit or target disjoint regions.",
252                    prev.edit_index, cur.edit_index, path
253                ),
254            )
255            .with_path(path));
256        }
257    }
258
259    let base_content = normalized_content.to_string();
260    let new_content = if used_fuzzy_match {
261        apply_replacements_preserving_unchanged_lines(
262            normalized_content,
263            &replacement_base_content,
264            &matched_edits,
265        )?
266    } else {
267        apply_replacements(&replacement_base_content, &matched_edits)
268    };
269
270    if base_content == new_content {
271        return Err(FileError::new(
272            crate::error::FileErrorCode::Invalid,
273            no_change_error(path, total),
274        )
275        .with_path(path));
276    }
277
278    Ok(ApplyResult {
279        base_content,
280        new_content,
281        used_fuzzy_match,
282    })
283}
284
285/// Splice replacements in reverse match-index order. Mirrors `applyReplacements`.
286fn apply_replacements(content: &str, replacements: &[MatchedEdit]) -> String {
287    let mut result = content.to_string();
288    let mut sorted = replacements.to_vec();
289    sorted.sort_by(|a, b| b.match_index.cmp(&a.match_index));
290    for r in sorted {
291        let start = r.match_index;
292        let end = start + r.match_length;
293        if end <= result.len() {
294            result.replace_range(start..end, &r.new_text);
295        }
296    }
297    result
298}
299
300/// Preserve original (non-normalized) bytes on untouched lines. Mirrors
301/// `applyReplacementsPreservingUnchangedLines`.
302fn apply_replacements_preserving_unchanged_lines(
303    original_content: &str,
304    base_content: &str,
305    replacements: &[MatchedEdit],
306) -> Result<String, FileError> {
307    let original_lines = split_lines_with_endings(original_content);
308    let base_lines = get_line_spans(base_content);
309    if original_lines.len() != base_lines.len() {
310        return Err(FileError::new(
311            crate::error::FileErrorCode::Invalid,
312            "Cannot preserve unchanged lines because the base content has a different line count.",
313        ));
314    }
315    // Group replacements by line range.
316    let mut sorted = replacements.to_vec();
317    sorted.sort_by(|a, b| a.match_index.cmp(&b.match_index));
318    let mut groups: Vec<(usize, usize, Vec<MatchedEdit>)> = Vec::new();
319    for r in &sorted {
320        let range =
321            get_replacement_line_range(&base_lines, r.match_index, r.match_index + r.match_length);
322        if let Some(last) = groups.last_mut() {
323            if range.start_line < last.1 {
324                last.1 = last.1.max(range.end_line);
325                last.2.push(r.clone());
326                continue;
327            }
328        }
329        groups.push((range.start_line, range.end_line, vec![r.clone()]));
330    }
331
332    let mut out = String::new();
333    let mut original_line_idx = 0usize;
334    for (start_line, end_line, reps) in &groups {
335        // Append unchanged leading lines.
336        if original_line_idx < *start_line {
337            out.push_str(&original_lines[original_line_idx..*start_line].join(""));
338        }
339        // Apply replacements to the base slice for this line group.
340        let group_start_offset = base_lines[*start_line].0;
341        let group_end_offset = base_lines[*end_line - 1].1;
342        let group_slice = &base_content[group_start_offset..group_end_offset];
343        // Offset replacements' match_index by -group_start_offset.
344        let offset_reps: Vec<MatchedEdit> = reps
345            .iter()
346            .map(|r| MatchedEdit {
347                edit_index: r.edit_index,
348                match_index: r.match_index - group_start_offset,
349                match_length: r.match_length,
350                new_text: r.new_text.clone(),
351            })
352            .collect();
353        out.push_str(&apply_replacements(group_slice, &offset_reps));
354        original_line_idx = *end_line;
355    }
356    // Append remaining unchanged lines.
357    if original_line_idx < original_lines.len() {
358        out.push_str(&original_lines[original_line_idx..].join(""));
359    }
360    Ok(out)
361}
362
363/// Split content into lines, each preserving its trailing `\n` (or the final
364/// line without one). Mirrors `splitLinesWithEndings`.
365fn split_lines_with_endings(content: &str) -> Vec<String> {
366    let mut out = Vec::new();
367    let mut acc = String::new();
368    for c in content.chars() {
369        acc.push(c);
370        if c == '\n' {
371            out.push(std::mem::take(&mut acc));
372        }
373    }
374    if !acc.is_empty() {
375        out.push(acc);
376    }
377    out
378}
379
380/// `(start, end)` byte-offset spans per line. Mirrors `getLineSpans`.
381fn get_line_spans(content: &str) -> Vec<(usize, usize)> {
382    let mut spans = Vec::new();
383    let mut start = 0usize;
384    for (i, c) in content.char_indices() {
385        if c == '\n' {
386            spans.push((start, i + 1));
387            start = i + 1;
388        }
389    }
390    if start < content.len() {
391        spans.push((start, content.len()));
392    }
393    spans
394}
395
396struct LineRange {
397    start_line: usize,
398    end_line: usize,
399}
400
401/// Find the line range covering `[start_offset, end_offset)`. Mirrors
402/// `getReplacementLineRange`. `end_line` is exclusive.
403fn get_replacement_line_range(
404    lines: &[(usize, usize)],
405    start_offset: usize,
406    end_offset: usize,
407) -> LineRange {
408    let mut start_line = 0usize;
409    for (i, span) in lines.iter().enumerate() {
410        if start_offset >= span.0 && start_offset < span.1 {
411            start_line = i;
412            break;
413        }
414        start_line = i + 1;
415    }
416    let mut end_line = start_line + 1;
417    while end_line < lines.len() && lines[end_line - 1].1 < end_offset {
418        end_line += 1;
419    }
420    LineRange {
421        start_line,
422        end_line,
423    }
424}
425
426/// `{ diff, first_changed_line }`. Mirrors `generateDiffString`.
427pub struct DiffResult {
428    pub diff: String,
429    pub first_changed_line: Option<usize>,
430}
431
432/// A display-oriented diff (NOT a unified patch) with line-number prefixes.
433/// Mirrors `generateDiffString` with a 4-line context window.
434pub fn generate_diff_string(
435    old_content: &str,
436    new_content: &str,
437    context_lines: usize,
438) -> DiffResult {
439    let parts = TextDiff::from_lines(old_content, new_content);
440    let ops: Vec<_> = parts.ops().to_vec();
441    let old_lines: Vec<&str> = old_content.split('\n').collect();
442    let new_lines: Vec<&str> = new_content.split('\n').collect();
443    let max_line_num = old_lines.len().max(new_lines.len());
444    let line_num_width = max_line_num.to_string().len();
445
446    let mut out: Vec<String> = Vec::new();
447    let mut old_line_num = 1usize;
448    let mut new_line_num = 1usize;
449    let mut last_was_change = false;
450    let mut first_changed_line: Option<usize> = None;
451
452    for (i, op) in ops.iter().enumerate() {
453        let tag = op.tag();
454        // The raw lines for this op — sliced from the op ranges directly
455        // (mirrors TS `part.value.split("\n")` + trailing-empty pop).
456        let mut raw: Vec<&str> = match tag {
457            similar::DiffTag::Equal | similar::DiffTag::Delete => {
458                old_lines[op.old_range().start..op.old_range().end].to_vec()
459            }
460            _ => new_lines[op.new_range().start..op.new_range().end].to_vec(),
461        };
462        if raw.last().map(|s| s.is_empty()).unwrap_or(false) {
463            raw.pop();
464        }
465        let raw_len = raw.len();
466
467        let is_added = matches!(tag, similar::DiffTag::Insert | similar::DiffTag::Replace);
468        let is_removed = matches!(tag, similar::DiffTag::Delete | similar::DiffTag::Replace);
469
470        if is_added || is_removed {
471            if first_changed_line.is_none() {
472                first_changed_line = Some(new_line_num);
473            }
474            for line in &raw {
475                if is_added {
476                    out.push(format!(
477                        "+{:>width$} {}",
478                        new_line_num,
479                        line,
480                        width = line_num_width
481                    ));
482                    new_line_num += 1;
483                }
484                if is_removed {
485                    out.push(format!(
486                        "-{:>width$} {}",
487                        old_line_num,
488                        line,
489                        width = line_num_width
490                    ));
491                    old_line_num += 1;
492                }
493            }
494            // A `Replace` op iterates both add and remove above; for pure
495            // Insert/Delete only one branch fires. Leave last_was_change set.
496            last_was_change = true;
497            let _ = i; // index only needed for context lookahead below
498        } else {
499            // Equal → context lines, show a few before/after changes.
500            let next_is_change = i + 1 < ops.len()
501                && matches!(
502                    ops[i + 1].tag(),
503                    similar::DiffTag::Insert | similar::DiffTag::Delete | similar::DiffTag::Replace
504                );
505            let has_leading_change = last_was_change;
506            let has_trailing_change = next_is_change;
507
508            if has_leading_change && has_trailing_change {
509                if raw_len <= context_lines * 2 {
510                    for line in &raw {
511                        out.push(format!(
512                            " {:>width$} {}",
513                            old_line_num,
514                            line,
515                            width = line_num_width
516                        ));
517                        old_line_num += 1;
518                        new_line_num += 1;
519                    }
520                } else {
521                    let leading = &raw[..context_lines];
522                    let trailing = &raw[raw_len - context_lines..];
523                    let skipped = raw_len - leading.len() - trailing.len();
524                    for line in leading {
525                        out.push(format!(
526                            " {:>width$} {}",
527                            old_line_num,
528                            line,
529                            width = line_num_width
530                        ));
531                        old_line_num += 1;
532                        new_line_num += 1;
533                    }
534                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
535                    old_line_num += skipped;
536                    new_line_num += skipped;
537                    for line in trailing {
538                        out.push(format!(
539                            " {:>width$} {}",
540                            old_line_num,
541                            line,
542                            width = line_num_width
543                        ));
544                        old_line_num += 1;
545                        new_line_num += 1;
546                    }
547                }
548            } else if has_leading_change {
549                let shown = if raw_len <= context_lines {
550                    &raw[..]
551                } else {
552                    &raw[..context_lines]
553                };
554                let skipped = raw_len - shown.len();
555                for line in shown {
556                    out.push(format!(
557                        " {:>width$} {}",
558                        old_line_num,
559                        line,
560                        width = line_num_width
561                    ));
562                    old_line_num += 1;
563                    new_line_num += 1;
564                }
565                if skipped > 0 {
566                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
567                    old_line_num += skipped;
568                    new_line_num += skipped;
569                }
570            } else if has_trailing_change {
571                let skipped = raw_len.saturating_sub(context_lines);
572                if skipped > 0 {
573                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
574                    old_line_num += skipped;
575                    new_line_num += skipped;
576                }
577                for line in &raw[skipped..] {
578                    out.push(format!(
579                        " {:>width$} {}",
580                        old_line_num,
581                        line,
582                        width = line_num_width
583                    ));
584                    old_line_num += 1;
585                    new_line_num += 1;
586                }
587            } else {
588                // Skip these context lines entirely.
589                old_line_num += raw_len;
590                new_line_num += raw_len;
591            }
592            last_was_change = false;
593        }
594    }
595
596    DiffResult {
597        diff: out.join("\n"),
598        first_changed_line,
599    }
600}
601
602/// A unified-diff patch with 4 lines of context, file headers only (no
603/// timestamps). Mirrors `generateUnifiedPatch` (via the `similar` crate, which
604/// is the Rust equivalent of the npm `diff` package).
605pub fn generate_unified_patch(path: &str, old_content: &str, new_content: &str) -> String {
606    // `similar::udiff::unified_diff(alg, old, new, context_radius, header)`
607    // where `header: Option<(&str,&str)>` carries the (old_label, new_label)
608    // file headers (`--- ` / `+++ `). No timestamps, matching the TS port.
609    unified_diff(
610        Algorithm::Myers,
611        old_content,
612        new_content,
613        4,
614        Some((path, path)),
615    )
616}
617
618// --- error-message builders (mirror the TS single-vs-multi variants) ---
619
620fn empty_old_text_error(path: &str, i: usize, total: usize) -> String {
621    if total == 1 {
622        format!("oldText must not be empty in {path}.")
623    } else {
624        format!("edits[{i}].oldText must not be empty in {path}.")
625    }
626}
627
628fn not_found_error(path: &str, i: usize, total: usize) -> String {
629    if total == 1 {
630        format!("Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines.")
631    } else {
632        format!("Could not find edits[{i}] in {path}. The old text must match exactly including all whitespace and newlines.")
633    }
634}
635
636fn duplicate_error(path: &str, i: usize, total: usize, occurrences: usize) -> String {
637    if total == 1 {
638        format!("Found {occurrences} occurrences of the text in {path}. The text must be unique — include more surrounding context to disambiguate.")
639    } else {
640        format!("Found {occurrences} occurrences of edits[{i}] in {path}. The text must be unique — include more surrounding context to disambiguate.")
641    }
642}
643
644/// Show bounded diagnostics using the same normalization as duplicate detection.
645/// Line numbers refer to the original file, and snippets preserve its characters.
646fn duplicate_match_context(content: &str, old_text: &str) -> String {
647    const MAX_MATCHES: usize = 5;
648    const MAX_LINE_CHARS: usize = 200;
649    let normalized = normalize_for_fuzzy_match(content);
650    let needle = normalize_for_fuzzy_match(old_text);
651    if needle.is_empty() {
652        return String::new();
653    }
654    let mut line_starts = vec![0];
655    line_starts.extend(normalized.match_indices('\n').map(|(index, _)| index + 1));
656    let original_lines: Vec<&str> = content.split('\n').collect();
657    let mut out = String::from(
658        "\nMatching locations (line labels are diagnostic; do not include them in oldText):\n",
659    );
660    for (number, (offset, _)) in normalized.match_indices(&needle).enumerate() {
661        if number == MAX_MATCHES {
662            out.push_str("[More matches omitted; use read to inspect the intended region.]\n");
663            break;
664        }
665        let line = line_starts.partition_point(|start| *start <= offset) - 1;
666        out.push_str(&format!("Match {} at line {}:\n", number + 1, line + 1));
667        let start = line.saturating_sub(1);
668        let end = (line + 2).min(original_lines.len());
669        for (index, text) in original_lines.iter().enumerate().take(end).skip(start) {
670            let snippet: String = text.chars().take(MAX_LINE_CHARS).collect();
671            let suffix = if text.chars().count() > MAX_LINE_CHARS {
672                " [line truncated]"
673            } else {
674                ""
675            };
676            out.push_str(&format!("{}: {snippet}{suffix}\n", index + 1));
677        }
678    }
679    out.push_str("Read the intended region and include enough surrounding text to make oldText unique before retrying.");
680    out
681}
682
683fn no_change_error(path: &str, total: usize) -> String {
684    if total == 1 {
685        format!("No changes made to {path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.")
686    } else {
687        format!("No changes made to {path}. The replacements produced identical content.")
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn normalize_to_lf_collapses_crlf_and_cr() {
697        assert_eq!(normalize_to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
698    }
699
700    #[test]
701    fn detect_line_ending_prefers_crlf_when_first() {
702        assert_eq!(detect_line_ending("a\r\nb\nc"), "\r\n");
703        assert_eq!(detect_line_ending("a\nb\r\nc"), "\n");
704        assert_eq!(detect_line_ending("noslash"), "\n");
705    }
706
707    #[test]
708    fn strip_bom_removes_prefix() {
709        let r = strip_bom("\u{FEFF}hello");
710        assert_eq!(r.bom, "\u{FEFF}");
711        assert_eq!(r.text, "hello");
712        let r2 = strip_bom("hello");
713        assert_eq!(r2.bom, "");
714        assert_eq!(r2.text, "hello");
715    }
716
717    #[test]
718    fn fuzzy_find_exact_then_smart_quotes() {
719        let content = "it's a test";
720        let m = fuzzy_find_text(content, "it's a test");
721        assert!(m.found);
722        assert!(!m.used_fuzzy_match);
723        // Smart-quote variant.
724        let m2 = fuzzy_find_text(content, "it\u{2019}s a test");
725        assert!(m2.found);
726        assert!(m2.used_fuzzy_match);
727    }
728
729    #[test]
730    fn apply_edits_single_replacement() {
731        let content = "line1\nline2\nline3\n";
732        let edits = vec![ReplaceEdit {
733            old_text: "line2".to_string(),
734            new_text: "LINE2".to_string(),
735        }];
736        let r = apply_edits_to_normalized_content(content, &edits, "/x").unwrap();
737        assert!(r.new_content.contains("LINE2"));
738        assert!(!r.new_content.contains("line2"));
739    }
740
741    #[test]
742    fn apply_edits_not_found_errors() {
743        let content = "line1\nline2\n";
744        let edits = vec![ReplaceEdit {
745            old_text: "nope".to_string(),
746            new_text: "yep".to_string(),
747        }];
748        let r = apply_edits_to_normalized_content(content, &edits, "/x");
749        assert!(r.is_err());
750        assert_eq!(r.unwrap_err().code, crate::error::FileErrorCode::NotFound);
751    }
752
753    #[test]
754    fn apply_edits_duplicate_errors() {
755        let content = "dup\nmid\ndup\n";
756        let edits = vec![ReplaceEdit {
757            old_text: "dup".to_string(),
758            new_text: "x".to_string(),
759        }];
760        let r = apply_edits_to_normalized_content(content, &edits, "/x");
761        assert!(r.is_err());
762        let error = r.unwrap_err();
763        assert_eq!(error.code, crate::error::FileErrorCode::Invalid);
764        let message = error.to_string();
765        assert!(message.contains("Match 1 at line 1"));
766        assert!(message.contains("Match 2 at line 3"));
767        assert!(message.contains("2: mid"));
768    }
769
770    #[test]
771    fn duplicate_diagnostics_preserve_original_unicode_and_batch_index() {
772        let edits = vec![
773            ReplaceEdit {
774                old_text: "heading".into(),
775                new_text: "title".into(),
776            },
777            ReplaceEdit {
778                old_text: "it's".into(),
779                new_text: "changed".into(),
780            },
781        ];
782        let error = apply_edits_to_normalized_content(
783            "heading\n第一处 it’s\n中间\n第二处 it's\n",
784            &edits,
785            "/x",
786        )
787        .unwrap_err()
788        .to_string();
789        assert!(error.contains("2 occurrences of edits[1]"));
790        assert!(error.contains("Match 1 at line 2"));
791        assert!(error.contains("Match 2 at line 4"));
792        assert!(error.contains("2: 第一处 it’s"));
793    }
794
795    #[test]
796    fn duplicate_diagnostics_bound_matches_and_long_unicode_lines() {
797        let content = format!("{} repeated\n", "界".repeat(1000)).repeat(20);
798        let context = duplicate_match_context(&content, "repeated");
799        assert!(context.contains("Match 5 at line 5"));
800        assert!(!context.contains("Match 6"));
801        assert!(context.contains("More matches omitted"));
802        assert!(context.contains("[line truncated]"));
803        assert!(context.len() < 12_000);
804    }
805
806    #[test]
807    fn apply_edits_overlap_errors() {
808        let content = "overlap region here\n";
809        let edits = vec![
810            ReplaceEdit {
811                old_text: "overlap region".to_string(),
812                new_text: "A".to_string(),
813            },
814            ReplaceEdit {
815                old_text: "region here".to_string(),
816                new_text: "B".to_string(),
817            },
818        ];
819        let r = apply_edits_to_normalized_content(content, &edits, "/x");
820        assert!(r.is_err());
821        let msg = r.unwrap_err().to_string();
822        assert!(msg.contains("overlap"));
823    }
824
825    #[test]
826    fn apply_edits_no_change_errors() {
827        let content = "same\n";
828        let edits = vec![ReplaceEdit {
829            old_text: "same".to_string(),
830            new_text: "same".to_string(),
831        }];
832        let r = apply_edits_to_normalized_content(content, &edits, "/x");
833        assert!(r.is_err());
834        let msg = r.unwrap_err().to_string();
835        assert!(msg.contains("No changes"));
836    }
837
838    #[test]
839    fn generate_unified_patch_has_headers() {
840        let patch = generate_unified_patch("/x", "a\nb\n", "a\nB\n");
841        assert!(patch.contains("--- /x"));
842        assert!(patch.contains("+++ /x"));
843        assert!(patch.contains("-b"));
844        assert!(patch.contains("+B"));
845    }
846}