diff --git a/src/differ.rs b/src/differ.rs
new file mode 100644
index 0000000..73aaa0a
@@ -0,0 +1,249 @@
+use crate::{Chunk, Operation, Patch};
+
+/// The Differ struct is used to generate a patch between old and new content
+pub struct Differ {
+ old: String,
+ new: String,
+ context_lines: usize,
+}
+
+impl Differ {
+ /// Create a new Differ with the old and new content
+ pub fn new(old: &str, new: &str) -> Self {
+ Self {
+ old: old.to_string(),
+ new: new.to_string(),
+ context_lines: 3, // Default context lines
+ }
+ }
+
+ /// Set the number of context lines to include
+ pub fn context_lines(mut self, lines: usize) -> Self {
+ self.context_lines = lines;
+ self
+ }
+
+ /// Generate a patch between the old and new content
+ pub fn generate(&self) -> Patch {
+ let old_lines: Vec<&str> = self.old.lines().collect();
+ let new_lines: Vec<&str> = self.new.lines().collect();
+
+ // Find the longest common subsequence to identify changes
+ let lcs = self.longest_common_subsequence(&old_lines, &new_lines);
+ let mut chunks = Vec::new();
+
+ let mut i = 0;
+ let mut j = 0;
+
+ let mut current_chunk: Option<Chunk> = None;
+
+ while i < old_lines.len() || j < new_lines.len() {
+ // Check if we're in an LCS (unchanged) section
+ if i < old_lines.len()
+ && j < new_lines.len()
+ && old_lines[i] == new_lines[j]
+ && lcs.contains(&(i, j))
+ {
+ // If we have an open chunk and we're past the context lines, close it
+ if let Some(chunk) = current_chunk.take() {
+ chunks.push(chunk);
+ }
+
+ // Add context line if we're starting a new chunk
+ if let Some(chunk) = &mut current_chunk {
+ chunk
+ .operations
+ .push(Operation::Context(old_lines[i].to_string()));
+ chunk.old_lines += 1;
+ chunk.new_lines += 1;
+ }
+
+ i += 1;
+ j += 1;
+ } else {
+ // We're in a changed section
+ if current_chunk.is_none() {
+ // Start a new chunk with context
+ let context_start = i.saturating_sub(self.context_lines);
+ let context_lines = i - context_start;
+
+ let mut operations = Vec::new();
+
+ // Add context lines
+ for line in old_lines.iter().skip(context_start).take(context_lines) {
+ operations.push(Operation::Context(line.to_string()));
+ }
+
+ current_chunk = Some(Chunk {
+ old_start: context_start,
+ old_lines: context_lines,
+ new_start: j.saturating_sub(context_lines),
+ new_lines: context_lines,
+ operations,
+ });
+ }
+
+ // Process removals (lines in old but not in new)
+ if i < old_lines.len() && (j >= new_lines.len() || !lcs.contains(&(i, j))) {
+ if let Some(chunk) = &mut current_chunk {
+ chunk
+ .operations
+ .push(Operation::Remove(old_lines[i].to_string()));
+ chunk.old_lines += 1;
+ }
+ i += 1;
+ }
+ // Process additions (lines in new but not in old)
+ else if j < new_lines.len() && (i >= old_lines.len() || !lcs.contains(&(i, j))) {
+ if let Some(chunk) = &mut current_chunk {
+ chunk
+ .operations
+ .push(Operation::Add(new_lines[j].to_string()));
+ chunk.new_lines += 1;
+ }
+ j += 1;
+ }
+ }
+ }
+
+ // Add the last chunk if there is one
+ if let Some(chunk) = current_chunk {
+ chunks.push(chunk);
+ }
+
+ Patch {
+ old_file: "original".to_string(),
+ new_file: "modified".to_string(),
+ chunks,
+ }
+ }
+
+ /// Find the longest common subsequence between two sequences
+ fn longest_common_subsequence<T: PartialEq>(&self, a: &[T], b: &[T]) -> Vec<(usize, usize)> {
+ if a.is_empty() || b.is_empty() {
+ return Vec::new();
+ }
+
+ // Create a matrix of lengths of LCS
+ let mut lengths = vec![vec![0; b.len() + 1]; a.len() + 1];
+
+ // Fill the matrix
+ for (i, a_item) in a.iter().enumerate() {
+ for (j, b_item) in b.iter().enumerate() {
+ if a_item == b_item {
+ lengths[i + 1][j + 1] = lengths[i][j] + 1;
+ } else {
+ lengths[i + 1][j + 1] = std::cmp::max(lengths[i + 1][j], lengths[i][j + 1]);
+ }
+ }
+ }
+
+ // Backtrack to find the actual sequence
+ let mut result = Vec::new();
+ let mut i = a.len();
+ let mut j = b.len();
+
+ while i > 0 && j > 0 {
+ if a[i - 1] == b[j - 1] {
+ result.push((i - 1, j - 1));
+ i -= 1;
+ j -= 1;
+ } else if lengths[i - 1][j] >= lengths[i][j - 1] {
+ i -= 1;
+ } else {
+ j -= 1;
+ }
+ }
+
+ result.reverse();
+ result
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::Patcher;
+
+ #[test]
+ fn test_simple_diff() {
+ let old = "line1\nline2\nline3\nline4";
+ let new = "line1\nline2 modified\nline3\nline4";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ assert_eq!(patch.chunks.len(), 1);
+ assert_eq!(patch.chunks[0].old_start, 0);
+ assert_eq!(patch.chunks[0].old_lines, 4);
+ assert_eq!(patch.chunks[0].new_start, 0);
+ assert_eq!(patch.chunks[0].new_lines, 4);
+
+ // Try applying the patch
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_add_line() {
+ let old = "line1\nline2\nline4";
+ let new = "line1\nline2\nline3\nline4";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_remove_line() {
+ let old = "line1\nline2\nline3\nline4";
+ let new = "line1\nline2\nline4";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_multiple_changes() {
+ let old = "line1\nline2\nline3\nline4\nline5\nline6";
+ let new = "line1\nmodified2\nline3\nnew line\nline5\nline6 changed";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_empty_files() {
+ let old = "";
+ let new = "new content";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_identical_files() {
+ let content = "line1\nline2\nline3";
+
+ let differ = Differ::new(content, content);
+ let patch = differ.generate();
+
+ assert_eq!(patch.chunks.len(), 0);
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 66aaf38..114943e 100644
@@ -1,6 +1,14 @@
use std::fmt;
use thiserror::Error;
+mod differ;
+mod patch;
+mod patcher;
+
+pub use differ::Differ;
+pub use patch::Patch;
+pub use patcher::Patcher;
+
#[derive(Debug, Error)]
pub enum Error {
#[error("Failed to apply patch: {0}")]
@@ -25,7 +33,7 @@ pub enum Operation {
}
impl Operation {
- fn to_char(&self) -> char {
+ pub(crate) fn to_char(&self) -> char {
match self {
Operation::Add(_) => '+',
Operation::Remove(_) => '-',
@@ -33,7 +41,7 @@ impl Operation {
}
}
- fn line(&self) -> &str {
+ pub(crate) fn line(&self) -> &str {
match self {
Operation::Add(line) => line,
Operation::Remove(line) => line,
@@ -76,583 +84,22 @@ impl fmt::Display for Chunk {
}
}
-/// A patch represents all the changes between two versions of a file
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct Patch {
- /// Original file path
- pub old_file: String,
- /// New file path
- pub new_file: String,
- /// Chunks of changes
- pub chunks: Vec<Chunk>,
-}
-
-impl Patch {
- /// Parse a patch from a string
- pub fn parse(content: &str) -> Result<Self, Error> {
- let lines: Vec<&str> = content.lines().collect();
- if lines.len() < 2 {
- return Err(Error::InvalidPatchFormat(
- "Patch must contain at least header lines".to_string(),
- ));
- }
-
- // Parse header lines (--- and +++)
- let old_file_line = lines[0];
- let new_file_line = lines[1];
-
- if !old_file_line.starts_with("--- ") || !new_file_line.starts_with("+++ ") {
- return Err(Error::InvalidPatchFormat(
- "Invalid patch header".to_string(),
- ));
- }
-
- let old_file = old_file_line
- .strip_prefix("--- a/")
- .or_else(|| old_file_line.strip_prefix("--- "))
- .ok_or_else(|| Error::InvalidPatchFormat("Invalid old file header".to_string()))?
- .to_string();
-
- let new_file = new_file_line
- .strip_prefix("+++ b/")
- .or_else(|| new_file_line.strip_prefix("+++ "))
- .ok_or_else(|| Error::InvalidPatchFormat("Invalid new file header".to_string()))?
- .to_string();
-
- let mut chunks = Vec::new();
- let mut i = 2; // Start after the header lines
-
- while i < lines.len() {
- let chunk_header = lines[i];
- if !chunk_header.starts_with("@@ ") || !chunk_header.ends_with(" @@") {
- return Err(Error::InvalidPatchFormat(format!(
- "Invalid chunk header: {}",
- chunk_header
- )));
- }
-
- // Extract the line numbers from the chunk header
- // Format: @@ -old_start,old_lines +new_start,new_lines @@
- let header_parts: Vec<&str> = chunk_header
- .strip_prefix("@@ ")
- .unwrap_or(chunk_header)
- .strip_suffix(" @@")
- .unwrap_or(chunk_header)
- .split(' ')
- .collect();
-
- if header_parts.len() != 2 {
- return Err(Error::InvalidPatchFormat(format!(
- "Invalid chunk header format: {}",
- chunk_header
- )));
- }
-
- let old_range = header_parts[0].strip_prefix('-').unwrap_or(header_parts[0]);
- let new_range = header_parts[1].strip_prefix('+').unwrap_or(header_parts[1]);
-
- let old_range_parts: Vec<&str> = old_range.split(',').collect();
- let new_range_parts: Vec<&str> = new_range.split(',').collect();
-
- if old_range_parts.len() != 2 || new_range_parts.len() != 2 {
- return Err(Error::InvalidPatchFormat(format!(
- "Invalid range format in chunk header: {}",
- chunk_header
- )));
- }
-
- let old_start = old_range_parts[0].parse::<usize>().map_err(|_| {
- Error::InvalidPatchFormat(format!(
- "Invalid old start number: {}",
- old_range_parts[0]
- ))
- })?;
- let old_lines = old_range_parts[1].parse::<usize>().map_err(|_| {
- Error::InvalidPatchFormat(format!(
- "Invalid old lines number: {}",
- old_range_parts[1]
- ))
- })?;
- let new_start = new_range_parts[0].parse::<usize>().map_err(|_| {
- Error::InvalidPatchFormat(format!(
- "Invalid new start number: {}",
- new_range_parts[0]
- ))
- })?;
- let new_lines = new_range_parts[1].parse::<usize>().map_err(|_| {
- Error::InvalidPatchFormat(format!(
- "Invalid new lines number: {}",
- new_range_parts[1]
- ))
- })?;
-
- // Adjust to 0-based indexing
- let old_start = old_start.saturating_sub(1);
- let new_start = new_start.saturating_sub(1);
-
- i += 1; // Move past the chunk header
-
- // Parse operations in the chunk
- let mut operations = Vec::new();
- let mut remaining_old_lines = old_lines;
- let mut remaining_new_lines = new_lines;
-
- while i < lines.len() && (remaining_old_lines > 0 || remaining_new_lines > 0) {
- let line = lines[i];
-
- if line.starts_with("@@ ") {
- // We've reached the next chunk
- break;
- }
-
- if line.is_empty() {
- // Skip empty lines
- i += 1;
- continue;
- }
-
- if let Some(content) = line.strip_prefix('+') {
- // Add operation
- operations.push(Operation::Add(content.to_string()));
- remaining_new_lines = remaining_new_lines.saturating_sub(1);
- } else if let Some(content) = line.strip_prefix('-') {
- // Remove operation
- operations.push(Operation::Remove(content.to_string()));
- remaining_old_lines = remaining_old_lines.saturating_sub(1);
- } else if let Some(content) = line.strip_prefix(' ') {
- // Context operation
- operations.push(Operation::Context(content.to_string()));
- remaining_old_lines = remaining_old_lines.saturating_sub(1);
- remaining_new_lines = remaining_new_lines.saturating_sub(1);
- } else {
- return Err(Error::InvalidPatchFormat(format!(
- "Invalid operation line: {}",
- line
- )));
- }
-
- i += 1;
- }
-
- chunks.push(Chunk {
- old_start,
- old_lines,
- new_start,
- new_lines,
- operations,
- });
- }
-
- Ok(Patch {
- old_file,
- new_file,
- chunks,
- })
- }
-}
-
-impl fmt::Display for Patch {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- writeln!(f, "--- a/{}", self.old_file)?;
- writeln!(f, "+++ b/{}", self.new_file)?;
-
- for chunk in &self.chunks {
- write!(f, "{}", chunk)?;
- }
-
- Ok(())
- }
-}
-
-/// The Differ struct is used to generate a patch between old and new content
-pub struct Differ {
- old: String,
- new: String,
- context_lines: usize,
-}
-
-impl Differ {
- /// Create a new Differ with the old and new content
- pub fn new(old: &str, new: &str) -> Self {
- Self {
- old: old.to_string(),
- new: new.to_string(),
- context_lines: 3, // Default context lines
- }
- }
-
- /// Set the number of context lines to include
- pub fn context_lines(mut self, lines: usize) -> Self {
- self.context_lines = lines;
- self
- }
-
- /// Generate a patch between the old and new content
- pub fn generate(&self) -> Patch {
- let old_lines: Vec<&str> = self.old.lines().collect();
- let new_lines: Vec<&str> = self.new.lines().collect();
-
- // Find the longest common subsequence to identify changes
- let lcs = self.longest_common_subsequence(&old_lines, &new_lines);
- let mut chunks = Vec::new();
-
- let mut i = 0;
- let mut j = 0;
-
- let mut current_chunk: Option<Chunk> = None;
-
- while i < old_lines.len() || j < new_lines.len() {
- // Check if we're in an LCS (unchanged) section
- if i < old_lines.len()
- && j < new_lines.len()
- && old_lines[i] == new_lines[j]
- && lcs.contains(&(i, j))
- {
- // If we have an open chunk and we're past the context lines, close it
- if let Some(chunk) = current_chunk.take() {
- chunks.push(chunk);
- }
-
- // Add context line if we're starting a new chunk
- if let Some(chunk) = &mut current_chunk {
- chunk
- .operations
- .push(Operation::Context(old_lines[i].to_string()));
- chunk.old_lines += 1;
- chunk.new_lines += 1;
- }
-
- i += 1;
- j += 1;
- } else {
- // We're in a changed section
- if current_chunk.is_none() {
- // Start a new chunk with context
- let context_start = i.saturating_sub(self.context_lines);
- let context_lines = i - context_start;
-
- let mut operations = Vec::new();
-
- // Add context lines
- for line in old_lines.iter().skip(context_start).take(context_lines) {
- operations.push(Operation::Context(line.to_string()));
- }
-
- current_chunk = Some(Chunk {
- old_start: context_start,
- old_lines: context_lines,
- new_start: j.saturating_sub(context_lines),
- new_lines: context_lines,
- operations,
- });
- }
-
- // Process removals (lines in old but not in new)
- if i < old_lines.len() && (j >= new_lines.len() || !lcs.contains(&(i, j))) {
- if let Some(chunk) = &mut current_chunk {
- chunk
- .operations
- .push(Operation::Remove(old_lines[i].to_string()));
- chunk.old_lines += 1;
- }
- i += 1;
- }
- // Process additions (lines in new but not in old)
- else if j < new_lines.len() && (i >= old_lines.len() || !lcs.contains(&(i, j))) {
- if let Some(chunk) = &mut current_chunk {
- chunk
- .operations
- .push(Operation::Add(new_lines[j].to_string()));
- chunk.new_lines += 1;
- }
- j += 1;
- }
- }
- }
-
- // Add the last chunk if there is one
- if let Some(chunk) = current_chunk {
- chunks.push(chunk);
- }
-
- Patch {
- old_file: "original".to_string(),
- new_file: "modified".to_string(),
- chunks,
- }
- }
-
- /// Find the longest common subsequence between two sequences
- fn longest_common_subsequence<T: PartialEq>(&self, a: &[T], b: &[T]) -> Vec<(usize, usize)> {
- if a.is_empty() || b.is_empty() {
- return Vec::new();
- }
-
- // Create a matrix of lengths of LCS
- let mut lengths = vec![vec![0; b.len() + 1]; a.len() + 1];
-
- // Fill the matrix
- for (i, a_item) in a.iter().enumerate() {
- for (j, b_item) in b.iter().enumerate() {
- if a_item == b_item {
- lengths[i + 1][j + 1] = lengths[i][j] + 1;
- } else {
- lengths[i + 1][j + 1] = std::cmp::max(lengths[i + 1][j], lengths[i][j + 1]);
- }
- }
- }
-
- // Backtrack to find the actual sequence
- let mut result = Vec::new();
- let mut i = a.len();
- let mut j = b.len();
-
- while i > 0 && j > 0 {
- if a[i - 1] == b[j - 1] {
- result.push((i - 1, j - 1));
- i -= 1;
- j -= 1;
- } else if lengths[i - 1][j] >= lengths[i][j - 1] {
- i -= 1;
- } else {
- j -= 1;
- }
- }
-
- result.reverse();
- result
- }
-}
-
-/// The Patcher struct is used to apply a patch to content
-pub struct Patcher {
- patch: Patch,
-}
-
-impl Patcher {
- /// Create a new Patcher with the given patch
- pub fn new(patch: Patch) -> Self {
- Self { patch }
- }
-
- /// Apply the patch to the content
- pub fn apply(&self, content: &str, reverse: bool) -> Result<String, Error> {
- let lines: Vec<&str> = content.lines().collect();
- let mut result = Vec::new();
- let mut current_line = 0;
-
- for chunk in &self.patch.chunks {
- let (start, operations) = if reverse {
- // In reverse mode, we use new_start and reverse the operations
- (chunk.new_start, self.reverse_operations(&chunk.operations))
- } else {
- (chunk.old_start, chunk.operations.clone())
- };
-
- // Add lines up to the start of the chunk
- while current_line < start {
- if current_line >= lines.len() {
- return Err(Error::LineNotFound(format!(
- "Line {} not found in content",
- current_line + 1
- )));
- }
- result.push(lines[current_line].to_string());
- current_line += 1;
- }
-
- // Apply operations
- for op in operations {
- match op {
- Operation::Context(line) => {
- // Context lines should match the content
- if current_line >= lines.len() || lines[current_line] != line {
- return Err(Error::ApplyError(format!(
- "Context mismatch at line {}",
- current_line + 1
- )));
- }
- result.push(line);
- current_line += 1;
- }
- Operation::Add(line) => {
- // Add the new line
- result.push(line);
- }
- Operation::Remove(_) => {
- // Skip the line to be removed
- if current_line >= lines.len() {
- return Err(Error::LineNotFound(format!(
- "Line {} not found to remove",
- current_line + 1
- )));
- }
- current_line += 1;
- }
- }
- }
- }
-
- // Add any remaining lines
- while current_line < lines.len() {
- result.push(lines[current_line].to_string());
- current_line += 1;
- }
-
- Ok(result.join("\n"))
- }
-
- /// Reverse the operations in a chunk
- fn reverse_operations(&self, operations: &[Operation]) -> Vec<Operation> {
- operations
- .iter()
- .map(|op| match op {
- Operation::Add(line) => Operation::Remove(line.clone()),
- Operation::Remove(line) => Operation::Add(line.clone()),
- Operation::Context(line) => Operation::Context(line.clone()),
- })
- .collect()
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn test_simple_diff() {
+ fn test_integration() {
let old = "line1\nline2\nline3\nline4";
let new = "line1\nline2 modified\nline3\nline4";
+ // Generate a patch
let differ = Differ::new(old, new);
let patch = differ.generate();
- assert_eq!(patch.chunks.len(), 1);
- assert_eq!(patch.chunks[0].old_start, 0);
- assert_eq!(patch.chunks[0].old_lines, 4);
- assert_eq!(patch.chunks[0].new_start, 0);
- assert_eq!(patch.chunks[0].new_lines, 4);
-
- // Try applying the patch
- let patcher = Patcher::new(patch);
- let result = patcher.apply(old, false).unwrap();
- assert_eq!(result, new);
- }
-
- #[test]
- fn test_add_line() {
- let old = "line1\nline2\nline4";
- let new = "line1\nline2\nline3\nline4";
-
- let differ = Differ::new(old, new);
- let patch = differ.generate();
-
- let patcher = Patcher::new(patch);
- let result = patcher.apply(old, false).unwrap();
- assert_eq!(result, new);
- }
-
- #[test]
- fn test_remove_line() {
- let old = "line1\nline2\nline3\nline4";
- let new = "line1\nline2\nline4";
-
- let differ = Differ::new(old, new);
- let patch = differ.generate();
-
+ // Apply the patch
let patcher = Patcher::new(patch);
let result = patcher.apply(old, false).unwrap();
assert_eq!(result, new);
}
-
- #[test]
- fn test_multiple_changes() {
- let old = "line1\nline2\nline3\nline4\nline5\nline6";
- let new = "line1\nmodified2\nline3\nnew line\nline5\nline6 changed";
-
- let differ = Differ::new(old, new);
- let patch = differ.generate();
-
- let patcher = Patcher::new(patch);
- let result = patcher.apply(old, false).unwrap();
- assert_eq!(result, new);
- }
-
- #[test]
- fn test_reverse_patch() {
- let old = "line1\nline2\nline3";
- let new = "line1\nmodified\nline3\nnew line";
-
- let differ = Differ::new(old, new);
- let patch = differ.generate();
-
- let patcher = Patcher::new(patch);
-
- // Apply forward
- let forward = patcher.apply(old, false).unwrap();
- assert_eq!(forward, new);
-
- // Apply backward
- let backward = patcher.apply(new, true).unwrap();
- assert_eq!(backward, old);
- }
-
- #[test]
- fn test_empty_files() {
- let old = "";
- let new = "new content";
-
- let differ = Differ::new(old, new);
- let patch = differ.generate();
-
- let patcher = Patcher::new(patch);
- let result = patcher.apply(old, false).unwrap();
- assert_eq!(result, new);
- }
-
- #[test]
- fn test_identical_files() {
- let content = "line1\nline2\nline3";
-
- let differ = Differ::new(content, content);
- let patch = differ.generate();
-
- assert_eq!(patch.chunks.len(), 0);
- }
-
- #[test]
- fn test_parse_patch() {
- let patch_str = "\
---- a/file.txt
-+++ b/file.txt
-@@ -1,4 +1,4 @@
- line1
--line2
-+line2 modified
- line3
- line4
-";
-
- let patch = Patch::parse(patch_str).unwrap();
-
- assert_eq!(patch.old_file, "file.txt");
- assert_eq!(patch.new_file, "file.txt");
- assert_eq!(patch.chunks.len(), 1);
-
- let chunk = &patch.chunks[0];
- println!("Chunk: {:?}", chunk);
- println!("Operations: {:?}", chunk.operations);
-
- assert_eq!(chunk.old_start, 0);
- assert_eq!(chunk.old_lines, 4);
- assert_eq!(chunk.new_start, 0);
- assert_eq!(chunk.new_lines, 4);
-
- assert_eq!(chunk.operations.len(), 5);
- assert!(matches!(chunk.operations[0], Operation::Context(_)));
- assert!(matches!(chunk.operations[1], Operation::Remove(_)));
- assert!(matches!(chunk.operations[2], Operation::Add(_)));
- assert!(matches!(chunk.operations[3], Operation::Context(_)));
- assert!(matches!(chunk.operations[4], Operation::Context(_)));
- }
}
diff --git a/src/patch.rs b/src/patch.rs
new file mode 100644
index 0000000..79e3da6
@@ -0,0 +1,229 @@
+use crate::{Chunk, Error, Operation};
+use std::fmt;
+
+/// A patch represents all the changes between two versions of a file
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Patch {
+ /// Original file path
+ pub old_file: String,
+ /// New file path
+ pub new_file: String,
+ /// Chunks of changes
+ pub chunks: Vec<Chunk>,
+}
+
+impl Patch {
+ /// Parse a patch from a string
+ pub fn parse(content: &str) -> Result<Self, Error> {
+ let lines: Vec<&str> = content.lines().collect();
+ if lines.len() < 2 {
+ return Err(Error::InvalidPatchFormat(
+ "Patch must contain at least header lines".to_string(),
+ ));
+ }
+
+ // Parse header lines (--- and +++)
+ let old_file_line = lines[0];
+ let new_file_line = lines[1];
+
+ if !old_file_line.starts_with("--- ") || !new_file_line.starts_with("+++ ") {
+ return Err(Error::InvalidPatchFormat(
+ "Invalid patch header".to_string(),
+ ));
+ }
+
+ let old_file = old_file_line
+ .strip_prefix("--- a/")
+ .or_else(|| old_file_line.strip_prefix("--- "))
+ .ok_or_else(|| Error::InvalidPatchFormat("Invalid old file header".to_string()))?
+ .to_string();
+
+ let new_file = new_file_line
+ .strip_prefix("+++ b/")
+ .or_else(|| new_file_line.strip_prefix("+++ "))
+ .ok_or_else(|| Error::InvalidPatchFormat("Invalid new file header".to_string()))?
+ .to_string();
+
+ let mut chunks = Vec::new();
+ let mut i = 2; // Start after the header lines
+
+ while i < lines.len() {
+ let chunk_header = lines[i];
+ if !chunk_header.starts_with("@@ ") || !chunk_header.ends_with(" @@") {
+ return Err(Error::InvalidPatchFormat(format!(
+ "Invalid chunk header: {}",
+ chunk_header
+ )));
+ }
+
+ // Extract the line numbers from the chunk header
+ // Format: @@ -old_start,old_lines +new_start,new_lines @@
+ let header_parts: Vec<&str> = chunk_header
+ .strip_prefix("@@ ")
+ .unwrap_or(chunk_header)
+ .strip_suffix(" @@")
+ .unwrap_or(chunk_header)
+ .split(' ')
+ .collect();
+
+ if header_parts.len() != 2 {
+ return Err(Error::InvalidPatchFormat(format!(
+ "Invalid chunk header format: {}",
+ chunk_header
+ )));
+ }
+
+ let old_range = header_parts[0].strip_prefix('-').unwrap_or(header_parts[0]);
+ let new_range = header_parts[1].strip_prefix('+').unwrap_or(header_parts[1]);
+
+ let old_range_parts: Vec<&str> = old_range.split(',').collect();
+ let new_range_parts: Vec<&str> = new_range.split(',').collect();
+
+ if old_range_parts.len() != 2 || new_range_parts.len() != 2 {
+ return Err(Error::InvalidPatchFormat(format!(
+ "Invalid range format in chunk header: {}",
+ chunk_header
+ )));
+ }
+
+ let old_start = old_range_parts[0].parse::<usize>().map_err(|_| {
+ Error::InvalidPatchFormat(format!(
+ "Invalid old start number: {}",
+ old_range_parts[0]
+ ))
+ })?;
+ let old_lines = old_range_parts[1].parse::<usize>().map_err(|_| {
+ Error::InvalidPatchFormat(format!(
+ "Invalid old lines number: {}",
+ old_range_parts[1]
+ ))
+ })?;
+ let new_start = new_range_parts[0].parse::<usize>().map_err(|_| {
+ Error::InvalidPatchFormat(format!(
+ "Invalid new start number: {}",
+ new_range_parts[0]
+ ))
+ })?;
+ let new_lines = new_range_parts[1].parse::<usize>().map_err(|_| {
+ Error::InvalidPatchFormat(format!(
+ "Invalid new lines number: {}",
+ new_range_parts[1]
+ ))
+ })?;
+
+ // Adjust to 0-based indexing
+ let old_start = old_start.saturating_sub(1);
+ let new_start = new_start.saturating_sub(1);
+
+ i += 1; // Move past the chunk header
+
+ // Parse operations in the chunk
+ let mut operations = Vec::new();
+ let mut remaining_old_lines = old_lines;
+ let mut remaining_new_lines = new_lines;
+
+ while i < lines.len() && (remaining_old_lines > 0 || remaining_new_lines > 0) {
+ let line = lines[i];
+
+ if line.starts_with("@@ ") {
+ // We've reached the next chunk
+ break;
+ }
+
+ if line.is_empty() {
+ // Skip empty lines
+ i += 1;
+ continue;
+ }
+
+ if let Some(content) = line.strip_prefix('+') {
+ // Add operation
+ operations.push(Operation::Add(content.to_string()));
+ remaining_new_lines = remaining_new_lines.saturating_sub(1);
+ } else if let Some(content) = line.strip_prefix('-') {
+ // Remove operation
+ operations.push(Operation::Remove(content.to_string()));
+ remaining_old_lines = remaining_old_lines.saturating_sub(1);
+ } else if let Some(content) = line.strip_prefix(' ') {
+ // Context operation
+ operations.push(Operation::Context(content.to_string()));
+ remaining_old_lines = remaining_old_lines.saturating_sub(1);
+ remaining_new_lines = remaining_new_lines.saturating_sub(1);
+ } else {
+ return Err(Error::InvalidPatchFormat(format!(
+ "Invalid operation line: {}",
+ line
+ )));
+ }
+
+ i += 1;
+ }
+
+ chunks.push(Chunk {
+ old_start,
+ old_lines,
+ new_start,
+ new_lines,
+ operations,
+ });
+ }
+
+ Ok(Patch {
+ old_file,
+ new_file,
+ chunks,
+ })
+ }
+}
+
+impl fmt::Display for Patch {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ writeln!(f, "--- a/{}", self.old_file)?;
+ writeln!(f, "+++ b/{}", self.new_file)?;
+
+ for chunk in &self.chunks {
+ write!(f, "{}", chunk)?;
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_patch() {
+ let patch_str = "\
+--- a/file.txt
++++ b/file.txt
+@@ -1,4 +1,4 @@
+ line1
+-line2
++line2 modified
+ line3
+ line4
+";
+
+ let patch = Patch::parse(patch_str).unwrap();
+
+ assert_eq!(patch.old_file, "file.txt");
+ assert_eq!(patch.new_file, "file.txt");
+ assert_eq!(patch.chunks.len(), 1);
+
+ let chunk = &patch.chunks[0];
+
+ assert_eq!(chunk.old_start, 0);
+ assert_eq!(chunk.old_lines, 4);
+ assert_eq!(chunk.new_start, 0);
+ assert_eq!(chunk.new_lines, 4);
+
+ assert_eq!(chunk.operations.len(), 5);
+ assert!(matches!(chunk.operations[0], Operation::Context(_)));
+ assert!(matches!(chunk.operations[1], Operation::Remove(_)));
+ assert!(matches!(chunk.operations[2], Operation::Add(_)));
+ assert!(matches!(chunk.operations[3], Operation::Context(_)));
+ assert!(matches!(chunk.operations[4], Operation::Context(_)));
+ }
+}
diff --git a/src/patcher.rs b/src/patcher.rs
new file mode 100644
index 0000000..0fb15c0
@@ -0,0 +1,130 @@
+use crate::{Error, Operation, Patch};
+
+/// The Patcher struct is used to apply a patch to content
+pub struct Patcher {
+ patch: Patch,
+}
+
+impl Patcher {
+ /// Create a new Patcher with the given patch
+ pub fn new(patch: Patch) -> Self {
+ Self { patch }
+ }
+
+ /// Apply the patch to the content
+ pub fn apply(&self, content: &str, reverse: bool) -> Result<String, Error> {
+ let lines: Vec<&str> = content.lines().collect();
+ let mut result = Vec::new();
+ let mut current_line = 0;
+
+ for chunk in &self.patch.chunks {
+ let (start, operations) = if reverse {
+ // In reverse mode, we use new_start and reverse the operations
+ (chunk.new_start, self.reverse_operations(&chunk.operations))
+ } else {
+ (chunk.old_start, chunk.operations.clone())
+ };
+
+ // Add lines up to the start of the chunk
+ while current_line < start {
+ if current_line >= lines.len() {
+ return Err(Error::LineNotFound(format!(
+ "Line {} not found in content",
+ current_line + 1
+ )));
+ }
+ result.push(lines[current_line].to_string());
+ current_line += 1;
+ }
+
+ // Apply operations
+ for op in operations {
+ match op {
+ Operation::Context(line) => {
+ // Context lines should match the content
+ if current_line >= lines.len() || lines[current_line] != line {
+ return Err(Error::ApplyError(format!(
+ "Context mismatch at line {}",
+ current_line + 1
+ )));
+ }
+ result.push(line);
+ current_line += 1;
+ }
+ Operation::Add(line) => {
+ // Add the new line
+ result.push(line);
+ }
+ Operation::Remove(_) => {
+ // Skip the line to be removed
+ if current_line >= lines.len() {
+ return Err(Error::LineNotFound(format!(
+ "Line {} not found to remove",
+ current_line + 1
+ )));
+ }
+ current_line += 1;
+ }
+ }
+ }
+ }
+
+ // Add any remaining lines
+ while current_line < lines.len() {
+ result.push(lines[current_line].to_string());
+ current_line += 1;
+ }
+
+ Ok(result.join("\n"))
+ }
+
+ /// Reverse the operations in a chunk
+ fn reverse_operations(&self, operations: &[Operation]) -> Vec<Operation> {
+ operations
+ .iter()
+ .map(|op| match op {
+ Operation::Add(line) => Operation::Remove(line.clone()),
+ Operation::Remove(line) => Operation::Add(line.clone()),
+ Operation::Context(line) => Operation::Context(line.clone()),
+ })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::Differ;
+
+ #[test]
+ fn test_apply_patch() {
+ let old = "line1\nline2\nline3\nline4";
+ let new = "line1\nline2 modified\nline3\nline4";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+ let result = patcher.apply(old, false).unwrap();
+ assert_eq!(result, new);
+ }
+
+ #[test]
+ fn test_reverse_patch() {
+ let old = "line1\nline2\nline3";
+ let new = "line1\nmodified\nline3\nnew line";
+
+ let differ = Differ::new(old, new);
+ let patch = differ.generate();
+
+ let patcher = Patcher::new(patch);
+
+ // Apply forward
+ let forward = patcher.apply(old, false).unwrap();
+ assert_eq!(forward, new);
+
+ // Apply backward
+ let backward = patcher.apply(new, true).unwrap();
+ assert_eq!(backward, old);
+ }
+}