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