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