Skip to main content

dotm/
adopt.rs

1use anyhow::Result;
2use crossterm::style::Stylize;
3use similar::{ChangeTag, TextDiff};
4use std::io::Write;
5
6/// A single diff hunk representing a localized change between the original and modified file.
7pub struct Hunk {
8    /// The unified diff header (e.g., "@@ -1,3 +1,4 @@")
9    pub header: String,
10    /// Formatted hunk text for display (with +/- lines and context)
11    pub display: String,
12    /// Range of lines in the original text that this hunk covers (start index, exclusive end)
13    pub old_range: (usize, usize),
14    /// The replacement lines from the modified version
15    pub new_lines: Vec<String>,
16    /// The original lines being replaced
17    pub old_lines: Vec<String>,
18}
19
20/// Compute the diff between `original` and `modified`, returning structured hunks.
21pub 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        // Compute the overall old/new ranges for this hunk group
31        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        // Build the header
37        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        // Build display text and collect the full new-side lines for this hunk.
50        // new_lines gets Equal + Insert lines (the full replacement when accepted).
51        // old_lines gets Equal + Delete lines (should match original[old_start..old_end]).
52        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
101/// Apply selected hunks to the original text, producing the patched result.
102///
103/// For each hunk, if `accepted[i]` is true, the old lines in that region are replaced
104/// with the new lines from the modified version. If false, the original lines are kept.
105/// Lines outside any hunk are always preserved from the original.
106pub 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        // Copy lines before this hunk (between previous hunk end and this hunk start)
115        for line in &orig_lines[pos..hunk_start] {
116            result.push((*line).to_string());
117        }
118
119        if accepted[i] {
120            // Use the new lines from the modified version
121            for line in &hunk.new_lines {
122                // Strip trailing newline if present since we rejoin with \n
123                result.push(line.strip_suffix('\n').unwrap_or(line).to_string());
124            }
125        } else {
126            // Keep the original lines
127            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    // Copy any remaining lines after the last hunk
136    for line in &orig_lines[pos..] {
137        result.push((*line).to_string());
138    }
139
140    let mut output = result.join("\n");
141    // Preserve trailing newline if original had one
142    if original.ends_with('\n') {
143        output.push('\n');
144    }
145    output
146}
147
148/// Interactively prompt the user to accept or reject each hunk of changes.
149///
150/// Returns `Some(patched_content)` if any hunks were accepted, `None` if all were
151/// rejected or the user quit early.
152pub 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        // Display the hunk with colored output
172        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        // Prompt for action
185        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        // With enough separation between changes, they should be separate hunks
273        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            // Accept only the first hunk
279            let mut accepted = vec![false; hunks.len()];
280            accepted[0] = true;
281            let result = apply_hunks(original, &hunks, &accepted);
282            // First change applied (b -> B), second not (o stays o)
283            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}