use anyhow::Result;
use crossterm::style::Stylize;
use similar::{ChangeTag, TextDiff};
use std::io::Write;
pub struct Hunk {
pub header: String,
pub display: String,
pub old_range: (usize, usize),
pub new_lines: Vec<String>,
pub old_lines: Vec<String>,
}
pub fn extract_hunks(original: &str, modified: &str) -> Vec<Hunk> {
let diff = TextDiff::from_lines(original, modified);
let mut hunks = Vec::new();
for group in diff.grouped_ops(3) {
if group.is_empty() {
continue;
}
let first = &group[0];
let last = &group[group.len() - 1];
let old_start = first.old_range().start;
let old_end = last.old_range().end;
let new_start = first.new_range().start;
let new_end = last.new_range().end;
let old_len = old_end - old_start;
let new_len = new_end - new_start;
let header = format!(
"@@ -{},{} +{},{} @@",
old_start + 1,
old_len,
new_start + 1,
new_len
);
let mut display = String::new();
display.push_str(&header);
display.push('\n');
let mut old_lines = Vec::new();
let mut new_lines = Vec::new();
for op in &group {
for change in diff.iter_changes(op) {
let line_str = change.as_str().unwrap_or("");
match change.tag() {
ChangeTag::Equal => {
display.push_str(&format!(" {}", line_str));
if !line_str.ends_with('\n') {
display.push('\n');
}
old_lines.push(line_str.to_string());
new_lines.push(line_str.to_string());
}
ChangeTag::Delete => {
display.push_str(&format!("-{}", line_str));
if !line_str.ends_with('\n') {
display.push('\n');
}
old_lines.push(line_str.to_string());
}
ChangeTag::Insert => {
display.push_str(&format!("+{}", line_str));
if !line_str.ends_with('\n') {
display.push('\n');
}
new_lines.push(line_str.to_string());
}
}
}
}
hunks.push(Hunk {
header,
display,
old_range: (old_start, old_end),
new_lines,
old_lines,
});
}
hunks
}
pub fn apply_hunks(original: &str, hunks: &[Hunk], accepted: &[bool]) -> String {
let orig_lines: Vec<&str> = original.lines().collect();
let mut result = Vec::new();
let mut pos = 0;
for (i, hunk) in hunks.iter().enumerate() {
let (hunk_start, hunk_end) = hunk.old_range;
for line in &orig_lines[pos..hunk_start] {
result.push((*line).to_string());
}
if accepted[i] {
for line in &hunk.new_lines {
result.push(line.strip_suffix('\n').unwrap_or(line).to_string());
}
} else {
for line in &orig_lines[hunk_start..hunk_end] {
result.push((*line).to_string());
}
}
pos = hunk_end;
}
for line in &orig_lines[pos..] {
result.push((*line).to_string());
}
let mut output = result.join("\n");
if original.ends_with('\n') {
output.push('\n');
}
output
}
pub fn interactive_adopt(
file_label: &str,
original: &str,
modified: &str,
) -> Result<Option<String>> {
let hunks = extract_hunks(original, modified);
if hunks.is_empty() {
return Ok(None);
}
let mut accepted = vec![false; hunks.len()];
let mut any_accepted = false;
println!("\n--- {}", file_label);
for (i, hunk) in hunks.iter().enumerate() {
println!();
println!("Hunk {}/{}", i + 1, hunks.len());
for line in hunk.display.lines() {
if line.starts_with('+') && !line.starts_with("+++") {
println!("{}", line.green());
} else if line.starts_with('-') && !line.starts_with("---") {
println!("{}", line.red());
} else if line.starts_with("@@") {
println!("{}", line.cyan());
} else {
println!("{}", line);
}
}
loop {
print!("Accept this hunk? [y/n/a/q/?] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let choice = input.trim().to_lowercase();
match choice.as_str() {
"y" | "yes" => {
accepted[i] = true;
any_accepted = true;
break;
}
"n" | "no" => {
break;
}
"a" | "all" => {
for item in accepted.iter_mut().skip(i) {
*item = true;
}
let result = apply_hunks(original, &hunks, &accepted);
return Ok(Some(result));
}
"q" | "quit" => {
if any_accepted {
let result = apply_hunks(original, &hunks, &accepted);
return Ok(Some(result));
}
return Ok(None);
}
_ => {
println!(" y = accept, n = reject, a = accept all remaining, q = quit");
}
}
}
}
if any_accepted {
let result = apply_hunks(original, &hunks, &accepted);
Ok(Some(result))
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_hunks_finds_changes() {
let original = "line1\nline2\nline3\nline4\nline5\n";
let modified = "line1\nchanged2\nline3\nline4\nnew5\n";
let hunks = extract_hunks(original, modified);
assert!(!hunks.is_empty());
}
#[test]
fn extract_hunks_empty_for_identical() {
let content = "line1\nline2\nline3\n";
let hunks = extract_hunks(content, content);
assert!(hunks.is_empty());
}
#[test]
fn apply_all_hunks_produces_modified() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nchanged2\nline3\n";
let hunks = extract_hunks(original, modified);
let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
let result = apply_hunks(original, &hunks, &accepted);
assert_eq!(result, modified);
}
#[test]
fn reject_all_hunks_produces_original() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nchanged2\nline3\n";
let hunks = extract_hunks(original, modified);
let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
let result = apply_hunks(original, &hunks, &accepted);
assert_eq!(result, original);
}
#[test]
fn apply_selective_hunks() {
let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\n";
let modified = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\nO\np\n";
let hunks = extract_hunks(original, modified);
if hunks.len() >= 2 {
let mut accepted = vec![false; hunks.len()];
accepted[0] = true;
let result = apply_hunks(original, &hunks, &accepted);
assert!(result.contains("\nB\n"));
assert!(result.contains("\no\n"));
}
}
#[test]
fn apply_hunks_with_additions() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nline2\nnew_line\nline3\n";
let hunks = extract_hunks(original, modified);
let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
let result = apply_hunks(original, &hunks, &accepted);
assert_eq!(result, modified);
}
#[test]
fn apply_hunks_with_deletions() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nline3\n";
let hunks = extract_hunks(original, modified);
let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
let result = apply_hunks(original, &hunks, &accepted);
assert_eq!(result, modified);
}
#[test]
fn reject_hunks_with_deletions_preserves_original() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nline3\n";
let hunks = extract_hunks(original, modified);
let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
let result = apply_hunks(original, &hunks, &accepted);
assert_eq!(result, original);
}
#[test]
fn hunk_header_present() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nchanged2\nline3\n";
let hunks = extract_hunks(original, modified);
assert!(!hunks.is_empty());
assert!(hunks[0].header.starts_with("@@"));
}
#[test]
fn hunk_display_contains_changes() {
let original = "line1\nline2\nline3\n";
let modified = "line1\nchanged2\nline3\n";
let hunks = extract_hunks(original, modified);
assert!(!hunks.is_empty());
assert!(hunks[0].display.contains("-line2"));
assert!(hunks[0].display.contains("+changed2"));
}
}