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                duplicate_error(path, i, total, occurrences),
227            )
228            .with_path(path));
229        }
230        matched_edits.push(MatchedEdit {
231            edit_index: i,
232            match_index: m.index,
233            match_length: m.match_length,
234            new_text: e.new_text.clone(),
235        });
236    }
237
238    // Sort by match_index, check overlap.
239    matched_edits.sort_by_key(|m| m.match_index);
240    for pair in matched_edits.windows(2) {
241        let prev = &pair[0];
242        let cur = &pair[1];
243        if prev.match_index + prev.match_length > cur.match_index {
244            return Err(FileError::new(
245                crate::error::FileErrorCode::Invalid,
246                format!(
247                    "edits[{}] and edits[{}] overlap in {}. Merge them into one edit or target disjoint regions.",
248                    prev.edit_index, cur.edit_index, path
249                ),
250            )
251            .with_path(path));
252        }
253    }
254
255    let base_content = normalized_content.to_string();
256    let new_content = if used_fuzzy_match {
257        apply_replacements_preserving_unchanged_lines(
258            normalized_content,
259            &replacement_base_content,
260            &matched_edits,
261        )?
262    } else {
263        apply_replacements(&replacement_base_content, &matched_edits)
264    };
265
266    if base_content == new_content {
267        return Err(FileError::new(
268            crate::error::FileErrorCode::Invalid,
269            no_change_error(path, total),
270        )
271        .with_path(path));
272    }
273
274    Ok(ApplyResult {
275        base_content,
276        new_content,
277        used_fuzzy_match,
278    })
279}
280
281/// Splice replacements in reverse match-index order. Mirrors `applyReplacements`.
282fn apply_replacements(content: &str, replacements: &[MatchedEdit]) -> String {
283    let mut result = content.to_string();
284    let mut sorted = replacements.to_vec();
285    sorted.sort_by(|a, b| b.match_index.cmp(&a.match_index));
286    for r in sorted {
287        let start = r.match_index;
288        let end = start + r.match_length;
289        if end <= result.len() {
290            result.replace_range(start..end, &r.new_text);
291        }
292    }
293    result
294}
295
296/// Preserve original (non-normalized) bytes on untouched lines. Mirrors
297/// `applyReplacementsPreservingUnchangedLines`.
298fn apply_replacements_preserving_unchanged_lines(
299    original_content: &str,
300    base_content: &str,
301    replacements: &[MatchedEdit],
302) -> Result<String, FileError> {
303    let original_lines = split_lines_with_endings(original_content);
304    let base_lines = get_line_spans(base_content);
305    if original_lines.len() != base_lines.len() {
306        return Err(FileError::new(
307            crate::error::FileErrorCode::Invalid,
308            "Cannot preserve unchanged lines because the base content has a different line count.",
309        ));
310    }
311    // Group replacements by line range.
312    let mut sorted = replacements.to_vec();
313    sorted.sort_by(|a, b| a.match_index.cmp(&b.match_index));
314    let mut groups: Vec<(usize, usize, Vec<MatchedEdit>)> = Vec::new();
315    for r in &sorted {
316        let range =
317            get_replacement_line_range(&base_lines, r.match_index, r.match_index + r.match_length);
318        if let Some(last) = groups.last_mut() {
319            if range.start_line < last.1 {
320                last.1 = last.1.max(range.end_line);
321                last.2.push(r.clone());
322                continue;
323            }
324        }
325        groups.push((range.start_line, range.end_line, vec![r.clone()]));
326    }
327
328    let mut out = String::new();
329    let mut original_line_idx = 0usize;
330    for (start_line, end_line, reps) in &groups {
331        // Append unchanged leading lines.
332        if original_line_idx < *start_line {
333            out.push_str(&original_lines[original_line_idx..*start_line].join(""));
334        }
335        // Apply replacements to the base slice for this line group.
336        let group_start_offset = base_lines[*start_line].0;
337        let group_end_offset = base_lines[*end_line - 1].1;
338        let group_slice = &base_content[group_start_offset..group_end_offset];
339        // Offset replacements' match_index by -group_start_offset.
340        let offset_reps: Vec<MatchedEdit> = reps
341            .iter()
342            .map(|r| MatchedEdit {
343                edit_index: r.edit_index,
344                match_index: r.match_index - group_start_offset,
345                match_length: r.match_length,
346                new_text: r.new_text.clone(),
347            })
348            .collect();
349        out.push_str(&apply_replacements(group_slice, &offset_reps));
350        original_line_idx = *end_line;
351    }
352    // Append remaining unchanged lines.
353    if original_line_idx < original_lines.len() {
354        out.push_str(&original_lines[original_line_idx..].join(""));
355    }
356    Ok(out)
357}
358
359/// Split content into lines, each preserving its trailing `\n` (or the final
360/// line without one). Mirrors `splitLinesWithEndings`.
361fn split_lines_with_endings(content: &str) -> Vec<String> {
362    let mut out = Vec::new();
363    let mut acc = String::new();
364    for c in content.chars() {
365        acc.push(c);
366        if c == '\n' {
367            out.push(std::mem::take(&mut acc));
368        }
369    }
370    if !acc.is_empty() {
371        out.push(acc);
372    }
373    out
374}
375
376/// `(start, end)` byte-offset spans per line. Mirrors `getLineSpans`.
377fn get_line_spans(content: &str) -> Vec<(usize, usize)> {
378    let mut spans = Vec::new();
379    let mut start = 0usize;
380    for (i, c) in content.char_indices() {
381        if c == '\n' {
382            spans.push((start, i + 1));
383            start = i + 1;
384        }
385    }
386    if start < content.len() {
387        spans.push((start, content.len()));
388    }
389    spans
390}
391
392struct LineRange {
393    start_line: usize,
394    end_line: usize,
395}
396
397/// Find the line range covering `[start_offset, end_offset)`. Mirrors
398/// `getReplacementLineRange`. `end_line` is exclusive.
399fn get_replacement_line_range(
400    lines: &[(usize, usize)],
401    start_offset: usize,
402    end_offset: usize,
403) -> LineRange {
404    let mut start_line = 0usize;
405    for (i, span) in lines.iter().enumerate() {
406        if start_offset >= span.0 && start_offset < span.1 {
407            start_line = i;
408            break;
409        }
410        start_line = i + 1;
411    }
412    let mut end_line = start_line + 1;
413    while end_line < lines.len() && lines[end_line - 1].1 < end_offset {
414        end_line += 1;
415    }
416    LineRange {
417        start_line,
418        end_line,
419    }
420}
421
422/// `{ diff, first_changed_line }`. Mirrors `generateDiffString`.
423pub struct DiffResult {
424    pub diff: String,
425    pub first_changed_line: Option<usize>,
426}
427
428/// A display-oriented diff (NOT a unified patch) with line-number prefixes.
429/// Mirrors `generateDiffString` with a 4-line context window.
430pub fn generate_diff_string(
431    old_content: &str,
432    new_content: &str,
433    context_lines: usize,
434) -> DiffResult {
435    let parts = TextDiff::from_lines(old_content, new_content);
436    let ops: Vec<_> = parts.ops().to_vec();
437    let old_lines: Vec<&str> = old_content.split('\n').collect();
438    let new_lines: Vec<&str> = new_content.split('\n').collect();
439    let max_line_num = old_lines.len().max(new_lines.len());
440    let line_num_width = max_line_num.to_string().len();
441
442    let mut out: Vec<String> = Vec::new();
443    let mut old_line_num = 1usize;
444    let mut new_line_num = 1usize;
445    let mut last_was_change = false;
446    let mut first_changed_line: Option<usize> = None;
447
448    for (i, op) in ops.iter().enumerate() {
449        let tag = op.tag();
450        // The raw lines for this op — sliced from the op ranges directly
451        // (mirrors TS `part.value.split("\n")` + trailing-empty pop).
452        let mut raw: Vec<&str> = match tag {
453            similar::DiffTag::Equal | similar::DiffTag::Delete => {
454                old_lines[op.old_range().start..op.old_range().end].to_vec()
455            }
456            _ => new_lines[op.new_range().start..op.new_range().end].to_vec(),
457        };
458        if raw.last().map(|s| s.is_empty()).unwrap_or(false) {
459            raw.pop();
460        }
461        let raw_len = raw.len();
462
463        let is_added = matches!(tag, similar::DiffTag::Insert | similar::DiffTag::Replace);
464        let is_removed = matches!(tag, similar::DiffTag::Delete | similar::DiffTag::Replace);
465
466        if is_added || is_removed {
467            if first_changed_line.is_none() {
468                first_changed_line = Some(new_line_num);
469            }
470            for line in &raw {
471                if is_added {
472                    out.push(format!(
473                        "+{:>width$} {}",
474                        new_line_num,
475                        line,
476                        width = line_num_width
477                    ));
478                    new_line_num += 1;
479                }
480                if is_removed {
481                    out.push(format!(
482                        "-{:>width$} {}",
483                        old_line_num,
484                        line,
485                        width = line_num_width
486                    ));
487                    old_line_num += 1;
488                }
489            }
490            // A `Replace` op iterates both add and remove above; for pure
491            // Insert/Delete only one branch fires. Leave last_was_change set.
492            last_was_change = true;
493            let _ = i; // index only needed for context lookahead below
494        } else {
495            // Equal → context lines, show a few before/after changes.
496            let next_is_change = i + 1 < ops.len()
497                && matches!(
498                    ops[i + 1].tag(),
499                    similar::DiffTag::Insert | similar::DiffTag::Delete | similar::DiffTag::Replace
500                );
501            let has_leading_change = last_was_change;
502            let has_trailing_change = next_is_change;
503
504            if has_leading_change && has_trailing_change {
505                if raw_len <= context_lines * 2 {
506                    for line in &raw {
507                        out.push(format!(
508                            " {:>width$} {}",
509                            old_line_num,
510                            line,
511                            width = line_num_width
512                        ));
513                        old_line_num += 1;
514                        new_line_num += 1;
515                    }
516                } else {
517                    let leading = &raw[..context_lines];
518                    let trailing = &raw[raw_len - context_lines..];
519                    let skipped = raw_len - leading.len() - trailing.len();
520                    for line in leading {
521                        out.push(format!(
522                            " {:>width$} {}",
523                            old_line_num,
524                            line,
525                            width = line_num_width
526                        ));
527                        old_line_num += 1;
528                        new_line_num += 1;
529                    }
530                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
531                    old_line_num += skipped;
532                    new_line_num += skipped;
533                    for line in trailing {
534                        out.push(format!(
535                            " {:>width$} {}",
536                            old_line_num,
537                            line,
538                            width = line_num_width
539                        ));
540                        old_line_num += 1;
541                        new_line_num += 1;
542                    }
543                }
544            } else if has_leading_change {
545                let shown = if raw_len <= context_lines {
546                    &raw[..]
547                } else {
548                    &raw[..context_lines]
549                };
550                let skipped = raw_len - shown.len();
551                for line in shown {
552                    out.push(format!(
553                        " {:>width$} {}",
554                        old_line_num,
555                        line,
556                        width = line_num_width
557                    ));
558                    old_line_num += 1;
559                    new_line_num += 1;
560                }
561                if skipped > 0 {
562                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
563                    old_line_num += skipped;
564                    new_line_num += skipped;
565                }
566            } else if has_trailing_change {
567                let skipped = raw_len.saturating_sub(context_lines);
568                if skipped > 0 {
569                    out.push(format!(" {:>width$} ...", "", width = line_num_width));
570                    old_line_num += skipped;
571                    new_line_num += skipped;
572                }
573                for line in &raw[skipped..] {
574                    out.push(format!(
575                        " {:>width$} {}",
576                        old_line_num,
577                        line,
578                        width = line_num_width
579                    ));
580                    old_line_num += 1;
581                    new_line_num += 1;
582                }
583            } else {
584                // Skip these context lines entirely.
585                old_line_num += raw_len;
586                new_line_num += raw_len;
587            }
588            last_was_change = false;
589        }
590    }
591
592    DiffResult {
593        diff: out.join("\n"),
594        first_changed_line,
595    }
596}
597
598/// A unified-diff patch with 4 lines of context, file headers only (no
599/// timestamps). Mirrors `generateUnifiedPatch` (via the `similar` crate, which
600/// is the Rust equivalent of the npm `diff` package).
601pub fn generate_unified_patch(path: &str, old_content: &str, new_content: &str) -> String {
602    // `similar::udiff::unified_diff(alg, old, new, context_radius, header)`
603    // where `header: Option<(&str,&str)>` carries the (old_label, new_label)
604    // file headers (`--- ` / `+++ `). No timestamps, matching the TS port.
605    unified_diff(
606        Algorithm::Myers,
607        old_content,
608        new_content,
609        4,
610        Some((path, path)),
611    )
612}
613
614// --- error-message builders (mirror the TS single-vs-multi variants) ---
615
616fn empty_old_text_error(path: &str, i: usize, total: usize) -> String {
617    if total == 1 {
618        format!("oldText must not be empty in {path}.")
619    } else {
620        format!("edits[{i}].oldText must not be empty in {path}.")
621    }
622}
623
624fn not_found_error(path: &str, i: usize, total: usize) -> String {
625    if total == 1 {
626        format!("Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines.")
627    } else {
628        format!("Could not find edits[{i}] in {path}. The old text must match exactly including all whitespace and newlines.")
629    }
630}
631
632fn duplicate_error(path: &str, i: usize, total: usize, occurrences: usize) -> String {
633    if total == 1 {
634        format!("Found {occurrences} occurrences of the text in {path}. The text must be unique — include more surrounding context to disambiguate.")
635    } else {
636        format!("Found {occurrences} occurrences of edits[{i}] in {path}. The text must be unique — include more surrounding context to disambiguate.")
637    }
638}
639
640fn no_change_error(path: &str, total: usize) -> String {
641    if total == 1 {
642        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.")
643    } else {
644        format!("No changes made to {path}. The replacements produced identical content.")
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn normalize_to_lf_collapses_crlf_and_cr() {
654        assert_eq!(normalize_to_lf("a\r\nb\rc\n"), "a\nb\nc\n");
655    }
656
657    #[test]
658    fn detect_line_ending_prefers_crlf_when_first() {
659        assert_eq!(detect_line_ending("a\r\nb\nc"), "\r\n");
660        assert_eq!(detect_line_ending("a\nb\r\nc"), "\n");
661        assert_eq!(detect_line_ending("noslash"), "\n");
662    }
663
664    #[test]
665    fn strip_bom_removes_prefix() {
666        let r = strip_bom("\u{FEFF}hello");
667        assert_eq!(r.bom, "\u{FEFF}");
668        assert_eq!(r.text, "hello");
669        let r2 = strip_bom("hello");
670        assert_eq!(r2.bom, "");
671        assert_eq!(r2.text, "hello");
672    }
673
674    #[test]
675    fn fuzzy_find_exact_then_smart_quotes() {
676        let content = "it's a test";
677        let m = fuzzy_find_text(content, "it's a test");
678        assert!(m.found);
679        assert!(!m.used_fuzzy_match);
680        // Smart-quote variant.
681        let m2 = fuzzy_find_text(content, "it\u{2019}s a test");
682        assert!(m2.found);
683        assert!(m2.used_fuzzy_match);
684    }
685
686    #[test]
687    fn apply_edits_single_replacement() {
688        let content = "line1\nline2\nline3\n";
689        let edits = vec![ReplaceEdit {
690            old_text: "line2".to_string(),
691            new_text: "LINE2".to_string(),
692        }];
693        let r = apply_edits_to_normalized_content(content, &edits, "/x").unwrap();
694        assert!(r.new_content.contains("LINE2"));
695        assert!(!r.new_content.contains("line2"));
696    }
697
698    #[test]
699    fn apply_edits_not_found_errors() {
700        let content = "line1\nline2\n";
701        let edits = vec![ReplaceEdit {
702            old_text: "nope".to_string(),
703            new_text: "yep".to_string(),
704        }];
705        let r = apply_edits_to_normalized_content(content, &edits, "/x");
706        assert!(r.is_err());
707        assert_eq!(r.unwrap_err().code, crate::error::FileErrorCode::NotFound);
708    }
709
710    #[test]
711    fn apply_edits_duplicate_errors() {
712        let content = "dup\nmid\ndup\n";
713        let edits = vec![ReplaceEdit {
714            old_text: "dup".to_string(),
715            new_text: "x".to_string(),
716        }];
717        let r = apply_edits_to_normalized_content(content, &edits, "/x");
718        assert!(r.is_err());
719        assert_eq!(r.unwrap_err().code, crate::error::FileErrorCode::Invalid);
720    }
721
722    #[test]
723    fn apply_edits_overlap_errors() {
724        let content = "overlap region here\n";
725        let edits = vec![
726            ReplaceEdit {
727                old_text: "overlap region".to_string(),
728                new_text: "A".to_string(),
729            },
730            ReplaceEdit {
731                old_text: "region here".to_string(),
732                new_text: "B".to_string(),
733            },
734        ];
735        let r = apply_edits_to_normalized_content(content, &edits, "/x");
736        assert!(r.is_err());
737        let msg = r.unwrap_err().to_string();
738        assert!(msg.contains("overlap"));
739    }
740
741    #[test]
742    fn apply_edits_no_change_errors() {
743        let content = "same\n";
744        let edits = vec![ReplaceEdit {
745            old_text: "same".to_string(),
746            new_text: "same".to_string(),
747        }];
748        let r = apply_edits_to_normalized_content(content, &edits, "/x");
749        assert!(r.is_err());
750        let msg = r.unwrap_err().to_string();
751        assert!(msg.contains("No changes"));
752    }
753
754    #[test]
755    fn generate_unified_patch_has_headers() {
756        let patch = generate_unified_patch("/x", "a\nb\n", "a\nB\n");
757        assert!(patch.contains("--- /x"));
758        assert!(patch.contains("+++ /x"));
759        assert!(patch.contains("-b"));
760        assert!(patch.contains("+B"));
761    }
762}