1use anyhow::Result;
2use crossterm::style::Stylize;
3use similar::{ChangeTag, TextDiff};
4use std::io::Write;
5
6pub struct Hunk {
8 pub header: String,
10 pub display: String,
12 pub old_range: (usize, usize),
14 pub new_lines: Vec<String>,
16 pub old_lines: Vec<String>,
18}
19
20pub fn extract_hunks(original: &str, modified: &str) -> Vec<Hunk> {
22 let diff = TextDiff::from_lines(original, modified);
23 let mut hunks = Vec::new();
24
25 for group in diff.grouped_ops(3) {
26 if group.is_empty() {
27 continue;
28 }
29
30 let first = &group[0];
32 let last = &group[group.len() - 1];
33 let old_start = first.old_range().start;
34 let old_end = last.old_range().end;
35
36 let new_start = first.new_range().start;
38 let new_end = last.new_range().end;
39 let old_len = old_end - old_start;
40 let new_len = new_end - new_start;
41 let header = format!(
42 "@@ -{},{} +{},{} @@",
43 old_start + 1,
44 old_len,
45 new_start + 1,
46 new_len
47 );
48
49 let mut display = String::new();
53 display.push_str(&header);
54 display.push('\n');
55
56 let mut old_lines = Vec::new();
57 let mut new_lines = Vec::new();
58
59 for op in &group {
60 for change in diff.iter_changes(op) {
61 let line_str = change.as_str().unwrap_or("");
62 match change.tag() {
63 ChangeTag::Equal => {
64 display.push_str(&format!(" {}", line_str));
65 if !line_str.ends_with('\n') {
66 display.push('\n');
67 }
68 old_lines.push(line_str.to_string());
69 new_lines.push(line_str.to_string());
70 }
71 ChangeTag::Delete => {
72 display.push_str(&format!("-{}", line_str));
73 if !line_str.ends_with('\n') {
74 display.push('\n');
75 }
76 old_lines.push(line_str.to_string());
77 }
78 ChangeTag::Insert => {
79 display.push_str(&format!("+{}", line_str));
80 if !line_str.ends_with('\n') {
81 display.push('\n');
82 }
83 new_lines.push(line_str.to_string());
84 }
85 }
86 }
87 }
88
89 hunks.push(Hunk {
90 header,
91 display,
92 old_range: (old_start, old_end),
93 new_lines,
94 old_lines,
95 });
96 }
97
98 hunks
99}
100
101pub fn apply_hunks(original: &str, hunks: &[Hunk], accepted: &[bool]) -> String {
107 let orig_lines: Vec<&str> = original.lines().collect();
108 let mut result = Vec::new();
109 let mut pos = 0;
110
111 for (i, hunk) in hunks.iter().enumerate() {
112 let (hunk_start, hunk_end) = hunk.old_range;
113
114 for line in &orig_lines[pos..hunk_start] {
116 result.push((*line).to_string());
117 }
118
119 if accepted[i] {
120 for line in &hunk.new_lines {
122 result.push(line.strip_suffix('\n').unwrap_or(line).to_string());
124 }
125 } else {
126 for line in &orig_lines[hunk_start..hunk_end] {
128 result.push((*line).to_string());
129 }
130 }
131
132 pos = hunk_end;
133 }
134
135 for line in &orig_lines[pos..] {
137 result.push((*line).to_string());
138 }
139
140 let mut output = result.join("\n");
141 if original.ends_with('\n') {
143 output.push('\n');
144 }
145 output
146}
147
148pub fn interactive_adopt(
153 file_label: &str,
154 original: &str,
155 modified: &str,
156) -> Result<Option<String>> {
157 let hunks = extract_hunks(original, modified);
158 if hunks.is_empty() {
159 return Ok(None);
160 }
161
162 let mut accepted = vec![false; hunks.len()];
163 let mut any_accepted = false;
164
165 println!("\n--- {}", file_label);
166
167 for (i, hunk) in hunks.iter().enumerate() {
168 println!();
169 println!("Hunk {}/{}", i + 1, hunks.len());
170
171 for line in hunk.display.lines() {
173 if line.starts_with('+') && !line.starts_with("+++") {
174 println!("{}", line.green());
175 } else if line.starts_with('-') && !line.starts_with("---") {
176 println!("{}", line.red());
177 } else if line.starts_with("@@") {
178 println!("{}", line.cyan());
179 } else {
180 println!("{}", line);
181 }
182 }
183
184 loop {
186 print!("Accept this hunk? [y/n/a/q/?] ");
187 std::io::stdout().flush()?;
188
189 let mut input = String::new();
190 std::io::stdin().read_line(&mut input)?;
191 let choice = input.trim().to_lowercase();
192
193 match choice.as_str() {
194 "y" | "yes" => {
195 accepted[i] = true;
196 any_accepted = true;
197 break;
198 }
199 "n" | "no" => {
200 break;
201 }
202 "a" | "all" => {
203 for item in accepted.iter_mut().skip(i) {
204 *item = true;
205 }
206 let result = apply_hunks(original, &hunks, &accepted);
207 return Ok(Some(result));
208 }
209 "q" | "quit" => {
210 if any_accepted {
211 let result = apply_hunks(original, &hunks, &accepted);
212 return Ok(Some(result));
213 }
214 return Ok(None);
215 }
216 _ => {
217 println!(" y = accept, n = reject, a = accept all remaining, q = quit");
218 }
219 }
220 }
221 }
222
223 if any_accepted {
224 let result = apply_hunks(original, &hunks, &accepted);
225 Ok(Some(result))
226 } else {
227 Ok(None)
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn extract_hunks_finds_changes() {
237 let original = "line1\nline2\nline3\nline4\nline5\n";
238 let modified = "line1\nchanged2\nline3\nline4\nnew5\n";
239 let hunks = extract_hunks(original, modified);
240 assert!(!hunks.is_empty());
241 }
242
243 #[test]
244 fn extract_hunks_empty_for_identical() {
245 let content = "line1\nline2\nline3\n";
246 let hunks = extract_hunks(content, content);
247 assert!(hunks.is_empty());
248 }
249
250 #[test]
251 fn apply_all_hunks_produces_modified() {
252 let original = "line1\nline2\nline3\n";
253 let modified = "line1\nchanged2\nline3\n";
254 let hunks = extract_hunks(original, modified);
255 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
256 let result = apply_hunks(original, &hunks, &accepted);
257 assert_eq!(result, modified);
258 }
259
260 #[test]
261 fn reject_all_hunks_produces_original() {
262 let original = "line1\nline2\nline3\n";
263 let modified = "line1\nchanged2\nline3\n";
264 let hunks = extract_hunks(original, modified);
265 let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
266 let result = apply_hunks(original, &hunks, &accepted);
267 assert_eq!(result, original);
268 }
269
270 #[test]
271 fn apply_selective_hunks() {
272 let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\n";
274 let modified = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\nO\np\n";
275 let hunks = extract_hunks(original, modified);
276
277 if hunks.len() >= 2 {
278 let mut accepted = vec![false; hunks.len()];
280 accepted[0] = true;
281 let result = apply_hunks(original, &hunks, &accepted);
282 assert!(result.contains("\nB\n"));
284 assert!(result.contains("\no\n"));
285 }
286 }
287
288 #[test]
289 fn apply_hunks_with_additions() {
290 let original = "line1\nline2\nline3\n";
291 let modified = "line1\nline2\nnew_line\nline3\n";
292 let hunks = extract_hunks(original, modified);
293 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
294 let result = apply_hunks(original, &hunks, &accepted);
295 assert_eq!(result, modified);
296 }
297
298 #[test]
299 fn apply_hunks_with_deletions() {
300 let original = "line1\nline2\nline3\n";
301 let modified = "line1\nline3\n";
302 let hunks = extract_hunks(original, modified);
303 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
304 let result = apply_hunks(original, &hunks, &accepted);
305 assert_eq!(result, modified);
306 }
307
308 #[test]
309 fn reject_hunks_with_deletions_preserves_original() {
310 let original = "line1\nline2\nline3\n";
311 let modified = "line1\nline3\n";
312 let hunks = extract_hunks(original, modified);
313 let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
314 let result = apply_hunks(original, &hunks, &accepted);
315 assert_eq!(result, original);
316 }
317
318 #[test]
319 fn hunk_header_present() {
320 let original = "line1\nline2\nline3\n";
321 let modified = "line1\nchanged2\nline3\n";
322 let hunks = extract_hunks(original, modified);
323 assert!(!hunks.is_empty());
324 assert!(hunks[0].header.starts_with("@@"));
325 }
326
327 #[test]
328 fn hunk_display_contains_changes() {
329 let original = "line1\nline2\nline3\n";
330 let modified = "line1\nchanged2\nline3\n";
331 let hunks = extract_hunks(original, modified);
332 assert!(!hunks.is_empty());
333 assert!(hunks[0].display.contains("-line2"));
334 assert!(hunks[0].display.contains("+changed2"));
335 }
336}