1use similar::{udiff::unified_diff, Algorithm, TextDiff};
10
11use crate::error::FileError;
12
13pub struct BomResult<'a> {
15 pub bom: &'static str,
16 pub text: &'a str,
17}
18
19pub 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
36pub 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
55pub fn normalize_to_lf(text: &str) -> String {
57 text.replace("\r\n", "\n").replace('\r', "\n")
58}
59
60pub 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
69pub fn normalize_for_fuzzy_match(text: &str) -> String {
72 use unicode_normalization::UnicodeNormalization;
73 let nfkc: String = text.nfkc().collect();
74 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#[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 pub content_for_replacement: String,
105}
106
107pub 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
139pub 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#[derive(Clone)]
152pub struct ReplaceEdit {
153 pub old_text: String,
154 pub new_text: String,
155}
156
157#[derive(Clone)]
159struct MatchedEdit {
160 edit_index: usize,
161 match_index: usize,
162 match_length: usize,
163 new_text: String,
164}
165
166#[derive(Debug)]
168pub struct ApplyResult {
169 pub base_content: String,
170 pub new_content: String,
171 pub used_fuzzy_match: bool,
172}
173
174pub 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 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 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
285fn 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
300fn 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 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 if original_line_idx < *start_line {
337 out.push_str(&original_lines[original_line_idx..*start_line].join(""));
338 }
339 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 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 if original_line_idx < original_lines.len() {
358 out.push_str(&original_lines[original_line_idx..].join(""));
359 }
360 Ok(out)
361}
362
363fn 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
380fn 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
401fn 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
426pub struct DiffResult {
428 pub diff: String,
429 pub first_changed_line: Option<usize>,
430}
431
432pub 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 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 last_was_change = true;
497 let _ = i; } else {
499 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 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
602pub fn generate_unified_patch(path: &str, old_content: &str, new_content: &str) -> String {
606 unified_diff(
610 Algorithm::Myers,
611 old_content,
612 new_content,
613 4,
614 Some((path, path)),
615 )
616}
617
618fn 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
644fn 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 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}