1use crate::error::{PunchError, PunchResult};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub enum PatchLine {
18 Context(String),
20 Remove(String),
22 Add(String),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PatchHunk {
29 pub old_start: usize,
31 pub old_count: usize,
33 pub new_start: usize,
35 pub new_count: usize,
37 pub lines: Vec<PatchLine>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FilePatch {
44 pub old_path: String,
46 pub new_path: String,
48 pub hunks: Vec<PatchHunk>,
50 pub is_new_file: bool,
52 pub is_deleted: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PatchSet {
59 pub patches: Vec<FilePatch>,
61 pub description: Option<String>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PatchConflict {
68 pub hunk_index: usize,
70 pub expected_line: String,
72 pub actual_line: String,
74 pub line_number: usize,
76 pub conflict_type: ConflictType,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub enum ConflictType {
83 ContextMismatch,
85 LineNotFound,
87 OffsetApplied(i32),
89}
90
91pub fn parse_unified_diff(diff_text: &str) -> PunchResult<PatchSet> {
100 let lines: Vec<&str> = diff_text.lines().collect();
101
102 if lines.is_empty() {
103 return Ok(PatchSet {
104 patches: Vec::new(),
105 description: None,
106 });
107 }
108
109 let mut patches = Vec::new();
110 let mut i = 0;
111
112 while i < lines.len() {
113 if !lines[i].starts_with("--- ") {
115 i += 1;
117 continue;
118 }
119
120 let old_header = lines[i];
122 i += 1;
123 if i >= lines.len() || !lines[i].starts_with("+++ ") {
124 return Err(PunchError::Tool {
125 tool: "patch".into(),
126 message: format!("expected '+++ ' header after '--- ' at line {}", i),
127 });
128 }
129 let new_header = lines[i];
130 i += 1;
131
132 let old_path = parse_file_path(old_header, "--- ");
133 let new_path = parse_file_path(new_header, "+++ ");
134
135 let is_new_file = old_path == "/dev/null";
136 let is_deleted = new_path == "/dev/null";
137
138 let mut hunks = Vec::new();
139
140 while i < lines.len() && lines[i].starts_with("@@ ") {
142 let (hunk, consumed) = parse_hunk(&lines[i..])?;
143 hunks.push(hunk);
144 i += consumed;
145 }
146
147 patches.push(FilePatch {
148 old_path: old_path.to_string(),
149 new_path: new_path.to_string(),
150 hunks,
151 is_new_file,
152 is_deleted,
153 });
154 }
155
156 Ok(PatchSet {
157 patches,
158 description: None,
159 })
160}
161
162fn parse_file_path<'a>(line: &'a str, prefix: &str) -> &'a str {
164 let path = &line[prefix.len()..];
165 if let Some(stripped) = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")) {
167 stripped
168 } else {
169 path
170 }
171}
172
173fn parse_hunk(lines: &[&str]) -> PunchResult<(PatchHunk, usize)> {
176 let header = lines[0];
177 let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
178
179 let mut patch_lines = Vec::new();
180 let mut i = 1;
181
182 while i < lines.len() {
183 let line = lines[i];
184
185 if line.starts_with("@@ ") || line.starts_with("--- ") || line.starts_with("diff ") {
187 break;
188 }
189
190 if line.starts_with("\\ ") {
192 i += 1;
193 continue;
194 }
195
196 if let Some(content) = line.strip_prefix(' ') {
197 patch_lines.push(PatchLine::Context(content.to_string()));
198 } else if let Some(content) = line.strip_prefix('-') {
199 patch_lines.push(PatchLine::Remove(content.to_string()));
200 } else if let Some(content) = line.strip_prefix('+') {
201 patch_lines.push(PatchLine::Add(content.to_string()));
202 } else if line.is_empty() {
203 patch_lines.push(PatchLine::Context(String::new()));
205 } else {
206 patch_lines.push(PatchLine::Context(line.to_string()));
209 }
210
211 i += 1;
212 }
213
214 Ok((
215 PatchHunk {
216 old_start,
217 old_count,
218 new_start,
219 new_count,
220 lines: patch_lines,
221 },
222 i,
223 ))
224}
225
226fn parse_hunk_header(header: &str) -> PunchResult<(usize, usize, usize, usize)> {
228 let inner = header
230 .strip_prefix("@@ ")
231 .and_then(|s| s.find(" @@").map(|pos| &s[..pos]))
232 .ok_or_else(|| PunchError::Tool {
233 tool: "patch".into(),
234 message: format!("malformed hunk header: {}", header),
235 })?;
236
237 let parts: Vec<&str> = inner.split_whitespace().collect();
239 if parts.len() != 2 {
240 return Err(PunchError::Tool {
241 tool: "patch".into(),
242 message: format!("malformed hunk header ranges: {}", header),
243 });
244 }
245
246 let (old_start, old_count) = parse_range(parts[0], '-')?;
247 let (new_start, new_count) = parse_range(parts[1], '+')?;
248
249 Ok((old_start, old_count, new_start, new_count))
250}
251
252fn parse_range(s: &str, prefix: char) -> PunchResult<(usize, usize)> {
254 let s = s.strip_prefix(prefix).ok_or_else(|| PunchError::Tool {
255 tool: "patch".into(),
256 message: format!("expected '{}' prefix in range '{}'", prefix, s),
257 })?;
258
259 if let Some((start_str, count_str)) = s.split_once(',') {
260 let start = start_str.parse::<usize>().map_err(|e| PunchError::Tool {
261 tool: "patch".into(),
262 message: format!("invalid range start '{}': {}", start_str, e),
263 })?;
264 let count = count_str.parse::<usize>().map_err(|e| PunchError::Tool {
265 tool: "patch".into(),
266 message: format!("invalid range count '{}': {}", count_str, e),
267 })?;
268 Ok((start, count))
269 } else {
270 let start = s.parse::<usize>().map_err(|e| PunchError::Tool {
272 tool: "patch".into(),
273 message: format!("invalid range '{}': {}", s, e),
274 })?;
275 Ok((start, 1))
276 }
277}
278
279pub fn apply_patch(original: &str, patch: &FilePatch) -> PunchResult<String> {
288 if patch.is_new_file {
289 let mut result = String::new();
291 for hunk in &patch.hunks {
292 for line in &hunk.lines {
293 if let PatchLine::Add(content) = line {
294 if !result.is_empty() {
295 result.push('\n');
296 }
297 result.push_str(content);
298 }
299 }
300 }
301 return Ok(result);
302 }
303
304 let orig_lines: Vec<&str> = if original.is_empty() {
305 Vec::new()
306 } else {
307 original.lines().collect()
308 };
309
310 let mut result_lines: Vec<String> = Vec::new();
311 let mut orig_pos: usize = 0;
313
314 for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
315 let hunk_start = if hunk.old_start == 0 {
317 0
318 } else {
319 hunk.old_start - 1
320 };
321
322 while orig_pos < hunk_start && orig_pos < orig_lines.len() {
324 result_lines.push(orig_lines[orig_pos].to_string());
325 orig_pos += 1;
326 }
327
328 for line in &hunk.lines {
330 match line {
331 PatchLine::Context(content) => {
332 if orig_pos < orig_lines.len() {
333 if orig_lines[orig_pos] != content.as_str() {
334 return Err(PunchError::Tool {
335 tool: "patch".into(),
336 message: format!(
337 "combo broken at hunk {}: context mismatch at line {} — \
338 expected {:?}, found {:?}",
339 hunk_idx + 1,
340 orig_pos + 1,
341 content,
342 orig_lines[orig_pos]
343 ),
344 });
345 }
346 result_lines.push(orig_lines[orig_pos].to_string());
347 orig_pos += 1;
348 } else {
349 return Err(PunchError::Tool {
350 tool: "patch".into(),
351 message: format!(
352 "combo broken at hunk {}: ran out of original lines at context line",
353 hunk_idx + 1,
354 ),
355 });
356 }
357 }
358 PatchLine::Remove(content) => {
359 if orig_pos < orig_lines.len() {
360 if orig_lines[orig_pos] != content.as_str() {
361 return Err(PunchError::Tool {
362 tool: "patch".into(),
363 message: format!(
364 "combo broken at hunk {}: remove mismatch at line {} — \
365 expected {:?}, found {:?}",
366 hunk_idx + 1,
367 orig_pos + 1,
368 content,
369 orig_lines[orig_pos]
370 ),
371 });
372 }
373 orig_pos += 1;
374 } else {
375 return Err(PunchError::Tool {
376 tool: "patch".into(),
377 message: format!(
378 "combo broken at hunk {}: ran out of original lines for removal",
379 hunk_idx + 1,
380 ),
381 });
382 }
383 }
384 PatchLine::Add(content) => {
385 result_lines.push(content.clone());
386 }
387 }
388 }
389 }
390
391 while orig_pos < orig_lines.len() {
393 result_lines.push(orig_lines[orig_pos].to_string());
394 orig_pos += 1;
395 }
396
397 Ok(result_lines.join("\n"))
398}
399
400pub fn apply_patch_fuzzy(
406 original: &str,
407 patch: &FilePatch,
408 fuzz_factor: usize,
409) -> PunchResult<String> {
410 if patch.is_new_file {
411 return apply_patch(original, patch);
412 }
413
414 let orig_lines: Vec<&str> = if original.is_empty() {
415 Vec::new()
416 } else {
417 original.lines().collect()
418 };
419
420 let mut result_lines: Vec<String> = Vec::new();
421 let mut orig_pos: usize = 0;
422
423 for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
424 let nominal_start = if hunk.old_start == 0 {
425 0
426 } else {
427 hunk.old_start - 1
428 };
429
430 let actual_start = find_hunk_match(&orig_lines, hunk, nominal_start, fuzz_factor)
432 .ok_or_else(|| PunchError::Tool {
433 tool: "patch".into(),
434 message: format!(
435 "combo broken at hunk {}: could not find matching context within fuzz factor {}",
436 hunk_idx + 1,
437 fuzz_factor
438 ),
439 })?;
440
441 while orig_pos < actual_start && orig_pos < orig_lines.len() {
443 result_lines.push(orig_lines[orig_pos].to_string());
444 orig_pos += 1;
445 }
446
447 for line in &hunk.lines {
449 match line {
450 PatchLine::Context(_) => {
451 if orig_pos < orig_lines.len() {
452 result_lines.push(orig_lines[orig_pos].to_string());
453 orig_pos += 1;
454 }
455 }
456 PatchLine::Remove(_) => {
457 if orig_pos < orig_lines.len() {
458 orig_pos += 1;
459 }
460 }
461 PatchLine::Add(content) => {
462 result_lines.push(content.clone());
463 }
464 }
465 }
466 }
467
468 while orig_pos < orig_lines.len() {
470 result_lines.push(orig_lines[orig_pos].to_string());
471 orig_pos += 1;
472 }
473
474 Ok(result_lines.join("\n"))
475}
476
477fn find_hunk_match(
480 orig_lines: &[&str],
481 hunk: &PatchHunk,
482 nominal_start: usize,
483 fuzz_factor: usize,
484) -> Option<usize> {
485 let expected: Vec<&str> = hunk
487 .lines
488 .iter()
489 .filter_map(|l| match l {
490 PatchLine::Context(s) => Some(s.as_str()),
491 PatchLine::Remove(s) => Some(s.as_str()),
492 _ => None,
493 })
494 .collect();
495
496 if expected.is_empty() {
497 return Some(nominal_start.min(orig_lines.len()));
499 }
500
501 for offset in 0..=fuzz_factor {
503 if let Some(start) = nominal_start.checked_add(offset)
505 && matches_at(orig_lines, &expected, start)
506 {
507 return Some(start);
508 }
509 if offset > 0
511 && let Some(start) = nominal_start.checked_sub(offset)
512 && matches_at(orig_lines, &expected, start)
513 {
514 return Some(start);
515 }
516 }
517
518 None
519}
520
521fn matches_at(orig_lines: &[&str], expected: &[&str], start: usize) -> bool {
523 if start + expected.len() > orig_lines.len() {
524 return false;
525 }
526 expected
527 .iter()
528 .zip(&orig_lines[start..start + expected.len()])
529 .all(|(exp, orig)| exp == orig)
530}
531
532pub fn validate_patch(original: &str, patch: &FilePatch) -> Vec<PatchConflict> {
540 let orig_lines: Vec<&str> = if original.is_empty() {
541 Vec::new()
542 } else {
543 original.lines().collect()
544 };
545
546 let mut conflicts = Vec::new();
547
548 for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
549 let nominal_start = if hunk.old_start == 0 {
550 0
551 } else {
552 hunk.old_start - 1
553 };
554 let mut line_pos = nominal_start;
555
556 for line in &hunk.lines {
557 match line {
558 PatchLine::Context(expected) => {
559 if line_pos >= orig_lines.len() {
560 conflicts.push(PatchConflict {
561 hunk_index: hunk_idx,
562 expected_line: expected.clone(),
563 actual_line: String::new(),
564 line_number: line_pos + 1,
565 conflict_type: ConflictType::LineNotFound,
566 });
567 } else if orig_lines[line_pos] != expected.as_str() {
568 let found_offset = find_line_nearby(&orig_lines, expected, line_pos, 10);
570 let conflict_type = if let Some(actual_pos) = found_offset {
571 ConflictType::OffsetApplied(actual_pos as i32 - line_pos as i32)
572 } else {
573 ConflictType::ContextMismatch
574 };
575 conflicts.push(PatchConflict {
576 hunk_index: hunk_idx,
577 expected_line: expected.clone(),
578 actual_line: orig_lines[line_pos].to_string(),
579 line_number: line_pos + 1,
580 conflict_type,
581 });
582 }
583 line_pos += 1;
584 }
585 PatchLine::Remove(expected) => {
586 if line_pos >= orig_lines.len() {
587 conflicts.push(PatchConflict {
588 hunk_index: hunk_idx,
589 expected_line: expected.clone(),
590 actual_line: String::new(),
591 line_number: line_pos + 1,
592 conflict_type: ConflictType::LineNotFound,
593 });
594 } else if orig_lines[line_pos] != expected.as_str() {
595 conflicts.push(PatchConflict {
596 hunk_index: hunk_idx,
597 expected_line: expected.clone(),
598 actual_line: orig_lines[line_pos].to_string(),
599 line_number: line_pos + 1,
600 conflict_type: ConflictType::ContextMismatch,
601 });
602 }
603 line_pos += 1;
604 }
605 PatchLine::Add(_) => {
606 }
608 }
609 }
610 }
611
612 conflicts
613}
614
615fn find_line_nearby(orig_lines: &[&str], target: &str, pos: usize, radius: usize) -> Option<usize> {
617 for offset in 1..=radius {
618 if let Some(p) = pos.checked_add(offset)
619 && p < orig_lines.len()
620 && orig_lines[p] == target
621 {
622 return Some(p);
623 }
624 if let Some(p) = pos.checked_sub(offset)
625 && orig_lines[p] == target
626 {
627 return Some(p);
628 }
629 }
630 None
631}
632
633pub fn generate_unified_diff(
642 old_content: &str,
643 new_content: &str,
644 old_path: &str,
645 new_path: &str,
646) -> String {
647 let old_lines: Vec<&str> = if old_content.is_empty() {
648 Vec::new()
649 } else {
650 old_content.lines().collect()
651 };
652 let new_lines: Vec<&str> = if new_content.is_empty() {
653 Vec::new()
654 } else {
655 new_content.lines().collect()
656 };
657
658 let edits = compute_edit_script(&old_lines, &new_lines);
659 let hunks = edits_to_hunks(&old_lines, &new_lines, &edits, 3);
660
661 if hunks.is_empty() {
662 return String::new();
663 }
664
665 let mut output = String::new();
666 output.push_str(&format!("--- a/{}\n", old_path));
667 output.push_str(&format!("+++ b/{}\n", new_path));
668
669 for hunk in &hunks {
670 output.push_str(&format!(
671 "@@ -{},{} +{},{} @@\n",
672 hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
673 ));
674 for line in &hunk.lines {
675 match line {
676 PatchLine::Context(s) => {
677 output.push(' ');
678 output.push_str(s);
679 output.push('\n');
680 }
681 PatchLine::Remove(s) => {
682 output.push('-');
683 output.push_str(s);
684 output.push('\n');
685 }
686 PatchLine::Add(s) => {
687 output.push('+');
688 output.push_str(s);
689 output.push('\n');
690 }
691 }
692 }
693 }
694
695 output
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum EditOp {
701 Equal,
703 Delete,
705 Insert,
707}
708
709fn compute_edit_script<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(EditOp, usize, usize)> {
711 let m = old.len();
712 let n = new.len();
713
714 let mut dp = vec![vec![0usize; n + 1]; m + 1];
716 for i in 1..=m {
717 for j in 1..=n {
718 if old[i - 1] == new[j - 1] {
719 dp[i][j] = dp[i - 1][j - 1] + 1;
720 } else {
721 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
722 }
723 }
724 }
725
726 let mut edits = Vec::new();
728 let mut i = m;
729 let mut j = n;
730
731 while i > 0 || j > 0 {
732 if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
733 edits.push((EditOp::Equal, i - 1, j - 1));
734 i -= 1;
735 j -= 1;
736 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
737 edits.push((EditOp::Insert, i, j - 1));
738 j -= 1;
739 } else if i > 0 {
740 edits.push((EditOp::Delete, i - 1, j));
741 i -= 1;
742 }
743 }
744
745 edits.reverse();
746 edits
747}
748
749fn edits_to_hunks(
751 old_lines: &[&str],
752 new_lines: &[&str],
753 edits: &[(EditOp, usize, usize)],
754 context: usize,
755) -> Vec<PatchHunk> {
756 if edits.is_empty() {
757 return Vec::new();
758 }
759
760 let mut change_ranges: Vec<(usize, usize)> = Vec::new(); let mut i = 0;
763 while i < edits.len() {
764 if edits[i].0 != EditOp::Equal {
765 let start = i;
766 while i < edits.len() && edits[i].0 != EditOp::Equal {
767 i += 1;
768 }
769 change_ranges.push((start, i));
770 } else {
771 i += 1;
772 }
773 }
774
775 if change_ranges.is_empty() {
776 return Vec::new();
777 }
778
779 let mut hunks = Vec::new();
781 let mut range_idx = 0;
782
783 while range_idx < change_ranges.len() {
784 let (first_start, _) = change_ranges[range_idx];
785
786 let mut last_end = change_ranges[range_idx].1;
788 let mut merge_end = range_idx;
789 while merge_end + 1 < change_ranges.len() {
790 let next_start = change_ranges[merge_end + 1].0;
791 if next_start - last_end <= 2 * context {
793 merge_end += 1;
794 last_end = change_ranges[merge_end].1;
795 } else {
796 break;
797 }
798 }
799
800 let hunk_edit_start = first_start.saturating_sub(context).max(0);
802 let hunk_edit_end = last_end
803 .min(edits.len())
804 .saturating_add(context)
805 .min(edits.len());
806
807 let mut hunk_lines = Vec::new();
808 let mut old_line_start = usize::MAX;
809 let mut old_count = 0usize;
810 let mut new_line_start = usize::MAX;
811 let mut new_count = 0usize;
812
813 for edit_idx in hunk_edit_start..hunk_edit_end {
814 if edit_idx >= edits.len() {
815 break;
816 }
817 let (op, old_idx, new_idx) = edits[edit_idx];
818
819 match op {
820 EditOp::Equal => {
821 if old_idx < old_lines.len() {
822 hunk_lines.push(PatchLine::Context(old_lines[old_idx].to_string()));
823 if old_line_start == usize::MAX {
824 old_line_start = old_idx;
825 new_line_start = new_idx;
826 }
827 old_count += 1;
828 new_count += 1;
829 }
830 }
831 EditOp::Delete => {
832 if old_idx < old_lines.len() {
833 hunk_lines.push(PatchLine::Remove(old_lines[old_idx].to_string()));
834 if old_line_start == usize::MAX {
835 old_line_start = old_idx;
836 new_line_start = new_idx;
837 }
838 old_count += 1;
839 }
840 }
841 EditOp::Insert => {
842 if new_idx < new_lines.len() {
843 hunk_lines.push(PatchLine::Add(new_lines[new_idx].to_string()));
844 if old_line_start == usize::MAX {
845 old_line_start = old_idx;
846 new_line_start = new_idx;
847 }
848 new_count += 1;
849 }
850 }
851 }
852 }
853
854 if !hunk_lines.is_empty() {
855 hunks.push(PatchHunk {
856 old_start: if old_line_start == usize::MAX {
857 0
858 } else {
859 old_line_start + 1
860 },
861 old_count,
862 new_start: if new_line_start == usize::MAX {
863 0
864 } else {
865 new_line_start + 1
866 },
867 new_count,
868 lines: hunk_lines,
869 });
870 }
871
872 range_idx = merge_end + 1;
873 }
874
875 hunks
876}
877
878pub fn reverse_patch(patch: &FilePatch) -> FilePatch {
887 let reversed_hunks: Vec<PatchHunk> = patch
888 .hunks
889 .iter()
890 .map(|hunk| {
891 let reversed_lines: Vec<PatchLine> = hunk
892 .lines
893 .iter()
894 .map(|line| match line {
895 PatchLine::Context(s) => PatchLine::Context(s.clone()),
896 PatchLine::Remove(s) => PatchLine::Add(s.clone()),
897 PatchLine::Add(s) => PatchLine::Remove(s.clone()),
898 })
899 .collect();
900
901 PatchHunk {
902 old_start: hunk.new_start,
903 old_count: hunk.new_count,
904 new_start: hunk.old_start,
905 new_count: hunk.old_count,
906 lines: reversed_lines,
907 }
908 })
909 .collect();
910
911 FilePatch {
912 old_path: patch.new_path.clone(),
913 new_path: patch.old_path.clone(),
914 hunks: reversed_hunks,
915 is_new_file: patch.is_deleted,
916 is_deleted: patch.is_new_file,
917 }
918}
919
920#[cfg(test)]
925mod tests {
926 use super::*;
927
928 #[test]
929 fn test_parse_simple_unified_diff() {
930 let diff = "\
931--- a/hello.rs
932+++ b/hello.rs
933@@ -1,3 +1,4 @@
934 fn main() {
935- println!(\"hello\");
936+ println!(\"hello, world\");
937+ println!(\"goodbye\");
938 }
939";
940 let ps = parse_unified_diff(diff).expect("should parse");
941 assert_eq!(ps.patches.len(), 1);
942 let fp = &ps.patches[0];
943 assert_eq!(fp.old_path, "hello.rs");
944 assert_eq!(fp.new_path, "hello.rs");
945 assert!(!fp.is_new_file);
946 assert!(!fp.is_deleted);
947 assert_eq!(fp.hunks.len(), 1);
948 let h = &fp.hunks[0];
949 assert_eq!(h.old_start, 1);
950 assert_eq!(h.old_count, 3);
951 assert_eq!(h.new_start, 1);
952 assert_eq!(h.new_count, 4);
953 assert_eq!(h.lines.len(), 5);
954 }
955
956 #[test]
957 fn test_parse_multi_hunk_diff() {
958 let diff = "\
959--- a/lib.rs
960+++ b/lib.rs
961@@ -1,3 +1,3 @@
962 fn a() {
963- old_a();
964+ new_a();
965 }
966@@ -10,3 +10,3 @@
967 fn b() {
968- old_b();
969+ new_b();
970 }
971";
972 let ps = parse_unified_diff(diff).expect("should parse");
973 assert_eq!(ps.patches.len(), 1);
974 assert_eq!(ps.patches[0].hunks.len(), 2);
975 assert_eq!(ps.patches[0].hunks[0].old_start, 1);
976 assert_eq!(ps.patches[0].hunks[1].old_start, 10);
977 }
978
979 #[test]
980 fn test_parse_new_file_diff() {
981 let diff = "\
982--- /dev/null
983+++ b/new_file.rs
984@@ -0,0 +1,3 @@
985+fn new_func() {
986+ // brand new
987+}
988";
989 let ps = parse_unified_diff(diff).expect("should parse");
990 assert_eq!(ps.patches.len(), 1);
991 assert!(ps.patches[0].is_new_file);
992 assert!(!ps.patches[0].is_deleted);
993 assert_eq!(ps.patches[0].new_path, "new_file.rs");
994 }
995
996 #[test]
997 fn test_parse_deleted_file_diff() {
998 let diff = "\
999--- a/dead.rs
1000+++ /dev/null
1001@@ -1,3 +0,0 @@
1002-fn old() {
1003- // going away
1004-}
1005";
1006 let ps = parse_unified_diff(diff).expect("should parse");
1007 assert_eq!(ps.patches.len(), 1);
1008 assert!(!ps.patches[0].is_new_file);
1009 assert!(ps.patches[0].is_deleted);
1010 assert_eq!(ps.patches[0].old_path, "dead.rs");
1011 }
1012
1013 #[test]
1014 fn test_apply_simple_addition() {
1015 let original = "line1\nline2\nline3";
1016 let patch = FilePatch {
1017 old_path: "f.txt".into(),
1018 new_path: "f.txt".into(),
1019 hunks: vec![PatchHunk {
1020 old_start: 2,
1021 old_count: 1,
1022 new_start: 2,
1023 new_count: 2,
1024 lines: vec![
1025 PatchLine::Context("line2".into()),
1026 PatchLine::Add("inserted".into()),
1027 ],
1028 }],
1029 is_new_file: false,
1030 is_deleted: false,
1031 };
1032 let result = apply_patch(original, &patch).expect("should apply");
1033 assert_eq!(result, "line1\nline2\ninserted\nline3");
1034 }
1035
1036 #[test]
1037 fn test_apply_simple_deletion() {
1038 let original = "line1\nline2\nline3";
1039 let patch = FilePatch {
1040 old_path: "f.txt".into(),
1041 new_path: "f.txt".into(),
1042 hunks: vec![PatchHunk {
1043 old_start: 1,
1044 old_count: 3,
1045 new_start: 1,
1046 new_count: 2,
1047 lines: vec![
1048 PatchLine::Context("line1".into()),
1049 PatchLine::Remove("line2".into()),
1050 PatchLine::Context("line3".into()),
1051 ],
1052 }],
1053 is_new_file: false,
1054 is_deleted: false,
1055 };
1056 let result = apply_patch(original, &patch).expect("should apply");
1057 assert_eq!(result, "line1\nline3");
1058 }
1059
1060 #[test]
1061 fn test_apply_modification() {
1062 let original = "fn main() {\n println!(\"old\");\n}";
1063 let patch = FilePatch {
1064 old_path: "f.rs".into(),
1065 new_path: "f.rs".into(),
1066 hunks: vec![PatchHunk {
1067 old_start: 1,
1068 old_count: 3,
1069 new_start: 1,
1070 new_count: 3,
1071 lines: vec![
1072 PatchLine::Context("fn main() {".into()),
1073 PatchLine::Remove(" println!(\"old\");".into()),
1074 PatchLine::Add(" println!(\"new\");".into()),
1075 PatchLine::Context("}".into()),
1076 ],
1077 }],
1078 is_new_file: false,
1079 is_deleted: false,
1080 };
1081 let result = apply_patch(original, &patch).expect("should apply");
1082 assert_eq!(result, "fn main() {\n println!(\"new\");\n}");
1083 }
1084
1085 #[test]
1086 fn test_apply_multi_hunk() {
1087 let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj";
1088 let patch = FilePatch {
1089 old_path: "f.txt".into(),
1090 new_path: "f.txt".into(),
1091 hunks: vec![
1092 PatchHunk {
1093 old_start: 2,
1094 old_count: 1,
1095 new_start: 2,
1096 new_count: 1,
1097 lines: vec![PatchLine::Remove("b".into()), PatchLine::Add("B".into())],
1098 },
1099 PatchHunk {
1100 old_start: 8,
1101 old_count: 1,
1102 new_start: 8,
1103 new_count: 1,
1104 lines: vec![PatchLine::Remove("h".into()), PatchLine::Add("H".into())],
1105 },
1106 ],
1107 is_new_file: false,
1108 is_deleted: false,
1109 };
1110 let result = apply_patch(original, &patch).expect("should apply");
1111 assert_eq!(result, "a\nB\nc\nd\ne\nf\ng\nH\ni\nj");
1112 }
1113
1114 #[test]
1115 fn test_apply_new_file_patch() {
1116 let patch = FilePatch {
1117 old_path: "/dev/null".into(),
1118 new_path: "new.rs".into(),
1119 hunks: vec![PatchHunk {
1120 old_start: 0,
1121 old_count: 0,
1122 new_start: 1,
1123 new_count: 2,
1124 lines: vec![
1125 PatchLine::Add("// new file".into()),
1126 PatchLine::Add("fn hello() {}".into()),
1127 ],
1128 }],
1129 is_new_file: true,
1130 is_deleted: false,
1131 };
1132 let result = apply_patch("", &patch).expect("should apply");
1133 assert_eq!(result, "// new file\nfn hello() {}");
1134 }
1135
1136 #[test]
1137 fn test_fuzzy_matching_with_offset() {
1138 let original = "extra1\nextra2\na\nb\nc";
1140 let patch = FilePatch {
1141 old_path: "f.txt".into(),
1142 new_path: "f.txt".into(),
1143 hunks: vec![PatchHunk {
1144 old_start: 1,
1145 old_count: 2,
1146 new_start: 1,
1147 new_count: 2,
1148 lines: vec![
1149 PatchLine::Context("a".into()),
1150 PatchLine::Remove("b".into()),
1151 PatchLine::Add("B".into()),
1152 ],
1153 }],
1154 is_new_file: false,
1155 is_deleted: false,
1156 };
1157 assert!(apply_patch(original, &patch).is_err());
1159 let result = apply_patch_fuzzy(original, &patch, 3).expect("should apply fuzzy");
1161 assert_eq!(result, "extra1\nextra2\na\nB\nc");
1162 }
1163
1164 #[test]
1165 fn test_validate_clean_patch() {
1166 let original = "line1\nline2\nline3";
1167 let patch = FilePatch {
1168 old_path: "f.txt".into(),
1169 new_path: "f.txt".into(),
1170 hunks: vec![PatchHunk {
1171 old_start: 1,
1172 old_count: 3,
1173 new_start: 1,
1174 new_count: 3,
1175 lines: vec![
1176 PatchLine::Context("line1".into()),
1177 PatchLine::Remove("line2".into()),
1178 PatchLine::Add("LINE2".into()),
1179 PatchLine::Context("line3".into()),
1180 ],
1181 }],
1182 is_new_file: false,
1183 is_deleted: false,
1184 };
1185 let conflicts = validate_patch(original, &patch);
1186 assert!(
1187 conflicts.is_empty(),
1188 "expected no conflicts, got {:?}",
1189 conflicts
1190 );
1191 }
1192
1193 #[test]
1194 fn test_validate_conflicting_patch() {
1195 let original = "line1\nDIFFERENT\nline3";
1196 let patch = FilePatch {
1197 old_path: "f.txt".into(),
1198 new_path: "f.txt".into(),
1199 hunks: vec![PatchHunk {
1200 old_start: 1,
1201 old_count: 3,
1202 new_start: 1,
1203 new_count: 3,
1204 lines: vec![
1205 PatchLine::Context("line1".into()),
1206 PatchLine::Remove("line2".into()),
1207 PatchLine::Add("LINE2".into()),
1208 PatchLine::Context("line3".into()),
1209 ],
1210 }],
1211 is_new_file: false,
1212 is_deleted: false,
1213 };
1214 let conflicts = validate_patch(original, &patch);
1215 assert_eq!(conflicts.len(), 1);
1216 assert_eq!(conflicts[0].expected_line, "line2");
1217 assert_eq!(conflicts[0].actual_line, "DIFFERENT");
1218 assert_eq!(conflicts[0].conflict_type, ConflictType::ContextMismatch);
1219 }
1220
1221 #[test]
1222 fn test_generate_diff_from_two_strings() {
1223 let old = "line1\nline2\nline3";
1224 let new = "line1\nmodified\nline3";
1225 let diff = generate_unified_diff(old, new, "file.txt", "file.txt");
1226 assert!(diff.contains("--- a/file.txt"));
1227 assert!(diff.contains("+++ b/file.txt"));
1228 assert!(diff.contains("-line2"));
1229 assert!(diff.contains("+modified"));
1230 }
1231
1232 #[test]
1233 fn test_generated_diff_can_be_parsed_back() {
1234 let old = "alpha\nbeta\ngamma\ndelta";
1235 let new = "alpha\nBETA\ngamma\ndelta";
1236 let diff = generate_unified_diff(old, new, "test.txt", "test.txt");
1237 let parsed = parse_unified_diff(&diff).expect("should parse generated diff");
1238 assert_eq!(parsed.patches.len(), 1);
1239 assert!(!parsed.patches[0].hunks.is_empty());
1240 }
1241
1242 #[test]
1243 fn test_round_trip_generate_parse_apply() {
1244 let old = "fn main() {\n println!(\"hello\");\n let x = 1;\n}";
1245 let new = "fn main() {\n println!(\"world\");\n let x = 1;\n let y = 2;\n}";
1246 let diff = generate_unified_diff(old, new, "main.rs", "main.rs");
1247 let parsed = parse_unified_diff(&diff).expect("should parse");
1248 assert_eq!(parsed.patches.len(), 1);
1249 let result = apply_patch(old, &parsed.patches[0]).expect("should apply");
1250 assert_eq!(result, new);
1251 }
1252
1253 #[test]
1254 fn test_reverse_patch_roundtrip() {
1255 let original = "line1\nline2\nline3";
1256 let patch = FilePatch {
1257 old_path: "f.txt".into(),
1258 new_path: "f.txt".into(),
1259 hunks: vec![PatchHunk {
1260 old_start: 1,
1261 old_count: 3,
1262 new_start: 1,
1263 new_count: 3,
1264 lines: vec![
1265 PatchLine::Context("line1".into()),
1266 PatchLine::Remove("line2".into()),
1267 PatchLine::Add("CHANGED".into()),
1268 PatchLine::Context("line3".into()),
1269 ],
1270 }],
1271 is_new_file: false,
1272 is_deleted: false,
1273 };
1274 let patched = apply_patch(original, &patch).expect("should apply");
1275 assert_eq!(patched, "line1\nCHANGED\nline3");
1276
1277 let reversed = reverse_patch(&patch);
1278 let restored = apply_patch(&patched, &reversed).expect("should apply reverse");
1279 assert_eq!(restored, original);
1280 }
1281
1282 #[test]
1283 fn test_empty_diff() {
1284 let ps = parse_unified_diff("").expect("should parse empty");
1285 assert!(ps.patches.is_empty());
1286 }
1287
1288 #[test]
1289 fn test_hunk_header_various_formats() {
1290 let (s, c, ns, nc) = parse_hunk_header("@@ -1,3 +1,4 @@").expect("should parse");
1292 assert_eq!((s, c, ns, nc), (1, 3, 1, 4));
1293
1294 let (s, c, ns, nc) = parse_hunk_header("@@ -1 +1 @@").expect("should parse");
1296 assert_eq!((s, c, ns, nc), (1, 1, 1, 1));
1297
1298 let (s, c, ns, nc) =
1300 parse_hunk_header("@@ -10,5 +12,7 @@ fn some_function()").expect("should parse");
1301 assert_eq!((s, c, ns, nc), (10, 5, 12, 7));
1302
1303 let (s, c, ns, nc) = parse_hunk_header("@@ -0,0 +1,3 @@").expect("should parse");
1305 assert_eq!((s, c, ns, nc), (0, 0, 1, 3));
1306 }
1307
1308 #[test]
1309 fn test_parse_diff_with_git_prefix() {
1310 let diff = "\
1311diff --git a/foo.rs b/foo.rs
1312index abc123..def456 100644
1313--- a/foo.rs
1314+++ b/foo.rs
1315@@ -1,2 +1,2 @@
1316 line1
1317-old
1318+new
1319";
1320 let ps = parse_unified_diff(diff).expect("should parse with git prefix");
1321 assert_eq!(ps.patches.len(), 1);
1322 assert_eq!(ps.patches[0].old_path, "foo.rs");
1323 assert_eq!(ps.patches[0].hunks.len(), 1);
1324 }
1325
1326 #[test]
1327 fn test_generate_empty_diff_for_identical_content() {
1328 let content = "same\ncontent\nhere";
1329 let diff = generate_unified_diff(content, content, "f.txt", "f.txt");
1330 assert!(
1331 diff.is_empty(),
1332 "identical content should produce empty diff"
1333 );
1334 }
1335}