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 = change.to_string_lossy();
62 let line_str = line.as_ref();
63 match change.tag() {
64 ChangeTag::Equal => {
65 display.push_str(&format!(" {}", line_str));
66 if !line_str.ends_with('\n') {
67 display.push('\n');
68 }
69 old_lines.push(line_str.to_string());
70 new_lines.push(line_str.to_string());
71 }
72 ChangeTag::Delete => {
73 display.push_str(&format!("-{}", line_str));
74 if !line_str.ends_with('\n') {
75 display.push('\n');
76 }
77 old_lines.push(line_str.to_string());
78 }
79 ChangeTag::Insert => {
80 display.push_str(&format!("+{}", line_str));
81 if !line_str.ends_with('\n') {
82 display.push('\n');
83 }
84 new_lines.push(line_str.to_string());
85 }
86 }
87 }
88 }
89
90 hunks.push(Hunk {
91 header,
92 display,
93 old_range: (old_start, old_end),
94 new_lines,
95 old_lines,
96 });
97 }
98
99 hunks
100}
101
102pub fn apply_hunks(original: &str, hunks: &[Hunk], accepted: &[bool]) -> String {
108 let orig_lines: Vec<&str> = original.lines().collect();
109 let mut result = Vec::new();
110 let mut pos = 0;
111
112 for (i, hunk) in hunks.iter().enumerate() {
113 let (hunk_start, hunk_end) = hunk.old_range;
114
115 for line in &orig_lines[pos..hunk_start] {
117 result.push((*line).to_string());
118 }
119
120 if accepted[i] {
121 for line in &hunk.new_lines {
123 result.push(line.strip_suffix('\n').unwrap_or(line).to_string());
125 }
126 } else {
127 for line in &orig_lines[hunk_start..hunk_end] {
129 result.push((*line).to_string());
130 }
131 }
132
133 pos = hunk_end;
134 }
135
136 for line in &orig_lines[pos..] {
138 result.push((*line).to_string());
139 }
140
141 let mut output = result.join("\n");
142 if original.ends_with('\n') {
144 output.push('\n');
145 }
146 output
147}
148
149pub fn interactive_adopt(file_label: &str, original: &str, modified: &str) -> Result<Option<String>> {
154 let hunks = extract_hunks(original, modified);
155 if hunks.is_empty() {
156 return Ok(None);
157 }
158
159 let mut accepted = vec![false; hunks.len()];
160 let mut any_accepted = false;
161
162 println!("\n--- {}", file_label);
163
164 for (i, hunk) in hunks.iter().enumerate() {
165 println!();
166 println!("Hunk {}/{}", i + 1, hunks.len());
167
168 for line in hunk.display.lines() {
170 if line.starts_with('+') && !line.starts_with("+++") {
171 println!("{}", line.green());
172 } else if line.starts_with('-') && !line.starts_with("---") {
173 println!("{}", line.red());
174 } else if line.starts_with("@@") {
175 println!("{}", line.cyan());
176 } else {
177 println!("{}", line);
178 }
179 }
180
181 loop {
183 print!("Accept this change? [y/n/a/q] ");
184 std::io::stdout().flush()?;
185
186 let mut input = String::new();
187 std::io::stdin().read_line(&mut input)?;
188 let choice = input.trim().to_lowercase();
189
190 match choice.as_str() {
191 "y" | "yes" => {
192 accepted[i] = true;
193 any_accepted = true;
194 break;
195 }
196 "n" | "no" => {
197 break;
198 }
199 "a" | "all" => {
200 for item in accepted.iter_mut().take(hunks.len()).skip(i) {
201 *item = true;
202 }
203 let result = apply_hunks(original, &hunks, &accepted);
204 return Ok(Some(result));
205 }
206 "q" | "quit" => {
207 if any_accepted {
208 let result = apply_hunks(original, &hunks, &accepted);
209 return Ok(Some(result));
210 }
211 return Ok(None);
212 }
213 _ => {
214 println!(" y = accept, n = reject, a = accept all remaining, q = quit");
215 }
216 }
217 }
218 }
219
220 if any_accepted {
221 let result = apply_hunks(original, &hunks, &accepted);
222 Ok(Some(result))
223 } else {
224 Ok(None)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn extract_hunks_finds_changes() {
234 let original = "line1\nline2\nline3\nline4\nline5\n";
235 let modified = "line1\nchanged2\nline3\nline4\nnew5\n";
236 let hunks = extract_hunks(original, modified);
237 assert!(!hunks.is_empty());
238 }
239
240 #[test]
241 fn extract_hunks_empty_for_identical() {
242 let content = "line1\nline2\nline3\n";
243 let hunks = extract_hunks(content, content);
244 assert!(hunks.is_empty());
245 }
246
247 #[test]
248 fn apply_all_hunks_produces_modified() {
249 let original = "line1\nline2\nline3\n";
250 let modified = "line1\nchanged2\nline3\n";
251 let hunks = extract_hunks(original, modified);
252 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
253 let result = apply_hunks(original, &hunks, &accepted);
254 assert_eq!(result, modified);
255 }
256
257 #[test]
258 fn reject_all_hunks_produces_original() {
259 let original = "line1\nline2\nline3\n";
260 let modified = "line1\nchanged2\nline3\n";
261 let hunks = extract_hunks(original, modified);
262 let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
263 let result = apply_hunks(original, &hunks, &accepted);
264 assert_eq!(result, original);
265 }
266
267 #[test]
268 fn apply_selective_hunks() {
269 let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\n";
271 let modified = "a\nB\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\nO\np\n";
272 let hunks = extract_hunks(original, modified);
273
274 if hunks.len() >= 2 {
275 let mut accepted = vec![false; hunks.len()];
277 accepted[0] = true;
278 let result = apply_hunks(original, &hunks, &accepted);
279 assert!(result.contains("\nB\n"));
281 assert!(result.contains("\no\n"));
282 }
283 }
284
285 #[test]
286 fn apply_hunks_with_additions() {
287 let original = "line1\nline2\nline3\n";
288 let modified = "line1\nline2\nnew_line\nline3\n";
289 let hunks = extract_hunks(original, modified);
290 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
291 let result = apply_hunks(original, &hunks, &accepted);
292 assert_eq!(result, modified);
293 }
294
295 #[test]
296 fn apply_hunks_with_deletions() {
297 let original = "line1\nline2\nline3\n";
298 let modified = "line1\nline3\n";
299 let hunks = extract_hunks(original, modified);
300 let accepted: Vec<bool> = hunks.iter().map(|_| true).collect();
301 let result = apply_hunks(original, &hunks, &accepted);
302 assert_eq!(result, modified);
303 }
304
305 #[test]
306 fn reject_hunks_with_deletions_preserves_original() {
307 let original = "line1\nline2\nline3\n";
308 let modified = "line1\nline3\n";
309 let hunks = extract_hunks(original, modified);
310 let accepted: Vec<bool> = hunks.iter().map(|_| false).collect();
311 let result = apply_hunks(original, &hunks, &accepted);
312 assert_eq!(result, original);
313 }
314
315 #[test]
316 fn hunk_header_present() {
317 let original = "line1\nline2\nline3\n";
318 let modified = "line1\nchanged2\nline3\n";
319 let hunks = extract_hunks(original, modified);
320 assert!(!hunks.is_empty());
321 assert!(hunks[0].header.starts_with("@@"));
322 }
323
324 #[test]
325 fn hunk_display_contains_changes() {
326 let original = "line1\nline2\nline3\n";
327 let modified = "line1\nchanged2\nline3\n";
328 let hunks = extract_hunks(original, modified);
329 assert!(!hunks.is_empty());
330 assert!(hunks[0].display.contains("-line2"));
331 assert!(hunks[0].display.contains("+changed2"));
332 }
333}