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 = 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
102/// Apply selected hunks to the original text, producing the patched result.
103///
104/// For each hunk, if `accepted[i]` is true, the old lines in that region are replaced
105/// with the new lines from the modified version. If false, the original lines are kept.
106/// Lines outside any hunk are always preserved from the original.
107pub 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        // Copy lines before this hunk (between previous hunk end and this hunk start)
116        for line in &orig_lines[pos..hunk_start] {
117            result.push((*line).to_string());
118        }
119
120        if accepted[i] {
121            // Use the new lines from the modified version
122            for line in &hunk.new_lines {
123                // Strip trailing newline if present since we rejoin with \n
124                result.push(line.strip_suffix('\n').unwrap_or(line).to_string());
125            }
126        } else {
127            // Keep the original lines
128            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    // Copy any remaining lines after the last hunk
137    for line in &orig_lines[pos..] {
138        result.push((*line).to_string());
139    }
140
141    let mut output = result.join("\n");
142    // Preserve trailing newline if original had one
143    if original.ends_with('\n') {
144        output.push('\n');
145    }
146    output
147}
148
149/// Interactively prompt the user to accept or reject each hunk of changes.
150///
151/// Returns `Some(patched_content)` if any hunks were accepted, `None` if all were
152/// rejected or the user quit early.
153pub 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        // Display the hunk with colored output
169        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        // Prompt for action
182        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        // With enough separation between changes, they should be separate hunks
270        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            // Accept only the first hunk
276            let mut accepted = vec![false; hunks.len()];
277            accepted[0] = true;
278            let result = apply_hunks(original, &hunks, &accepted);
279            // First change applied (b -> B), second not (o stays o)
280            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}