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