1use serde::{Deserialize, Serialize};
8use unicode_normalization::UnicodeNormalization;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum LineEnding {
13 Lf,
15 Crlf,
17}
18
19impl LineEnding {
20 #[must_use]
22 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Lf => "\n",
25 Self::Crlf => "\r\n",
26 }
27 }
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Edit {
34 pub old_text: String,
36 pub new_text: String,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct AppliedEditsResult {
43 pub base_content: String,
45 pub new_content: String,
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct DiffStringResult {
52 pub diff: String,
54 pub first_changed_line: Option<usize>,
56}
57
58#[must_use]
60pub fn detect_line_ending(content: &str) -> LineEnding {
61 let crlf_idx = content.find("\r\n");
62 let lf_idx = content.find('\n');
63 match (crlf_idx, lf_idx) {
64 (None, None | Some(_)) => LineEnding::Lf,
65 (Some(_), None) => LineEnding::Crlf,
66 (Some(crlf), Some(lf)) => {
67 if crlf < lf {
68 LineEnding::Crlf
69 } else {
70 LineEnding::Lf
71 }
72 }
73 }
74}
75
76#[must_use]
78pub fn normalize_to_lf(text: &str) -> String {
79 text.replace("\r\n", "\n").replace('\r', "\n")
80}
81
82#[must_use]
84pub fn restore_line_endings(text: &str, ending: LineEnding) -> String {
85 match ending {
86 LineEnding::Lf => text.to_owned(),
87 LineEnding::Crlf => text.replace('\n', "\r\n"),
88 }
89}
90
91#[must_use]
95pub fn strip_bom(content: &str) -> (String, String) {
96 if let Some(rest) = content.strip_prefix('\u{FEFF}') {
97 ("\u{FEFF}".to_owned(), rest.to_owned())
98 } else {
99 (String::new(), content.to_owned())
100 }
101}
102
103#[must_use]
105pub fn normalize_for_fuzzy_match(text: &str) -> String {
106 let nfkc: String = text.nfkc().collect();
107 let trimmed: String = nfkc
108 .split('\n')
109 .map(str::trim_end)
110 .collect::<Vec<_>>()
111 .join("\n");
112 let mut out = String::with_capacity(trimmed.len());
113 for ch in trimmed.chars() {
114 match ch {
115 '\u{2018}' | '\u{2019}' | '\u{201A}' | '\u{201B}' => out.push('\''),
116 '\u{201C}' | '\u{201D}' | '\u{201E}' | '\u{201F}' => out.push('"'),
117 '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2014}' | '\u{2015}'
118 | '\u{2212}' => out.push('-'),
119 '\u{00A0}' | '\u{2002}' | '\u{2003}' | '\u{2004}' | '\u{2005}' | '\u{2006}'
120 | '\u{2007}' | '\u{2008}' | '\u{2009}' | '\u{200A}' | '\u{202F}' | '\u{205F}'
121 | '\u{3000}' => out.push(' '),
122 other => out.push(other),
123 }
124 }
125 out
126}
127
128#[derive(Clone, Debug)]
129struct FuzzyMatchResult {
130 found: bool,
131 index: usize,
132 match_length: usize,
133 used_fuzzy_match: bool,
134}
135
136fn fuzzy_find_text(content: &str, old_text: &str) -> FuzzyMatchResult {
137 if let Some(exact_index) = content.find(old_text) {
138 return FuzzyMatchResult {
139 found: true,
140 index: exact_index,
141 match_length: old_text.len(),
142 used_fuzzy_match: false,
143 };
144 }
145
146 let fuzzy_content = normalize_for_fuzzy_match(content);
147 let fuzzy_old = normalize_for_fuzzy_match(old_text);
148 if let Some(fuzzy_index) = fuzzy_content.find(&fuzzy_old) {
149 return FuzzyMatchResult {
150 found: true,
151 index: fuzzy_index,
152 match_length: fuzzy_old.len(),
153 used_fuzzy_match: true,
154 };
155 }
156
157 FuzzyMatchResult {
158 found: false,
159 index: 0,
160 match_length: 0,
161 used_fuzzy_match: false,
162 }
163}
164
165fn count_occurrences(content: &str, old_text: &str) -> usize {
167 let fuzzy_content = normalize_for_fuzzy_match(content);
168 let fuzzy_old = normalize_for_fuzzy_match(old_text);
169 if fuzzy_old.is_empty() {
170 return 0;
171 }
172 fuzzy_content
173 .split(fuzzy_old.as_str())
174 .count()
175 .saturating_sub(1)
176}
177
178fn empty_old_text_error(path: &str, edit_index: usize, total_edits: usize) -> String {
179 if total_edits == 1 {
180 format!("oldText must not be empty in {path}.")
181 } else {
182 format!("edits[{edit_index}].oldText must not be empty in {path}.")
183 }
184}
185
186fn not_found_error(path: &str, edit_index: usize, total_edits: usize) -> String {
187 if total_edits == 1 {
188 format!(
189 "Could not find the exact text in {path}. The old text must match exactly including all whitespace and newlines."
190 )
191 } else {
192 format!(
193 "Could not find edits[{edit_index}] in {path}. The oldText must match exactly including all whitespace and newlines."
194 )
195 }
196}
197
198fn duplicate_error(
199 path: &str,
200 edit_index: usize,
201 total_edits: usize,
202 occurrences: usize,
203) -> String {
204 if total_edits == 1 {
205 format!(
206 "Found {occurrences} occurrences of the text in {path}. The text must be unique. Please provide more context to make it unique."
207 )
208 } else {
209 format!(
210 "Found {occurrences} occurrences of edits[{edit_index}] in {path}. Each oldText must be unique. Please provide more context to make it unique."
211 )
212 }
213}
214
215fn no_change_error(path: &str, total_edits: usize) -> String {
216 if total_edits == 1 {
217 format!(
218 "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."
219 )
220 } else {
221 format!("No changes made to {path}. The replacements produced identical content.")
222 }
223}
224
225#[derive(Clone, Debug)]
226struct MatchedEdit {
227 edit_index: usize,
228 match_index: usize,
229 match_length: usize,
230 new_text: String,
231}
232
233#[derive(Clone, Copy, Debug)]
234struct LineSpan {
235 start: usize,
236 end: usize,
237}
238
239fn split_lines_with_endings(content: &str) -> Vec<String> {
240 if content.is_empty() {
241 return Vec::new();
242 }
243 let mut lines = Vec::new();
244 let mut start = 0usize;
245 let bytes = content.as_bytes();
246 for (idx, byte) in bytes.iter().enumerate() {
247 if *byte == b'\n' {
248 lines.push(content[start..=idx].to_owned());
249 start = idx + 1;
250 }
251 }
252 if start < content.len() {
253 lines.push(content[start..].to_owned());
254 }
255 lines
256}
257
258fn get_line_spans(content: &str) -> Vec<LineSpan> {
259 let mut offset = 0usize;
260 split_lines_with_endings(content)
261 .into_iter()
262 .map(|line| {
263 let span = LineSpan {
264 start: offset,
265 end: offset + line.len(),
266 };
267 offset = span.end;
268 span
269 })
270 .collect()
271}
272
273fn get_replacement_line_range(
274 lines: &[LineSpan],
275 match_index: usize,
276 match_length: usize,
277) -> Result<(usize, usize), String> {
278 let replacement_start = match_index;
279 let replacement_end = match_index + match_length;
280
281 let mut start_line = None;
282 for (i, line) in lines.iter().enumerate() {
283 if replacement_start >= line.start && replacement_start < line.end {
284 start_line = Some(i);
285 break;
286 }
287 }
288 let start_line =
289 start_line.ok_or_else(|| "Replacement range is outside the base content.".to_owned())?;
290
291 let mut end_line = start_line;
292 while end_line < lines.len() && lines[end_line].end < replacement_end {
293 end_line += 1;
294 }
295 if end_line >= lines.len() {
296 return Err("Replacement range is outside the base content.".to_owned());
297 }
298 Ok((start_line, end_line + 1))
299}
300
301fn apply_replacements(content: &str, replacements: &[MatchedEdit], offset: usize) -> String {
302 let mut result = content.to_owned();
303 for replacement in replacements.iter().rev() {
304 let match_index = replacement.match_index.saturating_sub(offset);
305 let end = match_index + replacement.match_length;
306 if match_index > result.len() || end > result.len() {
307 continue;
308 }
309 result.replace_range(match_index..end, &replacement.new_text);
310 }
311 result
312}
313
314#[derive(Clone, Debug)]
315struct ReplacementGroup {
316 start_line: usize,
317 end_line: usize,
318 replacements: Vec<MatchedEdit>,
319}
320
321fn apply_replacements_preserving_unchanged_lines(
324 original_content: &str,
325 base_content: &str,
326 replacements: &[MatchedEdit],
327) -> Result<String, String> {
328 let original_lines = split_lines_with_endings(original_content);
329 let base_lines = get_line_spans(base_content);
330 if original_lines.len() != base_lines.len() {
331 return Err(
332 "Cannot preserve unchanged lines because the base content has a different line count."
333 .to_owned(),
334 );
335 }
336
337 let mut groups: Vec<ReplacementGroup> = Vec::new();
338 let mut sorted = replacements.to_vec();
339 sorted.sort_by_key(|item| item.match_index);
340 for replacement in sorted {
341 let (start_line, end_line) = get_replacement_line_range(
342 &base_lines,
343 replacement.match_index,
344 replacement.match_length,
345 )?;
346 if let Some(current) = groups.last_mut()
347 && start_line < current.end_line
348 {
349 current.end_line = current.end_line.max(end_line);
350 current.replacements.push(replacement);
351 continue;
352 }
353 groups.push(ReplacementGroup {
354 start_line,
355 end_line,
356 replacements: vec![replacement],
357 });
358 }
359
360 let mut original_line_index = 0usize;
361 let mut result = String::new();
362 for group in groups {
363 for line in &original_lines[original_line_index..group.start_line] {
364 result.push_str(line);
365 }
366 let group_start_offset = base_lines[group.start_line].start;
367 let group_end_offset = base_lines[group.end_line - 1].end;
368 let slice = &base_content[group_start_offset..group_end_offset];
369 result.push_str(&apply_replacements(
370 slice,
371 &group.replacements,
372 group_start_offset,
373 ));
374 original_line_index = group.end_line;
375 }
376 for line in &original_lines[original_line_index..] {
377 result.push_str(line);
378 }
379 Ok(result)
380}
381
382pub fn apply_edits_to_normalized_content(
389 normalized_content: &str,
390 edits: &[Edit],
391 path: &str,
392) -> Result<AppliedEditsResult, String> {
393 let normalized_edits: Vec<Edit> = edits
394 .iter()
395 .map(|edit| Edit {
396 old_text: normalize_to_lf(&edit.old_text),
397 new_text: normalize_to_lf(&edit.new_text),
398 })
399 .collect();
400
401 let total = normalized_edits.len();
402 for (i, edit) in normalized_edits.iter().enumerate() {
403 if edit.old_text.is_empty() {
404 return Err(empty_old_text_error(path, i, total));
405 }
406 }
407
408 let initial_matches: Vec<FuzzyMatchResult> = normalized_edits
409 .iter()
410 .map(|edit| fuzzy_find_text(normalized_content, &edit.old_text))
411 .collect();
412 let used_fuzzy_match = initial_matches.iter().any(|m| m.used_fuzzy_match);
413 let replacement_base_content = if used_fuzzy_match {
414 normalize_for_fuzzy_match(normalized_content)
415 } else {
416 normalized_content.to_owned()
417 };
418
419 let mut matched_edits: Vec<MatchedEdit> = Vec::with_capacity(total);
420 for (i, edit) in normalized_edits.iter().enumerate() {
421 let match_result = fuzzy_find_text(&replacement_base_content, &edit.old_text);
422 if !match_result.found {
423 return Err(not_found_error(path, i, total));
424 }
425 let occurrences = count_occurrences(&replacement_base_content, &edit.old_text);
426 if occurrences > 1 {
427 return Err(duplicate_error(path, i, total, occurrences));
428 }
429 matched_edits.push(MatchedEdit {
430 edit_index: i,
431 match_index: match_result.index,
432 match_length: match_result.match_length,
433 new_text: edit.new_text.clone(),
434 });
435 }
436
437 matched_edits.sort_by_key(|item| item.match_index);
438 for i in 1..matched_edits.len() {
439 let previous = &matched_edits[i - 1];
440 let current = &matched_edits[i];
441 if previous.match_index + previous.match_length > current.match_index {
442 return Err(format!(
443 "edits[{}] and edits[{}] overlap in {path}. Merge them into one edit or target disjoint regions.",
444 previous.edit_index, current.edit_index
445 ));
446 }
447 }
448
449 let base_content = normalized_content.to_owned();
450 let new_content = if used_fuzzy_match {
451 apply_replacements_preserving_unchanged_lines(
452 normalized_content,
453 &replacement_base_content,
454 &matched_edits,
455 )?
456 } else {
457 apply_replacements(&replacement_base_content, &matched_edits, 0)
458 };
459
460 if base_content == new_content {
461 return Err(no_change_error(path, total));
462 }
463
464 Ok(AppliedEditsResult {
465 base_content,
466 new_content,
467 })
468}
469
470fn split_lines_keep_trailing_empty(content: &str) -> Vec<&str> {
471 content.split('\n').collect()
472}
473
474fn split_patch_lines(content: &str) -> Vec<&str> {
475 if content.is_empty() {
476 Vec::new()
477 } else {
478 content.split_terminator('\n').collect()
479 }
480}
481
482#[derive(Clone, Debug)]
483struct PatchLine {
484 prefix: char,
485 text: String,
486}
487
488impl PatchLine {
489 const fn is_context(&self) -> bool {
490 self.prefix == ' '
491 }
492
493 fn old_increment(&self) -> usize {
494 usize::from(self.prefix != '+')
495 }
496
497 fn new_increment(&self) -> usize {
498 usize::from(self.prefix != '-')
499 }
500}
501
502fn patch_lines(old_content: &str, new_content: &str) -> Vec<PatchLine> {
503 let old_lines = split_patch_lines(old_content);
504 let new_lines = split_patch_lines(new_content);
505 let lcs = longest_common_subsequence(&old_lines, &new_lines);
506 let mut ops = Vec::new();
507 let mut old_index = 0;
508 let mut new_index = 0;
509 let mut common_index = 0;
510
511 while old_index < old_lines.len() || new_index < new_lines.len() {
512 if common_index < lcs.len()
513 && old_index == lcs[common_index].0
514 && new_index == lcs[common_index].1
515 {
516 ops.push(Op::Equal(old_lines[old_index].to_owned()));
517 old_index += 1;
518 new_index += 1;
519 common_index += 1;
520 } else if new_index < new_lines.len()
521 && (common_index >= lcs.len() || new_index < lcs[common_index].1)
522 {
523 ops.push(Op::Added(new_lines[new_index].to_owned()));
524 new_index += 1;
525 } else if old_index < old_lines.len() {
526 ops.push(Op::Removed(old_lines[old_index].to_owned()));
527 old_index += 1;
528 }
529 }
530
531 let mut lines = Vec::new();
532 for part in collapse_ops(&ops) {
533 match part {
534 DiffPart::Equal(equal) => lines.extend(
535 equal
536 .into_iter()
537 .map(|text| PatchLine { prefix: ' ', text }),
538 ),
539 DiffPart::Change { removed, added } => {
540 lines.extend(
541 removed
542 .into_iter()
543 .map(|text| PatchLine { prefix: '-', text }),
544 );
545 lines.extend(
546 added
547 .into_iter()
548 .map(|text| PatchLine { prefix: '+', text }),
549 );
550 }
551 }
552 }
553 lines
554}
555
556fn context_boundary(
557 lines: &[PatchLine],
558 from: usize,
559 context_lines: usize,
560 reverse: bool,
561) -> usize {
562 let mut index = from;
563 let mut context = 0;
564 while if reverse {
565 index > 0
566 } else {
567 index < lines.len()
568 } {
569 let candidate = if reverse { index - 1 } else { index };
570 if lines[candidate].is_context() {
571 if context == context_lines {
572 break;
573 }
574 context += 1;
575 }
576 index = if reverse { candidate } else { candidate + 1 };
577 }
578 index
579}
580
581#[must_use]
583pub fn generate_unified_patch(
584 path: &str,
585 old_content: &str,
586 new_content: &str,
587 context_lines: usize,
588) -> String {
589 let mut out = format!("--- {path}\n+++ {path}\n");
590 if old_content == new_content {
591 return out;
592 }
593
594 let lines = patch_lines(old_content, new_content);
595 let changes: Vec<usize> = lines
596 .iter()
597 .enumerate()
598 .filter_map(|(index, line)| (!line.is_context()).then_some(index))
599 .collect();
600 let mut groups = Vec::<(usize, usize)>::new();
601 let mut first = changes[0];
602 let mut last = first;
603 for &change in &changes[1..] {
604 let unchanged_between = lines[last + 1..change]
605 .iter()
606 .filter(|line| line.is_context())
607 .count();
608 if unchanged_between > context_lines.saturating_mul(2) {
609 groups.push((first, last));
610 first = change;
611 }
612 last = change;
613 }
614 groups.push((first, last));
615
616 for (first_change, last_change) in groups {
617 let start = context_boundary(&lines, first_change, context_lines, true);
618 let end = context_boundary(&lines, last_change + 1, context_lines, false);
619 let old_before: usize = lines[..start].iter().map(PatchLine::old_increment).sum();
620 let new_before: usize = lines[..start].iter().map(PatchLine::new_increment).sum();
621 let old_count: usize = lines[start..end].iter().map(PatchLine::old_increment).sum();
622 let new_count: usize = lines[start..end].iter().map(PatchLine::new_increment).sum();
623 let old_start = if old_count == 0 {
624 old_before
625 } else {
626 old_before + 1
627 };
628 let new_start = if new_count == 0 {
629 new_before
630 } else {
631 new_before + 1
632 };
633 out.push_str("@@ -");
634 out.push_str(&old_start.to_string());
635 out.push(',');
636 out.push_str(&old_count.to_string());
637 out.push_str(" +");
638 out.push_str(&new_start.to_string());
639 out.push(',');
640 out.push_str(&new_count.to_string());
641 out.push_str(" @@\n");
642 for line in &lines[start..end] {
643 out.push(line.prefix);
644 out.push_str(&line.text);
645 out.push('\n');
646 }
647 }
648 out
649}
650
651#[must_use]
653pub fn generate_diff_string(
654 old_content: &str,
655 new_content: &str,
656 context_lines: usize,
657) -> DiffStringResult {
658 let old_lines = split_lines_keep_trailing_empty(old_content);
659 let new_lines = split_lines_keep_trailing_empty(new_content);
660 let line_num_width = old_lines
661 .len()
662 .max(new_lines.len())
663 .max(1)
664 .to_string()
665 .len();
666 let parts = collapse_ops(&diff_ops(&old_lines, &new_lines));
667 render_diff_parts(&parts, context_lines, line_num_width)
668}
669
670fn diff_ops(old_lines: &[&str], new_lines: &[&str]) -> Vec<Op> {
671 let lcs = longest_common_subsequence(old_lines, new_lines);
672 let mut ops = Vec::new();
673 let mut old_index = 0;
674 let mut new_index = 0;
675 let mut common_index = 0;
676 while old_index < old_lines.len() || new_index < new_lines.len() {
677 if common_index < lcs.len()
678 && old_index < old_lines.len()
679 && new_index < new_lines.len()
680 && old_index == lcs[common_index].0
681 && new_index == lcs[common_index].1
682 {
683 ops.push(Op::Equal(old_lines[old_index].to_owned()));
684 old_index += 1;
685 new_index += 1;
686 common_index += 1;
687 } else if new_index < new_lines.len()
688 && (common_index >= lcs.len() || new_index < lcs[common_index].1)
689 {
690 ops.push(Op::Added(new_lines[new_index].to_owned()));
691 new_index += 1;
692 } else if old_index < old_lines.len()
693 && (common_index >= lcs.len() || old_index < lcs[common_index].0)
694 {
695 ops.push(Op::Removed(old_lines[old_index].to_owned()));
696 old_index += 1;
697 } else {
698 break;
699 }
700 }
701 ops
702}
703
704struct DiffRenderState {
705 output: Vec<String>,
706 old_line_num: usize,
707 new_line_num: usize,
708 last_was_change: bool,
709 first_changed_line: Option<usize>,
710}
711
712impl DiffRenderState {
713 fn new() -> Self {
714 Self {
715 output: Vec::new(),
716 old_line_num: 1,
717 new_line_num: 1,
718 last_was_change: false,
719 first_changed_line: None,
720 }
721 }
722
723 fn push_numbered(&mut self, prefix: char, line: &str, width: usize) {
724 let line_num = if prefix == '+' {
725 self.new_line_num
726 } else {
727 self.old_line_num
728 };
729 self.output
730 .push(format!("{prefix}{line_num:>width$} {line}"));
731 }
732
733 fn advance_both(&mut self, count: usize) {
734 self.old_line_num += count;
735 self.new_line_num += count;
736 }
737
738 fn push_gap(&mut self, width: usize) {
739 self.output.push(format!(" {:>width$} ...", ""));
740 }
741}
742
743fn render_diff_parts(
744 parts: &[DiffPart],
745 context_lines: usize,
746 line_num_width: usize,
747) -> DiffStringResult {
748 let mut state = DiffRenderState::new();
749 for (part_index, part) in parts.iter().enumerate() {
750 match part {
751 DiffPart::Change { added, removed } => {
752 state.first_changed_line.get_or_insert(state.new_line_num);
753 for line in removed {
754 state.push_numbered('-', line, line_num_width);
755 state.old_line_num += 1;
756 }
757 for line in added {
758 state.push_numbered('+', line, line_num_width);
759 state.new_line_num += 1;
760 }
761 state.last_was_change = true;
762 }
763 DiffPart::Equal(lines) => {
764 let next_is_change = parts
765 .get(part_index + 1)
766 .is_some_and(|part| matches!(part, DiffPart::Change { .. }));
767 render_equal_part(
768 &mut state,
769 lines,
770 context_lines,
771 line_num_width,
772 next_is_change,
773 );
774 state.last_was_change = false;
775 }
776 }
777 }
778 DiffStringResult {
779 diff: state.output.join("\n"),
780 first_changed_line: state.first_changed_line,
781 }
782}
783
784fn render_equal_part(
785 state: &mut DiffRenderState,
786 lines: &[String],
787 context_lines: usize,
788 line_num_width: usize,
789 next_is_change: bool,
790) {
791 match (state.last_was_change, next_is_change) {
792 (true, true) if lines.len() <= context_lines * 2 => {
793 push_context_lines(state, lines, line_num_width);
794 }
795 (true, true) => {
796 push_context_lines(state, &lines[..context_lines], line_num_width);
797 let skipped = lines.len() - context_lines * 2;
798 state.push_gap(line_num_width);
799 state.advance_both(skipped);
800 push_context_lines(state, &lines[lines.len() - context_lines..], line_num_width);
801 }
802 (true, false) => {
803 let shown = lines.len().min(context_lines);
804 push_context_lines(state, &lines[..shown], line_num_width);
805 let skipped = lines.len() - shown;
806 if skipped > 0 {
807 state.push_gap(line_num_width);
808 state.advance_both(skipped);
809 }
810 }
811 (false, true) => {
812 let skipped = lines.len().saturating_sub(context_lines);
813 if skipped > 0 {
814 state.push_gap(line_num_width);
815 state.advance_both(skipped);
816 }
817 push_context_lines(state, &lines[skipped..], line_num_width);
818 }
819 (false, false) => state.advance_both(lines.len()),
820 }
821}
822
823fn push_context_lines(state: &mut DiffRenderState, lines: &[String], line_num_width: usize) {
824 for line in lines {
825 state.push_numbered(' ', line, line_num_width);
826 state.advance_both(1);
827 }
828}
829
830#[derive(Clone, Debug)]
831enum Op {
832 Equal(String),
833 Added(String),
834 Removed(String),
835}
836
837fn longest_common_subsequence(seq_a: &[&str], seq_b: &[&str]) -> Vec<(usize, usize)> {
838 let len_a = seq_a.len();
839 let len_b = seq_b.len();
840 let mut dp = vec![vec![0usize; len_b + 1]; len_a + 1];
841 for row in 0..len_a {
842 for col in 0..len_b {
843 if seq_a[row] == seq_b[col] {
844 dp[row + 1][col + 1] = dp[row][col] + 1;
845 } else {
846 dp[row + 1][col + 1] = dp[row + 1][col].max(dp[row][col + 1]);
847 }
848 }
849 }
850 let mut out = Vec::new();
851 let mut row = len_a;
852 let mut col = len_b;
853 while row > 0 && col > 0 {
854 if seq_a[row - 1] == seq_b[col - 1] {
855 out.push((row - 1, col - 1));
856 row -= 1;
857 col -= 1;
858 } else if dp[row - 1][col] >= dp[row][col - 1] {
859 row -= 1;
860 } else {
861 col -= 1;
862 }
863 }
864 out.reverse();
865 out
866}
867
868#[derive(Clone, Debug)]
869enum DiffPart {
870 Equal(Vec<String>),
871 Change {
872 removed: Vec<String>,
873 added: Vec<String>,
874 },
875}
876
877fn collapse_ops(ops: &[Op]) -> Vec<DiffPart> {
878 let mut parts: Vec<DiffPart> = Vec::new();
879 let mut idx = 0usize;
880 while idx < ops.len() {
881 match &ops[idx] {
882 Op::Equal(s) => {
883 let mut equal = vec![s.clone()];
884 idx += 1;
885 while idx < ops.len() {
886 if let Op::Equal(next) = &ops[idx] {
887 equal.push(next.clone());
888 idx += 1;
889 } else {
890 break;
891 }
892 }
893 parts.push(DiffPart::Equal(equal));
894 }
895 Op::Added(_) | Op::Removed(_) => {
896 let mut removed = Vec::new();
897 let mut added = Vec::new();
898 while idx < ops.len() {
899 match &ops[idx] {
900 Op::Removed(s) => {
901 removed.push(s.clone());
902 idx += 1;
903 }
904 Op::Added(s) => {
905 added.push(s.clone());
906 idx += 1;
907 }
908 Op::Equal(_) => break,
909 }
910 }
911 parts.push(DiffPart::Change { removed, added });
912 }
913 }
914 }
915 parts
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921
922 #[test]
923 fn strip_bom_detects_prefix() {
924 let (bom, text) = strip_bom("\u{FEFF}hello");
925 assert_eq!(bom, "\u{FEFF}");
926 assert_eq!(text, "hello");
927 let (bom, text) = strip_bom("hello");
928 assert_eq!(bom, "");
929 assert_eq!(text, "hello");
930 }
931
932 #[test]
933 fn detect_line_ending_prefers_first() {
934 assert_eq!(detect_line_ending("a\r\nb\n"), LineEnding::Crlf);
935 assert_eq!(detect_line_ending("a\nb\r\n"), LineEnding::Lf);
936 assert_eq!(detect_line_ending("no newlines"), LineEnding::Lf);
937 }
938
939 #[test]
940 fn normalize_and_restore_crlf() {
941 assert_eq!(normalize_to_lf("a\r\nb\rc"), "a\nb\nc");
942 assert_eq!(
943 restore_line_endings("a\nb\n", LineEnding::Crlf),
944 "a\r\nb\r\n"
945 );
946 }
947
948 #[test]
949 fn fuzzy_normalizes_quotes_dashes_spaces() {
950 let input = "\u{2018}hi\u{2019} \u{2013} \u{00A0}x ";
951 let out = normalize_for_fuzzy_match(input);
952 assert_eq!(out, "'hi' - x");
953 }
954
955 #[test]
956 fn exact_single_edit() -> Result<(), String> {
957 let result = apply_edits_to_normalized_content(
958 "Hello, world!",
959 &[Edit {
960 old_text: "world".into(),
961 new_text: "testing".into(),
962 }],
963 "f.txt",
964 )?;
965 assert_eq!(result.new_content, "Hello, testing!");
966 Ok(())
967 }
968
969 #[test]
970 fn empty_old_text_rejected() -> Result<(), String> {
971 let err = apply_edits_to_normalized_content(
972 "abc",
973 &[Edit {
974 old_text: String::new(),
975 new_text: "x".into(),
976 }],
977 "f.txt",
978 )
979 .err()
980 .ok_or_else(|| "empty oldText was accepted".to_owned())?;
981 assert_eq!(err, "oldText must not be empty in f.txt.");
982 Ok(())
983 }
984
985 #[test]
986 fn missing_text_errors() -> Result<(), String> {
987 let err = apply_edits_to_normalized_content(
988 "abc",
989 &[Edit {
990 old_text: "zzz".into(),
991 new_text: "x".into(),
992 }],
993 "f.txt",
994 )
995 .err()
996 .ok_or_else(|| "missing oldText was accepted".to_owned())?;
997 assert!(err.contains("Could not find the exact text"));
998 Ok(())
999 }
1000
1001 #[test]
1002 fn occurrence_count_errors() -> Result<(), String> {
1003 let err = apply_edits_to_normalized_content(
1004 "foo foo foo",
1005 &[Edit {
1006 old_text: "foo".into(),
1007 new_text: "bar".into(),
1008 }],
1009 "f.txt",
1010 )
1011 .err()
1012 .ok_or_else(|| "duplicate oldText was accepted".to_owned())?;
1013 assert!(err.contains("Found 3 occurrences"));
1014 Ok(())
1015 }
1016
1017 #[test]
1018 fn multi_edit_reverse_and_original_coords() -> Result<(), String> {
1019 let result = apply_edits_to_normalized_content(
1020 "foo\nbar\nbaz\n",
1021 &[
1022 Edit {
1023 old_text: "foo\n".into(),
1024 new_text: "foo bar\n".into(),
1025 },
1026 Edit {
1027 old_text: "bar\n".into(),
1028 new_text: "BAR\n".into(),
1029 },
1030 ],
1031 "f.txt",
1032 )?;
1033 assert_eq!(result.new_content, "foo bar\nBAR\nbaz\n");
1034 Ok(())
1035 }
1036
1037 #[test]
1038 fn overlap_rejected() -> Result<(), String> {
1039 let err = apply_edits_to_normalized_content(
1040 "one\ntwo\nthree\n",
1041 &[
1042 Edit {
1043 old_text: "one\ntwo\n".into(),
1044 new_text: "ONE\nTWO\n".into(),
1045 },
1046 Edit {
1047 old_text: "two\nthree\n".into(),
1048 new_text: "TWO\nTHREE\n".into(),
1049 },
1050 ],
1051 "f.txt",
1052 )
1053 .err()
1054 .ok_or_else(|| "overlapping edits were accepted".to_owned())?;
1055 assert!(err.contains("overlap"));
1056 Ok(())
1057 }
1058
1059 #[test]
1060 fn no_op_rejected() -> Result<(), String> {
1061 let err = apply_edits_to_normalized_content(
1062 "same",
1063 &[Edit {
1064 old_text: "same".into(),
1065 new_text: "same".into(),
1066 }],
1067 "f.txt",
1068 )
1069 .err()
1070 .ok_or_else(|| "unchanged edit was accepted".to_owned())?;
1071 assert!(err.contains("No changes made"));
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn fuzzy_preserves_untouched_trailing_whitespace() -> Result<(), String> {
1077 let original = "line one \nline two \nline three\n";
1078 let result = apply_edits_to_normalized_content(
1079 original,
1080 &[Edit {
1081 old_text: "line one\nline two\n".into(),
1082 new_text: "replaced\n".into(),
1083 }],
1084 "f.txt",
1085 )?;
1086 assert_eq!(result.new_content, "replaced\nline three\n");
1087 Ok(())
1088 }
1089
1090 #[test]
1091 fn fuzzy_preserves_duplicate_line_bytes() -> Result<(), String> {
1092 let original = ["replace me ", "after ", ""].join("\n");
1093 let result = apply_edits_to_normalized_content(
1094 &original,
1095 &[Edit {
1096 old_text: "replace me\n".into(),
1097 new_text: "after\n".into(),
1098 }],
1099 "f.txt",
1100 )?;
1101 assert_eq!(result.new_content, ["after", "after ", ""].join("\n"));
1102 Ok(())
1103 }
1104
1105 #[test]
1106 fn unified_patch_contains_markers() {
1107 let patch = generate_unified_patch("a.txt", "Hello, world!", "Hello, testing!", 4);
1108 assert!(patch.contains("--- a.txt"));
1109 assert!(patch.contains("+++ a.txt"));
1110 assert!(patch.contains("@@"));
1111 assert!(patch.contains("-Hello, world!"));
1112 assert!(patch.contains("+Hello, testing!"));
1113 }
1114
1115 #[test]
1116 fn unified_patch_splits_distant_changes_with_requested_context() {
1117 let old = (1..=12)
1118 .map(|number| format!("line {number}"))
1119 .collect::<Vec<_>>()
1120 .join("\n");
1121 let mut new_lines = (1..=12)
1122 .map(|number| format!("line {number}"))
1123 .collect::<Vec<_>>();
1124 new_lines[1] = "LINE 2".to_owned();
1125 new_lines[10] = "LINE 11".to_owned();
1126 let patch = generate_unified_patch("a.txt", &old, &new_lines.join("\n"), 1);
1127
1128 assert_eq!(patch.matches("@@ ").count(), 2);
1129 assert!(patch.contains("@@ -1,3 +1,3 @@"));
1130 assert!(patch.contains("@@ -10,3 +10,3 @@"));
1131 assert!(patch.contains(" line 1\n-line 2\n+LINE 2\n line 3\n"));
1132 assert!(!patch.contains(" line 6\n"));
1133 }
1134
1135 #[test]
1136 fn unified_patch_uses_zero_count_headers_for_insert_and_delete() {
1137 let insert = generate_unified_patch("a.txt", "one\ntwo\n", "one\nadded\ntwo\n", 0);
1138 assert!(insert.contains("@@ -1,0 +2,1 @@\n+added\n"));
1139
1140 let delete = generate_unified_patch("a.txt", "one\nremoved\ntwo\n", "one\ntwo\n", 0);
1141 assert!(delete.contains("@@ -2,1 +1,0 @@\n-removed\n"));
1142 }
1143
1144 #[test]
1145 fn display_diff_marks_first_changed_line() {
1146 let result = generate_diff_string("a\nb\nc\n", "a\nB\nc\n", 4);
1147 assert_eq!(result.first_changed_line, Some(2));
1148 assert!(result.diff.contains('B'));
1149 }
1150}