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