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}