use similar::{ChangeTag, TextDiff};
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct DiffResult {
pub diff: String,
pub has_changes: bool,
pub lines_changed: usize,
pub changed_new_lines: Vec<usize>,
}
pub fn compute_diff(old_content: &str, new_content: &str, file_path: &str) -> DiffResult {
if old_content.is_empty() && new_content.is_empty() {
return DiffResult {
diff: String::new(),
has_changes: false,
lines_changed: 0,
changed_new_lines: vec![],
};
}
let diff = TextDiff::from_lines(old_content, new_content);
let mut raw_lines: Vec<RawDiffLine> = vec![];
let mut old_line: usize = 1;
let mut new_line: usize = 1;
let mut lines_changed: usize = 0;
for change in diff.iter_all_changes() {
let tag = change.tag();
let content = change.value();
let line_content = content.trim_end_matches('\n');
match tag {
ChangeTag::Equal => {
raw_lines.push(RawDiffLine {
line_type: LineType::Keep,
content: line_content.to_string(),
old_line,
new_line,
});
old_line += 1;
new_line += 1;
}
ChangeTag::Delete => {
raw_lines.push(RawDiffLine {
line_type: LineType::Remove,
content: line_content.to_string(),
old_line,
new_line,
});
old_line += 1;
lines_changed += 1;
}
ChangeTag::Insert => {
raw_lines.push(RawDiffLine {
line_type: LineType::Add,
content: line_content.to_string(),
old_line,
new_line,
});
new_line += 1;
lines_changed += 1;
}
}
}
if lines_changed == 0 {
return DiffResult {
diff: String::new(),
has_changes: false,
lines_changed: 0,
changed_new_lines: vec![],
};
}
let mut changed_new_lines_set: HashSet<usize> = HashSet::new();
for rl in &raw_lines {
match rl.line_type {
LineType::Add => {
changed_new_lines_set.insert(rl.new_line);
}
LineType::Remove => {
if rl.new_line > 0 {
changed_new_lines_set.insert(rl.new_line);
}
}
LineType::Keep => {}
}
}
let hunks = format_hunks(&raw_lines);
let header = format!("--- a/{}\n+++ b/{}", file_path, file_path);
let diff = if hunks.is_empty() {
header
} else {
format!("{}\n{}", header, hunks)
};
let mut changed_new_lines: Vec<usize> = changed_new_lines_set.into_iter().collect();
changed_new_lines.sort();
DiffResult {
diff,
has_changes: true,
lines_changed,
changed_new_lines,
}
}
#[derive(Debug, Clone, PartialEq)]
struct RawDiffLine {
line_type: LineType,
content: String,
old_line: usize,
new_line: usize,
}
#[derive(Debug, Clone, PartialEq)]
enum LineType {
Keep,
Add,
Remove,
}
fn format_hunks(raw_lines: &[RawDiffLine]) -> String {
const CONTEXT: usize = 3;
let mut hunks: Vec<String> = vec![];
let mut current_hunk: Vec<&RawDiffLine> = vec![];
let mut last_change_idx: isize = -999;
let mut max_added_idx: usize = 0;
for (i, line) in raw_lines.iter().enumerate() {
if line.line_type != LineType::Keep {
if i as isize - last_change_idx > (CONTEXT * 2 + 1) as isize && !current_hunk.is_empty()
{
hunks.push(format_hunk(¤t_hunk));
current_hunk = vec![];
let ctx_start = i.saturating_sub(CONTEXT);
current_hunk.extend(&raw_lines[ctx_start..i]);
max_added_idx = i; } else if current_hunk.is_empty() {
let ctx_start = i.saturating_sub(CONTEXT);
current_hunk.extend(&raw_lines[ctx_start..i]);
max_added_idx = i; }
let context_end = (last_change_idx as usize).saturating_add(CONTEXT + 1);
let fill_start = std::cmp::max(context_end, max_added_idx);
if fill_start < i {
current_hunk.extend(&raw_lines[fill_start..i]);
}
current_hunk.push(line);
max_added_idx = i + 1;
last_change_idx = i as isize;
} else if (i as isize - last_change_idx) <= (CONTEXT as isize) && !current_hunk.is_empty() {
current_hunk.push(line);
max_added_idx = i + 1;
}
}
if !current_hunk.is_empty() {
hunks.push(format_hunk(¤t_hunk));
}
hunks.join("\n")
}
fn format_hunk(lines: &[&RawDiffLine]) -> String {
if lines.is_empty() {
return String::new();
}
let first_line = lines.first().unwrap();
let old_count = lines
.iter()
.filter(|l| l.line_type != LineType::Add)
.count();
let new_count = lines
.iter()
.filter(|l| l.line_type != LineType::Remove)
.count();
let mut hunk = format!(
"@@ -{},{} +{},{} @@",
first_line.old_line, old_count, first_line.new_line, new_count
);
for line in lines {
let prefix = match line.line_type {
LineType::Add => "+",
LineType::Remove => "-",
LineType::Keep => " ",
};
hunk.push_str(&format!("\n{}{}", prefix, line.content));
}
hunk
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_identical_content() {
let content = "line1\nline2\nline3\n";
let result = compute_diff(content, content, "test.txt");
assert!(!result.has_changes);
assert_eq!(result.lines_changed, 0);
assert!(result.changed_new_lines.is_empty());
assert!(result.diff.is_empty());
}
#[test]
fn test_empty_to_content() {
let old = "";
let new = "line1\nline2\n";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.diff.contains("+line1"));
assert!(result.diff.contains("+line2"));
}
#[test]
fn test_content_to_empty() {
let old = "line1\nline2\n";
let new = "";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.diff.contains("-line1"));
assert!(result.diff.contains("-line2"));
}
#[test]
fn test_single_line_change() {
let old = "hello world\n";
let new = "hello rust\n";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.diff.contains("-hello world"));
assert!(result.diff.contains("+hello rust"));
}
#[test]
fn test_add_lines() {
let old = "line1\nline3\n";
let new = "line1\nline2\nline3\n";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.diff.contains("+line2"));
assert!(result.changed_new_lines.contains(&2));
}
#[test]
fn test_remove_lines() {
let old = "line1\nline2\nline3\n";
let new = "line1\nline3\n";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.diff.contains("-line2"));
}
#[test]
fn test_both_empty() {
let result = compute_diff("", "", "test.txt");
assert!(!result.has_changes);
assert_eq!(result.lines_changed, 0);
}
#[test]
fn test_hunk_header_pure_deletion() {
let result = compute_diff("line1\nline2\n", "", "test.txt");
assert!(result.has_changes);
assert!(
result.diff.contains("+1,0") || result.diff.contains("+0,0"),
"Pure deletion must show 0 new lines in @@ header, got:\n{}",
result.diff
);
}
#[test]
fn test_hunk_header_pure_insertion() {
let result = compute_diff("", "line1\nline2\n", "test.txt");
assert!(result.has_changes);
assert!(
result.diff.contains("-1,0") || result.diff.contains("-0,0"),
"Pure insertion must show 0 old lines in @@ header, got:\n{}",
result.diff
);
}
#[test]
fn test_hunk_header_single_line_replacement() {
let result = compute_diff("hello world\n", "hello rust\n", "test.txt");
assert!(result.has_changes);
assert!(
result.diff.contains("-1,1") && result.diff.contains("+1,1"),
"1-for-1 replacement must produce @@ -1,1 +1,1 @@, got:\n{}",
result.diff
);
}
#[test]
fn test_changed_new_lines_tracking() {
let old = "a\nb\nc\nd\ne\n";
let new = "a\nb\nX\nd\nY\n";
let result = compute_diff(old, new, "test.txt");
assert!(result.has_changes);
assert!(result.changed_new_lines.contains(&3));
assert!(result.changed_new_lines.contains(&5));
}
}