Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:53:28.961Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

reorder_patch.rs

1549 lines · 47.9 KB · rust
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, &current_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
Served at tenant.openagents/omega Member data and write actions are omitted.