1#![allow(unused)]
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5/// Reorder selected groups of edits (additions & deletions) into a new patch.
6///
7/// Intuition:
8/// Think of the original patch as a timeline of atomic edit indices (0..N),
9/// where one edit is one deleted or inserted line.
10/// This function recombines these edits into a new patch which can be thought
11/// of as a sequence of patches.
12///
13/// You provide `edits_order` describing logical chunks (e.g., "write a feature",
14/// "refactor", "add tests"). For each group the function:
15/// 1. Extracts those edits
16/// 2. Appends them to the output patch
17/// 3. Removes them from an internal remainder so subsequent original indices
18/// still point to the right (yet-to-be-extracted) edits.
19///
20/// The returned `Patch` contains only the edits you listed, emitted group by
21/// group. The leftover remainder is discarded.
22///
23/// Parameters:
24/// * `patch` - Source patch
25/// * `edits_order` - Vector of sets of original (0-based) edit indexes
26///
27/// Returns:
28/// * A new `Patch` containing the grouped edits in the requested order.
29///
30/// Example:
31/// ```rust
32/// use std::collections::BTreeSet;
33/// use reorder_patch::{Patch, reorder_edits};
34///
35/// // Edits (indexes): 0:-old, 1:+new, 2:-old2, 3:+new2, 4:+added
36/// let diff = "\
37/// --- a/a.txt
38/// +++ b/a.txt
39/// @@ -1,3 +1,3 @@
40/// one
41/// -old
42/// +new
43/// end
44/// @@ -5,3 +5,4 @@
45/// tail
46/// -old2
47/// +new2
48/// +added
49/// fin
50/// ";
51/// let patch = Patch::parse_unified_diff(diff);
52///
53/// // First take the part of the second hunk's edits (2),
54/// // then the first hunk (0,1), then the rest of the second hunk (3,4)
55/// let order = vec![BTreeSet::from([2]), BTreeSet::from([0, 1]), BTreeSet::from([3, 4])];
56/// let reordered = reorder_edits(&patch, order);
57/// println!("{}", reordered.to_string());
58/// ```
59pub fn reorder_edits(patch: &Patch, edits_order: Vec<BTreeSet<usize>>) -> Patch {
60 let mut result = Patch {
61 header: patch.header.clone(),
62 hunks: Vec::new(),
63 };
64
65 let mut remainder = patch.clone();
66
67 // Indexes in `edits_order` will shift as we apply edits.
68 // This structure maps the original index to the actual index.
69 let stats = patch.stats();
70 let total_edits = stats.added + stats.removed;
71 let mut indexes_map = BTreeMap::from_iter((0..total_edits).map(|i| (i, Some(i))));
72
73 for patch_edits_order in edits_order {
74 // Skip duplicated indexes that were already processed
75 let patch_edits_order = patch_edits_order
76 .into_iter()
77 .filter(|&i| indexes_map[&i].is_some()) // skip duplicated indexes
78 .collect::<BTreeSet<_>>();
79
80 if patch_edits_order.is_empty() {
81 continue;
82 }
83
84 let order = patch_edits_order
85 .iter()
86 .map(|&i| {
87 indexes_map[&i].unwrap_or_else(|| panic!("Edit index {i} has been already used. Perhaps your spec contains duplicates"))
88 })
89 .collect::<BTreeSet<_>>();
90
91 let extracted;
92 (extracted, remainder) = extract_edits(&remainder, &order);
93
94 result.hunks.extend(extracted.hunks);
95
96 // Update indexes_map to reflect applied edits. For example:
97 //
98 // Original_index | Removed? | Mapped_value
99 // 0 | false | 0
100 // 1 | true | None
101 // 2 | true | None
102 // 3 | false | 1
103
104 for index in patch_edits_order {
105 indexes_map.insert(index, None);
106 for j in (index + 1)..total_edits {
107 if let Some(val) = indexes_map[&j] {
108 indexes_map.insert(j, Some(val - 1));
109 }
110 }
111 }
112 }
113
114 result
115}
116
117/// Split a patch into (extracted, remainder) based on a set of edit indexes.
118/// The first returned patch contains only the chosen edits; the second contains
119/// everything else with those edits applied (converted into context).
120pub fn extract_edits(patch: &Patch, edit_indexes: &BTreeSet<usize>) -> (Patch, Patch) {
121 let mut extracted = patch.clone();
122 let mut remainder = patch.clone();
123
124 let stats = patch.stats();
125 let num_edits = stats.added + stats.removed;
126 let this_edits = edit_indexes.iter().cloned().collect::<Vec<_>>();
127 let other_edits = (0..num_edits)
128 .filter(|i| !edit_indexes.contains(i))
129 .collect();
130
131 remove_edits(&mut extracted, other_edits);
132 apply_edits(&mut remainder, this_edits);
133
134 (extracted, remainder)
135}
136
137#[derive(Debug, Default, Clone)]
138pub struct Patch {
139 pub header: String,
140 pub hunks: Vec<Hunk>,
141}
142
143pub struct DiffStats {
144 pub added: usize,
145 pub removed: usize,
146}
147
148impl ToString for Patch {
149 fn to_string(&self) -> String {
150 let mut result = self.header.clone();
151 for hunk in &self.hunks {
152 let current_file = hunk.filename.clone();
153 if hunk.is_file_creation() {
154 result.push_str("--- /dev/null\n");
155 } else {
156 result.push_str(&format!("--- a/{}\n", current_file));
157 }
158 result.push_str(&format!("+++ b/{}\n", current_file));
159 result.push_str(&hunk.to_string());
160 }
161
162 result
163 }
164}
165
166impl Patch {
167 /// Parse a unified diff (git style) string into a `Patch`.
168 pub fn parse_unified_diff(unified_diff: &str) -> Patch {
169 let mut current_file = String::new();
170 let mut is_filename_inherited = false;
171 let mut hunk = Hunk::default();
172 let mut patch = Patch::default();
173 let mut in_header = true;
174
175 for line in unified_diff.lines() {
176 if line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@") {
177 in_header = false;
178 }
179
180 if in_header {
181 patch.header.push_str(format!("{}\n", &line).as_ref());
182 continue;
183 }
184
185 if line.starts_with("@@") {
186 if !hunk.lines.is_empty() {
187 patch.hunks.push(hunk);
188 }
189 hunk = Hunk::from_header(line, ¤t_file, is_filename_inherited);
190 is_filename_inherited = true;
191 } else if let Some(path) = line.strip_prefix("--- ") {
192 is_filename_inherited = false;
193 current_file = path.trim().strip_prefix("a/").unwrap_or(path).into();
194 } else if let Some(path) = line.strip_prefix("+++ ") {
195 is_filename_inherited = false;
196 current_file = path.trim().strip_prefix("b/").unwrap_or(path).into();
197 } else if let Some(line) = line.strip_prefix("+") {
198 hunk.lines.push(PatchLine::Addition(line.to_string()));
199 } else if let Some(line) = line.strip_prefix("-") {
200 hunk.lines.push(PatchLine::Deletion(line.to_string()));
201 } else if let Some(line) = line.strip_prefix(" ") {
202 hunk.lines.push(PatchLine::Context(line.to_string()));
203 } else {
204 hunk.lines.push(PatchLine::Garbage(line.to_string()));
205 }
206 }
207
208 if !hunk.lines.is_empty() {
209 patch.hunks.push(hunk);
210 }
211
212 let header_lines = patch.header.lines().collect::<Vec<&str>>();
213 let len = header_lines.len();
214 if len >= 2 {
215 if header_lines[len - 2].starts_with("diff --git")
216 && header_lines[len - 1].starts_with("index ")
217 {
218 patch.header = header_lines[..len - 2].join("\n") + "\n";
219 }
220 }
221 if patch.header.trim().is_empty() {
222 patch.header = String::new();
223 }
224
225 patch
226 }
227
228 /// Drop hunks that contain no additions or deletions.
229 pub fn remove_empty_hunks(&mut self) {
230 self.hunks.retain(|hunk| {
231 hunk.lines
232 .iter()
233 .any(|line| matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_)))
234 });
235 }
236
237 /// Make sure there are no more than `context_lines` lines of context around each change.
238 pub fn normalize_hunks(&mut self, context_lines: usize) {
239 for hunk in &mut self.hunks {
240 // Find indices of all changes (additions and deletions)
241 let change_indices: Vec<usize> = hunk
242 .lines
243 .iter()
244 .enumerate()
245 .filter_map(|(i, line)| match line {
246 PatchLine::Addition(_) | PatchLine::Deletion(_) => Some(i),
247 _ => None,
248 })
249 .collect();
250
251 // If there are no changes, clear the hunk (it's all context)
252 if change_indices.is_empty() {
253 hunk.lines.clear();
254 hunk.old_count = 0;
255 hunk.new_count = 0;
256 continue;
257 }
258
259 // Determine the range to keep
260 let first_change = change_indices[0];
261 let last_change = change_indices[change_indices.len() - 1];
262
263 let start = first_change.saturating_sub(context_lines);
264 let end = (last_change + context_lines + 1).min(hunk.lines.len());
265
266 // Count lines trimmed from the beginning
267 let (old_lines_before, new_lines_before) = count_lines(&hunk.lines[0..start]);
268
269 // Keep only the lines in range + garbage
270 let garbage_before = hunk.lines[..start]
271 .iter()
272 .filter(|line| matches!(line, PatchLine::Garbage(_)));
273 let garbage_after = hunk.lines[end..]
274 .iter()
275 .filter(|line| matches!(line, PatchLine::Garbage(_)));
276
277 hunk.lines = garbage_before
278 .chain(hunk.lines[start..end].iter())
279 .chain(garbage_after)
280 .cloned()
281 .collect();
282
283 // Update hunk header
284 let (old_count, new_count) = count_lines(&hunk.lines);
285 hunk.old_start += old_lines_before as isize;
286 hunk.new_start += new_lines_before as isize;
287 hunk.old_count = old_count as isize;
288 hunk.new_count = new_count as isize;
289 }
290 }
291
292 /// Count total added and removed lines
293 pub fn stats(&self) -> DiffStats {
294 let mut added = 0;
295 let mut removed = 0;
296
297 for hunk in &self.hunks {
298 for line in &hunk.lines {
299 match line {
300 PatchLine::Addition(_) => added += 1,
301 PatchLine::Deletion(_) => removed += 1,
302 _ => {}
303 }
304 }
305 }
306
307 DiffStats { added, removed }
308 }
309}
310
311#[derive(Debug, Default, Clone)]
312pub struct Hunk {
313 pub old_start: isize,
314 pub old_count: isize,
315 pub new_start: isize,
316 pub new_count: isize,
317 pub comment: String,
318 pub filename: String,
319 pub is_filename_inherited: bool,
320 pub lines: Vec<PatchLine>,
321}
322
323impl ToString for Hunk {
324 fn to_string(&self) -> String {
325 let header = self.header_string();
326 let lines = self
327 .lines
328 .iter()
329 .map(|line| line.to_string() + "\n")
330 .collect::<Vec<String>>()
331 .join("");
332 format!("{header}\n{lines}")
333 }
334}
335
336impl Hunk {
337 /// Returns true if this hunk represents a file creation (old side is empty).
338 pub fn is_file_creation(&self) -> bool {
339 self.old_start == 0 && self.old_count == 0
340 }
341
342 /// Render the hunk header
343 pub fn header_string(&self) -> String {
344 format!(
345 "@@ -{},{} +{},{} @@ {}",
346 self.old_start,
347 self.old_count,
348 self.new_start,
349 self.new_count,
350 self.comment.clone()
351 )
352 .trim_end()
353 .into()
354 }
355
356 /// Create a `Hunk` from a raw header line and associated filename.
357 pub fn from_header(header: &str, filename: &str, is_filename_inherited: bool) -> Self {
358 let (old_start, old_count, new_start, new_count, comment) = Self::parse_hunk_header(header);
359 Self {
360 old_start,
361 old_count,
362 new_start,
363 new_count,
364 comment,
365 filename: filename.to_string(),
366 is_filename_inherited,
367 lines: Vec::new(),
368 }
369 }
370
371 /// Parse hunk headers like `@@ -3,2 +3,2 @@ some garbage"
372 fn parse_hunk_header(line: &str) -> (isize, isize, isize, isize, String) {
373 let header_part = line.trim_start_matches("@@").trim();
374 let parts: Vec<&str> = header_part.split_whitespace().collect();
375
376 if parts.len() < 2 {
377 return (0, 0, 0, 0, String::new());
378 }
379
380 let old_part = parts[0].trim_start_matches('-');
381 let new_part = parts[1].trim_start_matches('+');
382
383 let (old_start, old_count) = Hunk::parse_hunk_header_range(old_part);
384 let (new_start, new_count) = Hunk::parse_hunk_header_range(new_part);
385
386 let comment = if parts.len() > 2 {
387 parts[2..]
388 .join(" ")
389 .trim_start_matches("@@")
390 .trim()
391 .to_string()
392 } else {
393 String::new()
394 };
395
396 (
397 old_start as isize,
398 old_count as isize,
399 new_start as isize,
400 new_count as isize,
401 comment,
402 )
403 }
404
405 fn parse_hunk_header_range(part: &str) -> (usize, usize) {
406 let (old_start, old_count) = if part.contains(',') {
407 let old_parts: Vec<&str> = part.split(',').collect();
408 (
409 old_parts[0].parse().unwrap_or(0),
410 old_parts[1].parse().unwrap_or(0),
411 )
412 } else {
413 (part.parse().unwrap_or(0), 1)
414 };
415 (old_start, old_count)
416 }
417}
418
419#[derive(Clone, Debug, Eq, PartialEq)]
420pub enum PatchLine {
421 Context(String),
422 Addition(String),
423 Deletion(String),
424 HunkHeader(usize, usize, usize, usize, String),
425 FileStartMinus(String),
426 FileStartPlus(String),
427 Garbage(String),
428}
429
430impl PatchLine {
431 pub fn parse(line: &str) -> Self {
432 if let Some(line) = line.strip_prefix("+") {
433 Self::Addition(line.to_string())
434 } else if let Some(line) = line.strip_prefix("-") {
435 Self::Deletion(line.to_string())
436 } else if let Some(line) = line.strip_prefix(" ") {
437 Self::Context(line.to_string())
438 } else {
439 Self::Garbage(line.to_string())
440 }
441 }
442}
443
444impl ToString for PatchLine {
445 fn to_string(&self) -> String {
446 match self {
447 PatchLine::Context(line) => format!(" {}", line),
448 PatchLine::Addition(line) => format!("+{}", line),
449 PatchLine::Deletion(line) => format!("-{}", line),
450 PatchLine::HunkHeader(old_start, old_end, new_start, new_end, comment) => format!(
451 "@@ -{},{} +{},{} @@ {}",
452 old_start, old_end, new_start, new_end, comment
453 )
454 .trim_end()
455 .into(),
456 PatchLine::FileStartMinus(filename) => format!("--- {}", filename),
457 PatchLine::FileStartPlus(filename) => format!("+++ {}", filename),
458 PatchLine::Garbage(line) => line.to_string(),
459 }
460 }
461}
462
463///
464/// Removes specified edits from a patch by their indexes and adjusts line numbers accordingly.
465///
466/// This function removes edits (additions and deletions) from the patch as they never were made.
467/// The resulting patch is adjusted to maintain correctness.
468///
469/// # Arguments
470///
471/// * `patch` - A patch to modify
472/// * `edit_indexes` - A vector of edit indexes to remove (0-based, counting only additions and deletions)
473/// ```
474pub fn remove_edits(patch: &mut Patch, edit_indexes: Vec<usize>) {
475 let mut current_edit_index: isize = -1;
476 let mut new_start_delta_by_file: HashMap<String, isize> = HashMap::new();
477
478 for hunk in &mut patch.hunks {
479 if !hunk.is_filename_inherited {
480 new_start_delta_by_file.insert(hunk.filename.clone(), 0);
481 }
482 let delta = new_start_delta_by_file
483 .entry(hunk.filename.clone())
484 .or_insert(0);
485 hunk.new_start += *delta;
486
487 hunk.lines = hunk
488 .lines
489 .drain(..)
490 .filter_map(|line| {
491 let is_edit = matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_));
492 if is_edit {
493 current_edit_index += 1;
494 if !edit_indexes.contains(&(current_edit_index as usize)) {
495 return Some(line);
496 }
497 }
498 match line {
499 PatchLine::Addition(_) => {
500 hunk.new_count -= 1;
501 *delta -= 1;
502 None
503 }
504 PatchLine::Deletion(content) => {
505 hunk.new_count += 1;
506 *delta += 1;
507 Some(PatchLine::Context(content))
508 }
509 _ => Some(line),
510 }
511 })
512 .collect();
513 }
514
515 patch.normalize_hunks(3);
516 patch.remove_empty_hunks();
517}
518
519///
520/// Apply specified edits in the patch.
521///
522/// This generates another patch that looks like selected edits are already made
523/// and became part of the context
524///
525/// See also: `remove_edits()`
526///
527pub fn apply_edits(patch: &mut Patch, edit_indexes: Vec<usize>) {
528 let mut current_edit_index: isize = -1;
529 let mut delta_by_file: HashMap<String, isize> = HashMap::new();
530
531 for hunk in &mut patch.hunks {
532 if !hunk.is_filename_inherited {
533 delta_by_file.insert(hunk.filename.clone(), 0);
534 }
535 let delta = delta_by_file.entry(hunk.filename.clone()).or_insert(0);
536 hunk.old_start += *delta;
537
538 hunk.lines = hunk
539 .lines
540 .drain(..)
541 .filter_map(|line| {
542 let is_edit = matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_));
543 if is_edit {
544 current_edit_index += 1;
545 if !edit_indexes.contains(&(current_edit_index as usize)) {
546 return Some(line);
547 }
548 }
549 match line {
550 PatchLine::Addition(content) => {
551 hunk.old_count += 1;
552 *delta += 1;
553 Some(PatchLine::Context(content))
554 }
555 PatchLine::Deletion(_) => {
556 hunk.old_count -= 1;
557 *delta -= 1;
558 None
559 }
560 _ => Some(line),
561 }
562 })
563 .collect();
564 }
565
566 patch.normalize_hunks(3);
567 patch.remove_empty_hunks();
568}
569
570/// Parse an order specification text into groups of edit indexes.
571/// Supports numbers, ranges (a-b), commas, comments starting with `//`, and blank lines.
572///
573/// # Example spec
574///
575/// // Add new dependency
576/// 1, 49
577///
578/// // Add new imports and types
579/// 8-9, 51
580///
581/// // Add new struct and methods
582/// 10-47
583///
584/// // Update tests
585/// 48, 50
586///
587pub fn parse_order_spec(spec: &str) -> Vec<BTreeSet<usize>> {
588 let mut order = Vec::new();
589
590 for line in spec.lines() {
591 let line = line.trim();
592
593 // Skip empty lines and comments
594 if line.is_empty() || line.starts_with("//") {
595 continue;
596 }
597
598 // Parse the line into a BTreeSet
599 let mut set = BTreeSet::new();
600
601 for part in line.split(',') {
602 let part = part.trim();
603
604 if part.contains('-') {
605 // Handle ranges like "8-9" or "10-47"
606 let range_parts: Vec<&str> = part.split('-').collect();
607 if range_parts.len() == 2 {
608 if let (Ok(start), Ok(end)) = (
609 range_parts[0].parse::<usize>(),
610 range_parts[1].parse::<usize>(),
611 ) {
612 for i in start..=end {
613 set.insert(i);
614 }
615 } else {
616 eprintln!("Warning: Invalid range format '{}'", part);
617 }
618 } else {
619 eprintln!("Warning: Invalid range format '{}'", part);
620 }
621 } else {
622 // Handle single numbers
623 if let Ok(num) = part.parse::<usize>() {
624 set.insert(num);
625 } else {
626 eprintln!("Warning: Invalid number format '{}'", part);
627 }
628 }
629 }
630
631 if !set.is_empty() {
632 order.push(set);
633 }
634 }
635
636 order
637}
638
639#[derive(Debug, Eq, PartialEq)]
640pub struct EditLocation {
641 pub filename: String,
642 pub source_line_number: usize,
643 pub target_line_number: usize,
644 pub patch_line: PatchLine,
645 pub hunk_index: usize,
646 pub line_index_within_hunk: usize,
647}
648
649#[derive(Debug, Eq, PartialEq)]
650pub enum EditType {
651 Deletion,
652 Insertion,
653}
654
655pub fn locate_edited_line(patch: &Patch, mut edit_index: isize) -> Option<EditLocation> {
656 let mut edit_locations = vec![];
657
658 for (hunk_index, hunk) in patch.hunks.iter().enumerate() {
659 let mut old_line_number = hunk.old_start;
660 let mut new_line_number = hunk.new_start;
661 for (line_index, line) in hunk.lines.iter().enumerate() {
662 if matches!(line, PatchLine::Context(_)) {
663 old_line_number += 1;
664 new_line_number += 1;
665 continue;
666 }
667
668 if !matches!(line, PatchLine::Addition(_) | PatchLine::Deletion(_)) {
669 continue;
670 }
671
672 // old new
673 // 1 1 context
674 // 2 2 context
675 // 3 3 -deleted
676 // 4 3 +insert
677 // 4 4 more context
678 //
679 // old new
680 // 1 1 context
681 // 2 2 context
682 // 3 3 +inserted
683 // 3 4 more context
684 //
685 // old new
686 // 1 1 -deleted
687 //
688 // old new
689 // 1 1 context
690 // 2 2 context
691 // 3 3 -deleted
692 // 4 3 more context
693
694 edit_locations.push(EditLocation {
695 filename: hunk.filename.clone(),
696 source_line_number: old_line_number as usize,
697 target_line_number: new_line_number as usize,
698 patch_line: line.clone(),
699 hunk_index,
700 line_index_within_hunk: line_index,
701 });
702
703 match line {
704 PatchLine::Addition(_) => new_line_number += 1,
705 PatchLine::Deletion(_) => old_line_number += 1,
706 PatchLine::Context(_) => (),
707 _ => (),
708 };
709 }
710 }
711
712 if edit_index < 0 {
713 edit_index += edit_locations.len() as isize; // take from end
714 }
715 (0..edit_locations.len())
716 .contains(&(edit_index as usize))
717 .then(|| edit_locations.swap_remove(edit_index as usize)) // remove to take ownership
718}
719//
720// Helper function to count old and new lines
721fn count_lines(lines: &[PatchLine]) -> (usize, usize) {
722 lines.iter().fold((0, 0), |(old, new), line| match line {
723 PatchLine::Context(_) => (old + 1, new + 1),
724 PatchLine::Deletion(_) => (old + 1, new),
725 PatchLine::Addition(_) => (old, new + 1),
726 _ => (old, new),
727 })
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use indoc::indoc;
734
735 #[test]
736 fn test_parse_unified_diff() {
737 let patch_str = indoc! {"
738 Patch header
739 ============
740
741 diff --git a/text.txt b/text.txt
742 index 86c770d..a1fd855 100644
743 --- a/text.txt
744 +++ b/text.txt
745 @@ -1,7 +1,7 @@
746 azuere
747 beige
748 black
749 -blue
750 +dark blue
751 brown
752 cyan
753 gold
754
755 Some garbage
756
757 diff --git a/second.txt b/second.txt
758 index 86c770d..a1fd855 100644
759 --- a/second.txt
760 +++ b/second.txt
761 @@ -9,6 +9,7 @@ gray
762 green
763 indigo
764 magenta
765 +silver
766 orange
767 pink
768 purple
769 diff --git a/text.txt b/text.txt
770 index 86c770d..a1fd855 100644
771 --- a/text.txt
772 +++ b/text.txt
773 @@ -16,4 +17,3 @@ red
774 violet
775 white
776 yellow
777 -zinc
778 "};
779 let patch = Patch::parse_unified_diff(patch_str);
780
781 assert_eq!(patch.header, "Patch header\n============\n\n");
782 assert_eq!(patch.hunks.len(), 3);
783 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,7 @@");
784 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +9,7 @@ gray");
785 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +17,3 @@ red");
786 assert_eq!(patch.hunks[0].is_filename_inherited, false);
787 assert_eq!(patch.hunks[1].is_filename_inherited, false);
788 assert_eq!(patch.hunks[2].is_filename_inherited, false);
789 }
790
791 #[test]
792 fn test_locate_edited_line() {
793 let patch_str = indoc! {"
794 Patch header
795 ============
796
797 diff --git a/text.txt b/text.txt
798 index 86c770d..a1fd855 100644
799 --- a/text.txt
800 +++ b/text.txt
801 @@ -1,7 +1,7 @@
802 azuere
803 beige
804 black
805 -blue
806 +dark blue
807 brown
808 cyan
809 gold
810 diff --git a/second.txt b/second.txt
811 index 86c770d..a1fd855 100644
812 --- a/second.txt
813 +++ b/second.txt
814 @@ -9,6 +9,7 @@ gray
815 green
816 indigo
817 magenta
818 +silver
819 orange
820 pink
821 purple
822 diff --git a/text.txt b/text.txt
823 index 86c770d..a1fd855 100644
824 --- a/text.txt
825 +++ b/text.txt
826 @@ -16,4 +17,3 @@ red
827 violet
828 white
829 yellow
830 -zinc
831 "};
832 let patch = Patch::parse_unified_diff(patch_str);
833
834 assert_eq!(
835 locate_edited_line(&patch, 0), // -blue
836 Some(EditLocation {
837 filename: "text.txt".to_string(),
838 source_line_number: 4,
839 target_line_number: 4,
840 patch_line: PatchLine::Deletion("blue".to_string()),
841 hunk_index: 0,
842 line_index_within_hunk: 3
843 })
844 );
845 assert_eq!(
846 locate_edited_line(&patch, 1), // +dark blue
847 Some(EditLocation {
848 filename: "text.txt".to_string(),
849 source_line_number: 5,
850 target_line_number: 4,
851 patch_line: PatchLine::Addition("dark blue".to_string()),
852 hunk_index: 0,
853 line_index_within_hunk: 4
854 })
855 );
856 assert_eq!(
857 locate_edited_line(&patch, 2), // +silver
858 Some(EditLocation {
859 filename: "second.txt".to_string(),
860 source_line_number: 12,
861 target_line_number: 12,
862 patch_line: PatchLine::Addition("silver".to_string()),
863 hunk_index: 1,
864 line_index_within_hunk: 3
865 })
866 );
867 }
868
869 mod remove_edits {
870 use super::*;
871 use indoc::indoc;
872 use pretty_assertions::assert_eq;
873
874 static PATCH: &'static str = indoc! {"
875 diff --git a/text.txt b/text.txt
876 index 86c770d..a1fd855 100644
877 --- a/text.txt
878 +++ b/text.txt
879 @@ -1,7 +1,7 @@
880 azuere
881 beige
882 black
883 -blue
884 +dark blue
885 brown
886 cyan
887 gold
888 @@ -9,6 +9,7 @@ gray
889 green
890 indigo
891 magenta
892 +silver
893 orange
894 pink
895 purple
896 @@ -16,4 +17,3 @@ red
897 violet
898 white
899 yellow
900 -zinc
901 "};
902
903 #[test]
904 fn test_removes_hunks_without_edits() {
905 // Remove the first two edits:
906 // -blue
907 // +dark blue
908 let mut patch = Patch::parse_unified_diff(PATCH);
909 remove_edits(&mut patch, vec![0, 1]);
910
911 // The whole hunk should be removed since there are no other edits in it
912 let actual = patch.to_string();
913 let expected = indoc! {"
914 --- a/text.txt
915 +++ b/text.txt
916 @@ -9,6 +9,7 @@ gray
917 green
918 indigo
919 magenta
920 +silver
921 orange
922 pink
923 purple
924 --- a/text.txt
925 +++ b/text.txt
926 @@ -16,4 +17,3 @@ red
927 violet
928 white
929 yellow
930 -zinc
931 "};
932 assert_eq!(actual, String::from(expected));
933 }
934
935 #[test]
936 fn test_adjust_line_numbers_after_deletion() {
937 // Remove the first deletion (`-blue`)
938 let mut patch = Patch::parse_unified_diff(PATCH);
939 remove_edits(&mut patch, vec![0]);
940
941 // The line numbers should be adjusted in the subsequent hunks
942 println!("{}", &patch.to_string());
943 assert_eq!(patch.hunks[0].header_string(), "@@ -2,6 +2,7 @@");
944 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +10,7 @@ gray");
945 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +18,3 @@ red");
946 }
947 #[test]
948 fn test_adjust_line_numbers_after_insertion() {
949 // Remove the first insertion (`+dark blue`)
950 let mut patch = Patch::parse_unified_diff(PATCH);
951 remove_edits(&mut patch, vec![1]);
952
953 // The line numbers should be adjusted in the subsequent hunks
954 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@");
955 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +8,7 @@ gray");
956 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +16,3 @@ red");
957 }
958 #[test]
959 fn test_adjust_line_numbers_multifile_case() {
960 // Given a patch that spans multiple files
961 let patch_str = indoc! {"
962 --- a/first.txt
963 +++ b/first.txt
964 @@ -1,7 +1,7 @@
965 azuere
966 beige
967 black
968 -blue
969 +dark blue
970 brown
971 cyan
972 gold
973 @@ -16,4 +17,3 @@ red
974 violet
975 white
976 yellow
977 -zinc
978 --- a/second.txt
979 +++ b/second.txt
980 @@ -9,6 +9,7 @@ gray
981 green
982 indigo
983 magenta
984 +silver
985 orange
986 pink
987 purple
988 "};
989
990 // When removing edit from one of the files (`+dark blue`)
991 let mut patch = Patch::parse_unified_diff(patch_str);
992 remove_edits(&mut patch, vec![1]);
993
994 // Then the line numbers should only be adjusted in subsequent hunks from that file
995 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@"); // edited hunk
996 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +16,3 @@ red"); // hunk from edited file again
997 assert_eq!(patch.hunks[2].header_string(), "@@ -9,6 +9,7 @@ gray"); // hunk from another file
998
999 // When removing hunk from `second.txt`
1000 let mut patch = Patch::parse_unified_diff(patch_str);
1001 remove_edits(&mut patch, vec![3]);
1002
1003 // Then patch serialization should list `first.txt` only once
1004 // (because hunks from that file become adjacent)
1005 let expected = indoc! {"
1006 --- a/first.txt
1007 +++ b/first.txt
1008 @@ -1,7 +1,7 @@
1009 azuere
1010 beige
1011 black
1012 -blue
1013 +dark blue
1014 brown
1015 cyan
1016 gold
1017 --- a/first.txt
1018 +++ b/first.txt
1019 @@ -16,4 +17,3 @@ red
1020 violet
1021 white
1022 yellow
1023 -zinc
1024 "};
1025 assert_eq!(patch.to_string(), expected);
1026 }
1027
1028 #[test]
1029 fn test_dont_adjust_line_numbers_samefile_case() {
1030 // Given a patch that has hunks in the same file, but with a file header
1031 // (which makes `git apply` flush edits so far and start counting lines numbers afresh)
1032 let patch_str = indoc! {"
1033 diff --git a/text.txt b/text.txt
1034 index 86c770d..a1fd855 100644
1035 --- a/text.txt
1036 +++ b/text.txt
1037 @@ -1,7 +1,7 @@
1038 azuere
1039 beige
1040 black
1041 -blue
1042 +dark blue
1043 brown
1044 cyan
1045 gold
1046 --- a/text.txt
1047 +++ b/text.txt
1048 @@ -16,4 +16,3 @@ red
1049 violet
1050 white
1051 yellow
1052 -zinc
1053 "};
1054
1055 // When removing edit from one of the files (`+dark blue`)
1056 let mut patch = Patch::parse_unified_diff(patch_str);
1057 remove_edits(&mut patch, vec![1]);
1058
1059 // Then the line numbers should **not** be adjusted in a subsequent hunk,
1060 // because it starts with a file header
1061 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@"); // edited hunk
1062 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +16,3 @@ red"); // subsequent hunk
1063 }
1064 }
1065
1066 mod apply_edits {
1067 use super::*;
1068 use indoc::indoc;
1069 use pretty_assertions::assert_eq;
1070
1071 static PATCH: &'static str = indoc! {"
1072 diff --git a/text.txt b/text.txt
1073 index 86c770d..a1fd855 100644
1074 --- a/text.txt
1075 +++ b/text.txt
1076 @@ -1,7 +1,7 @@
1077 azuere
1078 beige
1079 black
1080 -blue
1081 +dark blue
1082 brown
1083 cyan
1084 gold
1085 --- a/text.txt
1086 +++ b/text.txt
1087 @@ -9,6 +9,7 @@ gray
1088 green
1089 indigo
1090 magenta
1091 +silver
1092 orange
1093 pink
1094 purple
1095 --- a/text.txt
1096 +++ b/text.txt
1097 @@ -16,4 +17,3 @@ red
1098 violet
1099 white
1100 yellow
1101 -zinc
1102 "};
1103
1104 #[test]
1105 fn test_removes_hunks_without_edits() {
1106 // When applying the first two edits (`-blue`, `+dark blue`)
1107 let mut patch = Patch::parse_unified_diff(PATCH);
1108 apply_edits(&mut patch, vec![0, 1]);
1109
1110 // Then the whole hunk should be removed since there are no other edits in it,
1111 // and the line numbers should be adjusted in the subsequent hunks
1112 assert_eq!(patch.hunks[0].header_string(), "@@ -9,6 +9,7 @@ gray");
1113 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +17,3 @@ red");
1114 assert_eq!(patch.hunks.len(), 2);
1115 }
1116
1117 #[test]
1118 fn test_adjust_line_numbers_after_applying_deletion() {
1119 // Apply the first deletion (`-blue`)
1120 let mut patch = Patch::parse_unified_diff(PATCH);
1121 apply_edits(&mut patch, vec![0]);
1122
1123 // The line numbers should be adjusted
1124 assert_eq!(patch.hunks[0].header_string(), "@@ -1,6 +1,7 @@");
1125 assert_eq!(patch.hunks[1].header_string(), "@@ -8,6 +9,7 @@ gray");
1126 assert_eq!(patch.hunks[2].header_string(), "@@ -15,4 +17,3 @@ red");
1127 }
1128 #[test]
1129 fn test_adjust_line_numbers_after_applying_insertion() {
1130 // Apply the first insertion (`+dark blue`)
1131 let mut patch = Patch::parse_unified_diff(PATCH);
1132 apply_edits(&mut patch, vec![1]);
1133
1134 // The line numbers should be adjusted in the subsequent hunks
1135 println!("{}", &patch.to_string());
1136 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@");
1137 assert_eq!(patch.hunks[1].header_string(), "@@ -10,6 +9,7 @@ gray");
1138 assert_eq!(patch.hunks[2].header_string(), "@@ -17,4 +17,3 @@ red");
1139 }
1140 }
1141
1142 mod reorder_edits {
1143 use super::*;
1144 use indoc::indoc;
1145 use pretty_assertions::assert_eq;
1146
1147 static PATCH: &'static str = indoc! {"
1148 Some header.
1149
1150 diff --git a/first.txt b/first.txt
1151 index 86c770d..a1fd855 100644
1152 --- a/first.txt
1153 +++ b/first.txt
1154 @@ -1,7 +1,7 @@
1155 azuere
1156 beige
1157 black
1158 -blue
1159 +dark blue
1160 brown
1161 cyan
1162 gold
1163 --- a/second.txt
1164 +++ b/second.txt
1165 @@ -9,6 +9,7 @@ gray
1166 green
1167 indigo
1168 magenta
1169 +silver
1170 orange
1171 pink
1172 purple
1173 --- a/first.txt
1174 +++ b/first.txt
1175 @@ -16,4 +17,3 @@ red
1176 violet
1177 white
1178 yellow
1179 -zinc
1180 "};
1181
1182 #[test]
1183 fn test_reorder_1() {
1184 let edits_order = vec![
1185 BTreeSet::from([2]), // +silver
1186 BTreeSet::from([3]), // -zinc
1187 BTreeSet::from([0, 1]), // -blue +dark blue
1188 ];
1189
1190 let patch = Patch::parse_unified_diff(PATCH);
1191 let reordered_patch = reorder_edits(&patch, edits_order);
1192
1193 // The whole hunk should be removed since there are no other edits in it
1194 let actual = reordered_patch.to_string();
1195
1196 println!("{}", actual);
1197
1198 let expected = indoc! {"
1199 Some header.
1200
1201 --- a/second.txt
1202 +++ b/second.txt
1203 @@ -9,6 +9,7 @@ gray
1204 green
1205 indigo
1206 magenta
1207 +silver
1208 orange
1209 pink
1210 purple
1211 --- a/first.txt
1212 +++ b/first.txt
1213 @@ -16,4 +17,3 @@ red
1214 violet
1215 white
1216 yellow
1217 -zinc
1218 --- a/first.txt
1219 +++ b/first.txt
1220 @@ -1,7 +1,7 @@
1221 azuere
1222 beige
1223 black
1224 -blue
1225 +dark blue
1226 brown
1227 cyan
1228 gold
1229 "};
1230 assert_eq!(actual, String::from(expected));
1231 }
1232
1233 #[test]
1234 fn test_reorder_duplicates() {
1235 let edits_order = vec![
1236 BTreeSet::from([2]), // +silver
1237 BTreeSet::from([2]), // +silver again
1238 BTreeSet::from([3]), // -zinc
1239 ];
1240
1241 let patch = Patch::parse_unified_diff(PATCH);
1242 let reordered_patch = reorder_edits(&patch, edits_order);
1243
1244 // The whole hunk should be removed since there are no other edits in it
1245 let actual = reordered_patch.to_string();
1246
1247 println!("{}", actual);
1248
1249 let expected = indoc! {"
1250 Some header.
1251
1252 --- a/second.txt
1253 +++ b/second.txt
1254 @@ -9,6 +9,7 @@ gray
1255 green
1256 indigo
1257 magenta
1258 +silver
1259 orange
1260 pink
1261 purple
1262 --- a/first.txt
1263 +++ b/first.txt
1264 @@ -16,4 +17,3 @@ red
1265 violet
1266 white
1267 yellow
1268 -zinc
1269 "};
1270 assert_eq!(actual, String::from(expected));
1271 }
1272 }
1273
1274 mod extract_edits {
1275
1276 use super::*;
1277 use indoc::indoc;
1278 use pretty_assertions::assert_eq;
1279
1280 static PATCH: &'static str = indoc! {"
1281 Some header.
1282
1283 diff --git a/first.txt b/first.txt
1284 index 86c770d..a1fd855 100644
1285 --- a/first.txt
1286 +++ b/first.txt
1287 @@ -1,7 +1,7 @@
1288 azuere
1289 beige
1290 black
1291 -blue
1292 +dark blue
1293 brown
1294 cyan
1295 gold
1296 @@ -16,4 +17,3 @@ red
1297 violet
1298 white
1299 yellow
1300 -zinc
1301 --- a/second.txt
1302 +++ b/second.txt
1303 @@ -9,6 +9,7 @@ gray
1304 green
1305 indigo
1306 magenta
1307 +silver
1308 orange
1309 pink
1310 purple
1311 "};
1312
1313 #[test]
1314 fn test_extract_edits() {
1315 let to_extract = BTreeSet::from([
1316 3, // +silver
1317 0, // -blue
1318 ]);
1319
1320 let mut patch = Patch::parse_unified_diff(PATCH);
1321 let (extracted, remainder) = extract_edits(&mut patch, &to_extract);
1322
1323 // Edits will be extracted in the sorted order, so [0, 3]
1324 let expected_extracted = indoc! {"
1325 Some header.
1326
1327 --- a/first.txt
1328 +++ b/first.txt
1329 @@ -1,7 +1,6 @@
1330 azuere
1331 beige
1332 black
1333 -blue
1334 brown
1335 cyan
1336 gold
1337 --- a/second.txt
1338 +++ b/second.txt
1339 @@ -9,6 +9,7 @@ gray
1340 green
1341 indigo
1342 magenta
1343 +silver
1344 orange
1345 pink
1346 purple
1347 "};
1348
1349 let expected_remainder = indoc! {"
1350 Some header.
1351
1352 --- a/first.txt
1353 +++ b/first.txt
1354 @@ -1,6 +1,7 @@
1355 azuere
1356 beige
1357 black
1358 +dark blue
1359 brown
1360 cyan
1361 gold
1362 --- a/first.txt
1363 +++ b/first.txt
1364 @@ -15,4 +17,3 @@ red
1365 violet
1366 white
1367 yellow
1368 -zinc
1369 "};
1370 assert_eq!(extracted.to_string(), String::from(expected_extracted));
1371 assert_eq!(remainder.to_string(), String::from(expected_remainder));
1372 }
1373 }
1374
1375 #[test]
1376 fn test_parse_order_file() {
1377 let content = r#"
1378// Add new dependency
13791, 49
1380
1381// Add new imports and types
13828-9, 51
1383
1384// Add new struct and login command method
138510-47
1386
1387// Modify AgentServerDelegate to make status_tx optional
13882-3
1389
1390// Update status_tx usage to handle optional value
13914
13925-7
1393
1394// Update all existing callers to use None for status_tx
139548, 50
1396
1397// Update the main login implementation to use custom command
139852-55
139956-95
1400"#;
1401
1402 let order = parse_order_spec(content);
1403
1404 assert_eq!(order.len(), 9);
1405
1406 // First group: 1, 49
1407 assert_eq!(order[0], BTreeSet::from([1, 49]));
1408
1409 // Second group: 8-9, 51
1410 assert_eq!(order[1], BTreeSet::from([8, 9, 51]));
1411
1412 // Third group: 10-47
1413 let expected_range: BTreeSet<usize> = (10..=47).collect();
1414 assert_eq!(order[2], expected_range);
1415
1416 // Fourth group: 2-3
1417 assert_eq!(order[3], BTreeSet::from([2, 3]));
1418
1419 // Fifth group: 4
1420 assert_eq!(order[4], BTreeSet::from([4]));
1421
1422 // Sixth group: 5-7
1423 assert_eq!(order[5], BTreeSet::from([5, 6, 7]));
1424
1425 // Seventh group: 48, 50
1426 assert_eq!(order[6], BTreeSet::from([48, 50]));
1427
1428 // Eighth group: 52-55
1429 assert_eq!(order[7], BTreeSet::from([52, 53, 54, 55]));
1430
1431 // Ninth group: 56-95
1432 let expected_range_2: BTreeSet<usize> = (56..=95).collect();
1433 assert_eq!(order[8], expected_range_2);
1434 }
1435
1436 #[test]
1437 fn test_normalize_hunk() {
1438 let mut patch = Patch::parse_unified_diff(indoc! {"
1439 This patch has too many lines of context.
1440
1441 --- a/first.txt
1442 +++ b/first.txt
1443 @@ -1,7 +1,6 @@
1444 azuere
1445 beige
1446 black
1447 -blue
1448 brown
1449 cyan
1450 gold
1451 // Some garbage
1452 "});
1453
1454 patch.normalize_hunks(1);
1455 let actual = patch.to_string();
1456 assert_eq!(
1457 actual,
1458 indoc! {"
1459 This patch has too many lines of context.
1460
1461 --- a/first.txt
1462 +++ b/first.txt
1463 @@ -3,3 +3,2 @@
1464 black
1465 -blue
1466 brown
1467 // Some garbage
1468 "}
1469 );
1470 }
1471
1472 #[test]
1473 fn test_file_creation_diff_header() {
1474 // When old_start and old_count are both 0, the file is being created,
1475 // so the --- line should be /dev/null instead of a/filename
1476 let patch = Patch::parse_unified_diff(indoc! {"
1477 --- a/new_file.rs
1478 +++ b/new_file.rs
1479 @@ -0,0 +1,3 @@
1480 +fn main() {
1481 + println!(\"hello\");
1482 +}
1483 "});
1484
1485 let actual = patch.to_string();
1486 assert_eq!(
1487 actual,
1488 indoc! {"
1489 --- /dev/null
1490 +++ b/new_file.rs
1491 @@ -0,0 +1,3 @@
1492 +fn main() {
1493 + println!(\"hello\");
1494 +}
1495 "}
1496 );
1497 }
1498}