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