Skip to main content

atman_runtime/
hunk.rs

1use std::path::PathBuf;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct EditProposal {
7    pub path: PathBuf,
8    pub original: String,
9    pub proposed: String,
10    pub hunks: Vec<Hunk>,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct Hunk {
15    pub id: u32,
16    pub old_start: u32,
17    pub old_len: u32,
18    pub new_start: u32,
19    pub new_len: u32,
20    pub lines: Vec<HunkLine>,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "kind", rename_all = "snake_case")]
25pub enum HunkLine {
26    Context { text: String },
27    Add { text: String },
28    Delete { text: String },
29}
30
31impl EditProposal {
32    pub fn compute(path: PathBuf, original: String, proposed: String) -> Self {
33        let hunks = extract_hunks(&original, &proposed);
34        Self {
35            path,
36            original,
37            proposed,
38            hunks,
39        }
40    }
41
42    pub fn apply_selected(&self, selected: &[u32]) -> Result<String, ApplyError> {
43        let selected_set: std::collections::HashSet<u32> = selected.iter().copied().collect();
44        let mut lines: Vec<String> = self
45            .original
46            .split_inclusive('\n')
47            .map(String::from)
48            .collect();
49        let trailing_newline_original = self.original.ends_with('\n');
50        if !trailing_newline_original && let Some(last) = lines.last_mut() {
51            let s: &str = last;
52            if !s.ends_with('\n') {
53                last.push('\n');
54            }
55        }
56
57        let mut sorted: Vec<&Hunk> = self
58            .hunks
59            .iter()
60            .filter(|h| selected_set.contains(&h.id))
61            .collect();
62        sorted.sort_by_key(|h| std::cmp::Reverse(h.old_start));
63        for hunk in sorted {
64            let old_start = hunk.old_start as usize;
65            let old_len = hunk.old_len as usize;
66            if old_start.saturating_add(old_len) > lines.len() {
67                return Err(ApplyError::OutOfRange {
68                    hunk_id: hunk.id,
69                    old_start: hunk.old_start,
70                    old_len: hunk.old_len,
71                    file_len: lines.len() as u32,
72                });
73            }
74            let replacement: Vec<String> = hunk
75                .lines
76                .iter()
77                .filter_map(|l| match l {
78                    HunkLine::Add { text } | HunkLine::Context { text } => {
79                        Some(ensure_newline(text.clone()))
80                    }
81                    HunkLine::Delete { .. } => None,
82                })
83                .collect();
84            lines.splice(old_start..old_start + old_len, replacement);
85        }
86
87        let mut out: String = lines.into_iter().collect();
88        if !trailing_newline_original && out.ends_with('\n') {
89            out.pop();
90        }
91        Ok(out)
92    }
93}
94
95fn ensure_newline(mut s: String) -> String {
96    if !s.ends_with('\n') {
97        s.push('\n');
98    }
99    s
100}
101
102#[derive(Debug, Clone, thiserror::Error)]
103pub enum ApplyError {
104    #[error(
105        "hunk {hunk_id} out of range: old_start={old_start} old_len={old_len} file_len={file_len}"
106    )]
107    OutOfRange {
108        hunk_id: u32,
109        old_start: u32,
110        old_len: u32,
111        file_len: u32,
112    },
113}
114
115fn extract_hunks(original: &str, proposed: &str) -> Vec<Hunk> {
116    use similar::{ChangeTag, TextDiff};
117
118    let diff = TextDiff::from_lines(original, proposed);
119    let mut hunks = Vec::new();
120    let mut next_id: u32 = 1;
121    for group in diff.grouped_ops(3) {
122        if group.is_empty() {
123            continue;
124        }
125        let old_start = group[0].old_range().start as u32;
126        let new_start = group[0].new_range().start as u32;
127        let mut old_end = old_start;
128        let mut new_end = new_start;
129        let mut lines: Vec<HunkLine> = Vec::new();
130        for op in &group {
131            for change in diff.iter_changes(op) {
132                let raw: &str = change.value();
133                let text = strip_trailing_newline(raw.to_string());
134                match change.tag() {
135                    ChangeTag::Equal => {
136                        lines.push(HunkLine::Context { text });
137                    }
138                    ChangeTag::Insert => {
139                        lines.push(HunkLine::Add { text });
140                    }
141                    ChangeTag::Delete => {
142                        lines.push(HunkLine::Delete { text });
143                    }
144                }
145            }
146            old_end = op.old_range().end as u32;
147            new_end = op.new_range().end as u32;
148        }
149        hunks.push(Hunk {
150            id: next_id,
151            old_start,
152            old_len: old_end - old_start,
153            new_start,
154            new_len: new_end - new_start,
155            lines,
156        });
157        next_id += 1;
158    }
159    hunks
160}
161
162fn strip_trailing_newline(mut s: String) -> String {
163    if s.ends_with('\n') {
164        s.pop();
165        if s.ends_with('\r') {
166            s.pop();
167        }
168    }
169    s
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn identical_files_produce_no_hunks() {
178        let p = EditProposal::compute("/x".into(), "a\nb\nc\n".into(), "a\nb\nc\n".into());
179        assert!(p.hunks.is_empty());
180    }
181
182    #[test]
183    fn single_line_change_produces_one_hunk() {
184        let p = EditProposal::compute("/x".into(), "a\nb\nc\n".into(), "a\nB\nc\n".into());
185        assert_eq!(p.hunks.len(), 1);
186        let h = &p.hunks[0];
187        assert_eq!(h.id, 1);
188        let deletes: Vec<&str> = h
189            .lines
190            .iter()
191            .filter_map(|l| match l {
192                HunkLine::Delete { text } => Some(text.as_str()),
193                _ => None,
194            })
195            .collect();
196        assert_eq!(deletes, vec!["b"]);
197        let adds: Vec<&str> = h
198            .lines
199            .iter()
200            .filter_map(|l| match l {
201                HunkLine::Add { text } => Some(text.as_str()),
202                _ => None,
203            })
204            .collect();
205        assert_eq!(adds, vec!["B"]);
206    }
207
208    #[test]
209    fn far_apart_changes_produce_two_separate_hunks() {
210        let original: String = (0..20).map(|i| format!("line{i}\n")).collect();
211        let mut proposed = original.clone();
212        proposed = proposed.replace("line2\n", "LINE2\n");
213        proposed = proposed.replace("line17\n", "LINE17\n");
214        let p = EditProposal::compute("/x".into(), original, proposed);
215        assert_eq!(p.hunks.len(), 2, "hunks: {:#?}", p.hunks);
216        assert_eq!(p.hunks[0].id, 1);
217        assert_eq!(p.hunks[1].id, 2);
218    }
219
220    #[test]
221    fn apply_all_hunks_equals_full_replacement() {
222        let orig: String = (0..20).map(|i| format!("l{i}\n")).collect();
223        let mut proposed = orig.clone();
224        proposed = proposed.replace("l3\n", "L3\n");
225        proposed = proposed.replace("l15\n", "L15\n");
226        let p = EditProposal::compute("/x".into(), orig, proposed.clone());
227        assert_eq!(p.hunks.len(), 2);
228        let ids: Vec<u32> = p.hunks.iter().map(|h| h.id).collect();
229        let out = p.apply_selected(&ids).unwrap();
230        assert_eq!(out, proposed);
231    }
232
233    #[test]
234    fn apply_no_hunks_returns_original() {
235        let orig: String = (0..10).map(|i| format!("l{i}\n")).collect();
236        let mut proposed = orig.clone();
237        proposed = proposed.replace("l3\n", "L3\n");
238        let p = EditProposal::compute("/x".into(), orig.clone(), proposed);
239        let out = p.apply_selected(&[]).unwrap();
240        assert_eq!(out, orig);
241    }
242
243    #[test]
244    fn apply_selects_only_marked_hunks() {
245        let orig: String = (0..20).map(|i| format!("l{i}\n")).collect();
246        let mut proposed = orig.clone();
247        proposed = proposed.replace("l3\n", "L3\n");
248        proposed = proposed.replace("l15\n", "L15\n");
249        let p = EditProposal::compute("/x".into(), orig, proposed);
250        assert_eq!(p.hunks.len(), 2);
251        let out = p.apply_selected(&[p.hunks[0].id]).unwrap();
252        assert!(out.contains("L3\n"), "hunk 1 (l3) must be applied: {out}");
253        assert!(
254            !out.contains("L15\n"),
255            "hunk 2 (l15) must NOT be applied: {out}"
256        );
257        assert!(
258            out.contains("l15\n"),
259            "hunk 2 line must retain original: {out}"
260        );
261    }
262
263    #[test]
264    fn preserves_no_trailing_newline_when_original_has_none() {
265        let orig = "a\nb\nc".to_string();
266        let proposed = "a\nB\nc".to_string();
267        let p = EditProposal::compute("/x".into(), orig, proposed.clone());
268        let ids: Vec<u32> = p.hunks.iter().map(|h| h.id).collect();
269        let out = p.apply_selected(&ids).unwrap();
270        assert_eq!(out, proposed);
271        assert!(!out.ends_with('\n'));
272    }
273
274    #[test]
275    fn roundtrip_via_serde_json() {
276        let p = EditProposal::compute("/x".into(), "a\nb\n".into(), "a\nB\n".into());
277        let s = serde_json::to_string(&p).unwrap();
278        let back: EditProposal = serde_json::from_str(&s).unwrap();
279        assert_eq!(p, back);
280    }
281}