Skip to repository content1549 lines · 47.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:53:11.137Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
reorder_patch.rs
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 .concat();
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(Clone, 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 edit_locations(patch: &Patch) -> Vec<EditLocation> {
671 let mut edit_locations = Vec::new();
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 edit_locations
728}
729
730pub fn locate_edited_line(patch: &Patch, mut edit_index: isize) -> Option<EditLocation> {
731 let mut edit_locations = edit_locations(patch);
732
733 if edit_index < 0 {
734 edit_index += edit_locations.len() as isize; // take from end
735 }
736 (0..edit_locations.len())
737 .contains(&(edit_index as usize))
738 .then(|| edit_locations.swap_remove(edit_index as usize)) // remove to take ownership
739}
740//
741// Helper function to count old and new lines
742fn count_lines(lines: &[PatchLine]) -> (usize, usize) {
743 lines.iter().fold((0, 0), |(old, new), line| match line {
744 PatchLine::Context(_) => (old + 1, new + 1),
745 PatchLine::Deletion(_) => (old + 1, new),
746 PatchLine::Addition(_) => (old, new + 1),
747 _ => (old, new),
748 })
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use indoc::indoc;
755
756 #[test]
757 fn test_parse_unified_diff() {
758 let patch_str = indoc! {"
759 Patch header
760 ============
761
762 diff --git a/text.txt b/text.txt
763 index 86c770d..a1fd855 100644
764 --- a/text.txt
765 +++ b/text.txt
766 @@ -1,7 +1,7 @@
767 azuere
768 beige
769 black
770 -blue
771 +dark blue
772 brown
773 cyan
774 gold
775
776 Some garbage
777
778 diff --git a/second.txt b/second.txt
779 index 86c770d..a1fd855 100644
780 --- a/second.txt
781 +++ b/second.txt
782 @@ -9,6 +9,7 @@ gray
783 green
784 indigo
785 magenta
786 +silver
787 orange
788 pink
789 purple
790 diff --git a/text.txt b/text.txt
791 index 86c770d..a1fd855 100644
792 --- a/text.txt
793 +++ b/text.txt
794 @@ -16,4 +17,3 @@ red
795 violet
796 white
797 yellow
798 -zinc
799 "};
800 let patch = Patch::parse_unified_diff(patch_str);
801
802 assert_eq!(patch.header, "Patch header\n============\n\n");
803 assert_eq!(patch.hunks.len(), 3);
804 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,7 @@");
805 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +9,7 @@ gray");
806 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +17,3 @@ red");
807 assert_eq!(patch.hunks[0].is_filename_inherited, false);
808 assert_eq!(patch.hunks[1].is_filename_inherited, false);
809 assert_eq!(patch.hunks[2].is_filename_inherited, false);
810 }
811
812 #[test]
813 fn test_locate_edited_line() {
814 let patch_str = indoc! {"
815 Patch header
816 ============
817
818 diff --git a/text.txt b/text.txt
819 index 86c770d..a1fd855 100644
820 --- a/text.txt
821 +++ b/text.txt
822 @@ -1,7 +1,7 @@
823 azuere
824 beige
825 black
826 -blue
827 +dark blue
828 brown
829 cyan
830 gold
831 diff --git a/second.txt b/second.txt
832 index 86c770d..a1fd855 100644
833 --- a/second.txt
834 +++ b/second.txt
835 @@ -9,6 +9,7 @@ gray
836 green
837 indigo
838 magenta
839 +silver
840 orange
841 pink
842 purple
843 diff --git a/text.txt b/text.txt
844 index 86c770d..a1fd855 100644
845 --- a/text.txt
846 +++ b/text.txt
847 @@ -16,4 +17,3 @@ red
848 violet
849 white
850 yellow
851 -zinc
852 "};
853 let patch = Patch::parse_unified_diff(patch_str);
854 let locations = edit_locations(&patch);
855 assert_eq!(locations.len(), 4);
856
857 assert_eq!(
858 locate_edited_line(&patch, 0), // -blue
859 Some(EditLocation {
860 filename: "text.txt".to_string(),
861 source_line_number: 4,
862 target_line_number: 4,
863 patch_line: PatchLine::Deletion("blue".to_string()),
864 hunk_index: 0,
865 line_index_within_hunk: 3
866 })
867 );
868 assert_eq!(
869 locate_edited_line(&patch, 1), // +dark blue
870 Some(EditLocation {
871 filename: "text.txt".to_string(),
872 source_line_number: 5,
873 target_line_number: 4,
874 patch_line: PatchLine::Addition("dark blue".to_string()),
875 hunk_index: 0,
876 line_index_within_hunk: 4
877 })
878 );
879 assert_eq!(
880 locate_edited_line(&patch, 2), // +silver
881 Some(EditLocation {
882 filename: "second.txt".to_string(),
883 source_line_number: 12,
884 target_line_number: 12,
885 patch_line: PatchLine::Addition("silver".to_string()),
886 hunk_index: 1,
887 line_index_within_hunk: 3
888 })
889 );
890 }
891
892 mod remove_edits {
893 use super::*;
894 use indoc::indoc;
895 use pretty_assertions::assert_eq;
896
897 static PATCH: &'static str = indoc! {"
898 diff --git a/text.txt b/text.txt
899 index 86c770d..a1fd855 100644
900 --- a/text.txt
901 +++ b/text.txt
902 @@ -1,7 +1,7 @@
903 azuere
904 beige
905 black
906 -blue
907 +dark blue
908 brown
909 cyan
910 gold
911 @@ -9,6 +9,7 @@ gray
912 green
913 indigo
914 magenta
915 +silver
916 orange
917 pink
918 purple
919 @@ -16,4 +17,3 @@ red
920 violet
921 white
922 yellow
923 -zinc
924 "};
925
926 #[test]
927 fn test_removes_hunks_without_edits() {
928 // Remove the first two edits:
929 // -blue
930 // +dark blue
931 let mut patch = Patch::parse_unified_diff(PATCH);
932 remove_edits(&mut patch, vec![0, 1]);
933
934 // The whole hunk should be removed since there are no other edits in it
935 let actual = patch.to_string();
936 let expected = indoc! {"
937 --- a/text.txt
938 +++ b/text.txt
939 @@ -9,6 +9,7 @@ gray
940 green
941 indigo
942 magenta
943 +silver
944 orange
945 pink
946 purple
947 --- a/text.txt
948 +++ b/text.txt
949 @@ -16,4 +17,3 @@ red
950 violet
951 white
952 yellow
953 -zinc
954 "};
955 assert_eq!(actual, String::from(expected));
956 }
957
958 #[test]
959 fn test_adjust_line_numbers_after_deletion() {
960 // Remove the first deletion (`-blue`)
961 let mut patch = Patch::parse_unified_diff(PATCH);
962 remove_edits(&mut patch, vec![0]);
963
964 // The line numbers should be adjusted in the subsequent hunks
965 println!("{}", &patch.to_string());
966 assert_eq!(patch.hunks[0].header_string(), "@@ -2,6 +2,7 @@");
967 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +10,7 @@ gray");
968 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +18,3 @@ red");
969 }
970 #[test]
971 fn test_adjust_line_numbers_after_insertion() {
972 // Remove the first insertion (`+dark blue`)
973 let mut patch = Patch::parse_unified_diff(PATCH);
974 remove_edits(&mut patch, vec![1]);
975
976 // The line numbers should be adjusted in the subsequent hunks
977 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@");
978 assert_eq!(patch.hunks[1].header_string(), "@@ -9,6 +8,7 @@ gray");
979 assert_eq!(patch.hunks[2].header_string(), "@@ -16,4 +16,3 @@ red");
980 }
981 #[test]
982 fn test_adjust_line_numbers_multifile_case() {
983 // Given a patch that spans multiple files
984 let patch_str = indoc! {"
985 --- a/first.txt
986 +++ b/first.txt
987 @@ -1,7 +1,7 @@
988 azuere
989 beige
990 black
991 -blue
992 +dark blue
993 brown
994 cyan
995 gold
996 @@ -16,4 +17,3 @@ red
997 violet
998 white
999 yellow
1000 -zinc
1001 --- a/second.txt
1002 +++ b/second.txt
1003 @@ -9,6 +9,7 @@ gray
1004 green
1005 indigo
1006 magenta
1007 +silver
1008 orange
1009 pink
1010 purple
1011 "};
1012
1013 // When removing edit from one of the files (`+dark blue`)
1014 let mut patch = Patch::parse_unified_diff(patch_str);
1015 remove_edits(&mut patch, vec![1]);
1016
1017 // Then the line numbers should only be adjusted in subsequent hunks from that file
1018 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@"); // edited hunk
1019 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +16,3 @@ red"); // hunk from edited file again
1020 assert_eq!(patch.hunks[2].header_string(), "@@ -9,6 +9,7 @@ gray"); // hunk from another file
1021
1022 // When removing hunk from `second.txt`
1023 let mut patch = Patch::parse_unified_diff(patch_str);
1024 remove_edits(&mut patch, vec![3]);
1025
1026 // Then patch serialization should list `first.txt` only once
1027 // (because hunks from that file become adjacent)
1028 let expected = indoc! {"
1029 --- a/first.txt
1030 +++ b/first.txt
1031 @@ -1,7 +1,7 @@
1032 azuere
1033 beige
1034 black
1035 -blue
1036 +dark blue
1037 brown
1038 cyan
1039 gold
1040 --- a/first.txt
1041 +++ b/first.txt
1042 @@ -16,4 +17,3 @@ red
1043 violet
1044 white
1045 yellow
1046 -zinc
1047 "};
1048 assert_eq!(patch.to_string(), expected);
1049 }
1050
1051 #[test]
1052 fn test_dont_adjust_line_numbers_samefile_case() {
1053 // Given a patch that has hunks in the same file, but with a file header
1054 // (which makes `git apply` flush edits so far and start counting lines numbers afresh)
1055 let patch_str = indoc! {"
1056 diff --git a/text.txt b/text.txt
1057 index 86c770d..a1fd855 100644
1058 --- a/text.txt
1059 +++ b/text.txt
1060 @@ -1,7 +1,7 @@
1061 azuere
1062 beige
1063 black
1064 -blue
1065 +dark blue
1066 brown
1067 cyan
1068 gold
1069 --- a/text.txt
1070 +++ b/text.txt
1071 @@ -16,4 +16,3 @@ red
1072 violet
1073 white
1074 yellow
1075 -zinc
1076 "};
1077
1078 // When removing edit from one of the files (`+dark blue`)
1079 let mut patch = Patch::parse_unified_diff(patch_str);
1080 remove_edits(&mut patch, vec![1]);
1081
1082 // Then the line numbers should **not** be adjusted in a subsequent hunk,
1083 // because it starts with a file header
1084 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@"); // edited hunk
1085 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +16,3 @@ red"); // subsequent hunk
1086 }
1087 }
1088
1089 mod apply_edits {
1090 use super::*;
1091 use indoc::indoc;
1092 use pretty_assertions::assert_eq;
1093
1094 static PATCH: &'static str = indoc! {"
1095 diff --git a/text.txt b/text.txt
1096 index 86c770d..a1fd855 100644
1097 --- a/text.txt
1098 +++ b/text.txt
1099 @@ -1,7 +1,7 @@
1100 azuere
1101 beige
1102 black
1103 -blue
1104 +dark blue
1105 brown
1106 cyan
1107 gold
1108 --- a/text.txt
1109 +++ b/text.txt
1110 @@ -9,6 +9,7 @@ gray
1111 green
1112 indigo
1113 magenta
1114 +silver
1115 orange
1116 pink
1117 purple
1118 --- a/text.txt
1119 +++ b/text.txt
1120 @@ -16,4 +17,3 @@ red
1121 violet
1122 white
1123 yellow
1124 -zinc
1125 "};
1126
1127 #[test]
1128 fn test_removes_hunks_without_edits() {
1129 // When applying the first two edits (`-blue`, `+dark blue`)
1130 let mut patch = Patch::parse_unified_diff(PATCH);
1131 apply_edits(&mut patch, vec![0, 1]);
1132
1133 // Then the whole hunk should be removed since there are no other edits in it,
1134 // and the line numbers should be adjusted in the subsequent hunks
1135 assert_eq!(patch.hunks[0].header_string(), "@@ -9,6 +9,7 @@ gray");
1136 assert_eq!(patch.hunks[1].header_string(), "@@ -16,4 +17,3 @@ red");
1137 assert_eq!(patch.hunks.len(), 2);
1138 }
1139
1140 #[test]
1141 fn test_adjust_line_numbers_after_applying_deletion() {
1142 // Apply the first deletion (`-blue`)
1143 let mut patch = Patch::parse_unified_diff(PATCH);
1144 apply_edits(&mut patch, vec![0]);
1145
1146 // The line numbers should be adjusted
1147 assert_eq!(patch.hunks[0].header_string(), "@@ -1,6 +1,7 @@");
1148 assert_eq!(patch.hunks[1].header_string(), "@@ -8,6 +9,7 @@ gray");
1149 assert_eq!(patch.hunks[2].header_string(), "@@ -15,4 +17,3 @@ red");
1150 }
1151 #[test]
1152 fn test_adjust_line_numbers_after_applying_insertion() {
1153 // Apply the first insertion (`+dark blue`)
1154 let mut patch = Patch::parse_unified_diff(PATCH);
1155 apply_edits(&mut patch, vec![1]);
1156
1157 // The line numbers should be adjusted in the subsequent hunks
1158 println!("{}", &patch.to_string());
1159 assert_eq!(patch.hunks[0].header_string(), "@@ -1,7 +1,6 @@");
1160 assert_eq!(patch.hunks[1].header_string(), "@@ -10,6 +9,7 @@ gray");
1161 assert_eq!(patch.hunks[2].header_string(), "@@ -17,4 +17,3 @@ red");
1162 }
1163 }
1164
1165 mod reorder_edits {
1166 use super::*;
1167 use indoc::indoc;
1168 use pretty_assertions::assert_eq;
1169
1170 static PATCH: &'static str = indoc! {"
1171 Some header.
1172
1173 diff --git a/first.txt b/first.txt
1174 index 86c770d..a1fd855 100644
1175 --- a/first.txt
1176 +++ b/first.txt
1177 @@ -1,7 +1,7 @@
1178 azuere
1179 beige
1180 black
1181 -blue
1182 +dark blue
1183 brown
1184 cyan
1185 gold
1186 --- a/second.txt
1187 +++ b/second.txt
1188 @@ -9,6 +9,7 @@ gray
1189 green
1190 indigo
1191 magenta
1192 +silver
1193 orange
1194 pink
1195 purple
1196 --- a/first.txt
1197 +++ b/first.txt
1198 @@ -16,4 +17,3 @@ red
1199 violet
1200 white
1201 yellow
1202 -zinc
1203 "};
1204
1205 #[test]
1206 fn test_reorder_1() {
1207 let edits_order = vec![
1208 BTreeSet::from([2]), // +silver
1209 BTreeSet::from([3]), // -zinc
1210 BTreeSet::from([0, 1]), // -blue +dark blue
1211 ];
1212
1213 let patch = Patch::parse_unified_diff(PATCH);
1214 let reordered_patch = reorder_edits(&patch, edits_order);
1215
1216 // The whole hunk should be removed since there are no other edits in it
1217 let actual = reordered_patch.to_string();
1218
1219 println!("{}", actual);
1220
1221 let expected = indoc! {"
1222 Some header.
1223
1224 --- a/second.txt
1225 +++ b/second.txt
1226 @@ -9,6 +9,7 @@ gray
1227 green
1228 indigo
1229 magenta
1230 +silver
1231 orange
1232 pink
1233 purple
1234 --- a/first.txt
1235 +++ b/first.txt
1236 @@ -16,4 +17,3 @@ red
1237 violet
1238 white
1239 yellow
1240 -zinc
1241 --- a/first.txt
1242 +++ b/first.txt
1243 @@ -1,7 +1,7 @@
1244 azuere
1245 beige
1246 black
1247 -blue
1248 +dark blue
1249 brown
1250 cyan
1251 gold
1252 "};
1253 assert_eq!(actual, String::from(expected));
1254 }
1255
1256 #[test]
1257 fn test_reorder_duplicates() {
1258 let edits_order = vec![
1259 BTreeSet::from([2]), // +silver
1260 BTreeSet::from([2]), // +silver again
1261 BTreeSet::from([3]), // -zinc
1262 ];
1263
1264 let patch = Patch::parse_unified_diff(PATCH);
1265 let reordered_patch = reorder_edits(&patch, edits_order);
1266
1267 // The whole hunk should be removed since there are no other edits in it
1268 let actual = reordered_patch.to_string();
1269
1270 println!("{}", actual);
1271
1272 let expected = indoc! {"
1273 Some header.
1274
1275 --- a/second.txt
1276 +++ b/second.txt
1277 @@ -9,6 +9,7 @@ gray
1278 green
1279 indigo
1280 magenta
1281 +silver
1282 orange
1283 pink
1284 purple
1285 --- a/first.txt
1286 +++ b/first.txt
1287 @@ -16,4 +17,3 @@ red
1288 violet
1289 white
1290 yellow
1291 -zinc
1292 "};
1293 assert_eq!(actual, String::from(expected));
1294 }
1295 }
1296
1297 mod extract_edits {
1298
1299 use super::*;
1300 use indoc::indoc;
1301 use pretty_assertions::assert_eq;
1302
1303 static PATCH: &'static str = indoc! {"
1304 Some header.
1305
1306 diff --git a/first.txt b/first.txt
1307 index 86c770d..a1fd855 100644
1308 --- a/first.txt
1309 +++ b/first.txt
1310 @@ -1,7 +1,7 @@
1311 azuere
1312 beige
1313 black
1314 -blue
1315 +dark blue
1316 brown
1317 cyan
1318 gold
1319 @@ -16,4 +17,3 @@ red
1320 violet
1321 white
1322 yellow
1323 -zinc
1324 --- a/second.txt
1325 +++ b/second.txt
1326 @@ -9,6 +9,7 @@ gray
1327 green
1328 indigo
1329 magenta
1330 +silver
1331 orange
1332 pink
1333 purple
1334 "};
1335
1336 #[test]
1337 fn test_extract_edits() {
1338 let to_extract = BTreeSet::from([
1339 3, // +silver
1340 0, // -blue
1341 ]);
1342
1343 let mut patch = Patch::parse_unified_diff(PATCH);
1344 let (extracted, remainder) = extract_edits(&mut patch, &to_extract);
1345
1346 // Edits will be extracted in the sorted order, so [0, 3]
1347 let expected_extracted = indoc! {"
1348 Some header.
1349
1350 --- a/first.txt
1351 +++ b/first.txt
1352 @@ -1,7 +1,6 @@
1353 azuere
1354 beige
1355 black
1356 -blue
1357 brown
1358 cyan
1359 gold
1360 --- a/second.txt
1361 +++ b/second.txt
1362 @@ -9,6 +9,7 @@ gray
1363 green
1364 indigo
1365 magenta
1366 +silver
1367 orange
1368 pink
1369 purple
1370 "};
1371
1372 let expected_remainder = indoc! {"
1373 Some header.
1374
1375 --- a/first.txt
1376 +++ b/first.txt
1377 @@ -1,6 +1,7 @@
1378 azuere
1379 beige
1380 black
1381 +dark blue
1382 brown
1383 cyan
1384 gold
1385 --- a/first.txt
1386 +++ b/first.txt
1387 @@ -15,4 +17,3 @@ red
1388 violet
1389 white
1390 yellow
1391 -zinc
1392 "};
1393 assert_eq!(extracted.to_string(), String::from(expected_extracted));
1394 assert_eq!(remainder.to_string(), String::from(expected_remainder));
1395 }
1396 }
1397
1398 #[test]
1399 fn test_parse_order_file() {
1400 let content = r#"
1401// Add new dependency
14021, 49
1403
1404// Add new imports and types
14058-9, 51
1406
1407// Add new struct and login command method
140810-47
1409
1410// Modify AgentServerDelegate to make status_tx optional
14112-3
1412
1413// Update status_tx usage to handle optional value
14144
14155-7
1416
1417// Update all existing callers to use None for status_tx
141848, 50
1419
1420// Update the main login implementation to use custom command
142152-55
142256-95
1423"#;
1424
1425 let order = parse_order_spec(content);
1426
1427 assert_eq!(order.len(), 9);
1428
1429 // First group: 1, 49
1430 assert_eq!(order[0], BTreeSet::from([1, 49]));
1431
1432 // Second group: 8-9, 51
1433 assert_eq!(order[1], BTreeSet::from([8, 9, 51]));
1434
1435 // Third group: 10-47
1436 let expected_range: BTreeSet<usize> = (10..=47).collect();
1437 assert_eq!(order[2], expected_range);
1438
1439 // Fourth group: 2-3
1440 assert_eq!(order[3], BTreeSet::from([2, 3]));
1441
1442 // Fifth group: 4
1443 assert_eq!(order[4], BTreeSet::from([4]));
1444
1445 // Sixth group: 5-7
1446 assert_eq!(order[5], BTreeSet::from([5, 6, 7]));
1447
1448 // Seventh group: 48, 50
1449 assert_eq!(order[6], BTreeSet::from([48, 50]));
1450
1451 // Eighth group: 52-55
1452 assert_eq!(order[7], BTreeSet::from([52, 53, 54, 55]));
1453
1454 // Ninth group: 56-95
1455 let expected_range_2: BTreeSet<usize> = (56..=95).collect();
1456 assert_eq!(order[8], expected_range_2);
1457 }
1458
1459 #[test]
1460 fn test_normalize_hunk() {
1461 let mut patch = Patch::parse_unified_diff(indoc! {"
1462 This patch has too many lines of context.
1463
1464 --- a/first.txt
1465 +++ b/first.txt
1466 @@ -1,7 +1,6 @@
1467 azuere
1468 beige
1469 black
1470 -blue
1471 brown
1472 cyan
1473 gold
1474 // Some garbage
1475 "});
1476
1477 patch.normalize_hunks(1);
1478 let actual = patch.to_string();
1479 assert_eq!(
1480 actual,
1481 indoc! {"
1482 This patch has too many lines of context.
1483
1484 --- a/first.txt
1485 +++ b/first.txt
1486 @@ -3,3 +3,2 @@
1487 black
1488 -blue
1489 brown
1490 // Some garbage
1491 "}
1492 );
1493 }
1494
1495 #[test]
1496 fn test_file_creation_diff_header() {
1497 // When old_start and old_count are both 0, the file is being created,
1498 // so the --- line should be /dev/null instead of a/filename
1499 let patch = Patch::parse_unified_diff(indoc! {"
1500 --- a/new_file.rs
1501 +++ b/new_file.rs
1502 @@ -0,0 +1,3 @@
1503 +fn main() {
1504 + println!(\"hello\");
1505 +}
1506 "});
1507
1508 let actual = patch.to_string();
1509 assert_eq!(
1510 actual,
1511 indoc! {"
1512 --- /dev/null
1513 +++ b/new_file.rs
1514 @@ -0,0 +1,3 @@
1515 +fn main() {
1516 + println!(\"hello\");
1517 +}
1518 "}
1519 );
1520 }
1521
1522 #[test]
1523 fn test_file_deletion_diff_header() {
1524 // When new_start and new_count are both 0, the file is being deleted,
1525 // so the +++ line should be /dev/null instead of b/filename
1526 let patch = Patch::parse_unified_diff(indoc! {"
1527 --- a/old_file.rs
1528 +++ /dev/null
1529 @@ -1,3 +0,0 @@
1530 -fn main() {
1531 - println!(\"goodbye\");
1532 -}
1533 "});
1534
1535 let actual = patch.to_string();
1536 assert_eq!(
1537 actual,
1538 indoc! {"
1539 --- a/old_file.rs
1540 +++ /dev/null
1541 @@ -1,3 +0,0 @@
1542 -fn main() {
1543 - println!(\"goodbye\");
1544 -}
1545 "}
1546 );
1547 }
1548}
1549