1use std::collections::BTreeSet;
14
15use super::{Diff, DiffLineKind};
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum PartialMode {
22 Stage,
25 Unstage,
28}
29
30pub fn is_change_line(kind: DiffLineKind) -> bool {
34 matches!(kind, DiffLineKind::Addition | DiffLineKind::Deletion)
35}
36
37pub fn build_partial_patch(
41 diff: &Diff,
42 selected: &BTreeSet<usize>,
43 mode: PartialMode,
44) -> Option<String> {
45 let lines = &diff.lines;
46 let mut out = String::new();
47 let mut emitted_any = false;
48
49 let mut file_header: Vec<&str> = Vec::new();
53 let mut file_header_written = false;
54 let mut delta: i64 = 0;
57
58 let mut i = 0;
59 while i < lines.len() {
60 match lines[i].kind {
61 DiffLineKind::FileHeader => {
62 file_header.clear();
63 file_header_written = false;
64 delta = 0;
65 while i < lines.len() && lines[i].kind == DiffLineKind::FileHeader {
66 if keep_header_line(&lines[i].text) {
67 file_header.push(&lines[i].text);
68 }
69 i += 1;
70 }
71 }
72 DiffLineKind::HunkHeader => {
73 let parsed = parse_hunk_header(&lines[i].text);
74 let body_start = i + 1;
75 let mut body_end = body_start;
76 while body_end < lines.len()
77 && !matches!(
78 lines[body_end].kind,
79 DiffLineKind::HunkHeader | DiffLineKind::FileHeader
80 )
81 {
82 body_end += 1;
83 }
84
85 if let (Some((old_a, new_c)), Some(hunk)) = (
86 parsed,
87 rebuild_hunk(lines, body_start, body_end, selected, mode),
88 ) {
89 if !file_header_written {
90 for h in &file_header {
91 out.push_str(h);
92 out.push('\n');
93 }
94 file_header_written = true;
95 }
96 let old_start = match mode {
100 PartialMode::Stage => old_a,
101 PartialMode::Unstage => new_c,
102 };
103 let new_start = (old_start as i64 + delta).max(0);
104 out.push_str(&format!(
105 "@@ -{},{} +{},{} @@\n",
106 old_start, hunk.old_count, new_start, hunk.new_count
107 ));
108 out.push_str(&hunk.body);
109 delta += hunk.new_count as i64 - hunk.old_count as i64;
110 emitted_any = true;
111 }
112 i = body_end;
113 }
114 _ => i += 1,
117 }
118 }
119
120 emitted_any.then_some(out)
121}
122
123struct RebuiltHunk {
125 body: String,
126 old_count: usize,
127 new_count: usize,
128}
129
130fn rebuild_hunk(
134 lines: &[super::DiffLine],
135 start: usize,
136 end: usize,
137 selected: &BTreeSet<usize>,
138 mode: PartialMode,
139) -> Option<RebuiltHunk> {
140 let mut body = String::new();
141 let mut old_count = 0;
142 let mut new_count = 0;
143 let mut has_change = false;
144 let mut prev_emitted = false;
145
146 for (idx, line) in lines.iter().enumerate().take(end).skip(start) {
147 if line.kind == DiffLineKind::Meta {
150 if prev_emitted && line.text.starts_with('\\') {
151 body.push_str(&line.text);
152 body.push('\n');
153 }
154 continue;
155 }
156
157 let selected_here = selected.contains(&idx);
158 let new_origin = map_origin(line.kind, selected_here, mode);
159 let Some(origin) = new_origin else {
160 prev_emitted = false;
161 continue;
162 };
163
164 if selected_here && is_change_line(line.kind) {
165 has_change = true;
166 }
167 let content = &line.text[1..];
170 body.push(origin);
171 body.push_str(content);
172 body.push('\n');
173 match origin {
174 ' ' => {
175 old_count += 1;
176 new_count += 1;
177 }
178 '-' => old_count += 1,
179 '+' => new_count += 1,
180 _ => {}
181 }
182 prev_emitted = true;
183 }
184
185 has_change.then_some(RebuiltHunk {
186 body,
187 old_count,
188 new_count,
189 })
190}
191
192fn map_origin(kind: DiffLineKind, selected: bool, mode: PartialMode) -> Option<char> {
195 match (kind, mode) {
196 (DiffLineKind::Context, _) => Some(' '),
197 (DiffLineKind::Addition, PartialMode::Stage) => selected.then_some('+'),
200 (DiffLineKind::Deletion, PartialMode::Stage) => Some(if selected { '-' } else { ' ' }),
201 (DiffLineKind::Addition, PartialMode::Unstage) => Some(if selected { '-' } else { ' ' }),
205 (DiffLineKind::Deletion, PartialMode::Unstage) => selected.then_some('+'),
206 _ => None,
207 }
208}
209
210fn keep_header_line(text: &str) -> bool {
214 !text.starts_with("index ")
215}
216
217fn parse_hunk_header(text: &str) -> Option<(usize, usize)> {
221 let rest = text.strip_prefix("@@ ")?;
222 let mut parts = rest.split_whitespace();
223 let old = parts.next()?.strip_prefix('-')?;
224 let new = parts.next()?.strip_prefix('+')?;
225 let a = old.split(',').next()?.parse().ok()?;
226 let c = new.split(',').next()?.parse().ok()?;
227 Some((a, c))
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::backend::DiffLine;
234
235 fn diff(rows: &[(DiffLineKind, &str)]) -> Diff {
236 Diff {
237 lines: rows
238 .iter()
239 .map(|(k, t)| DiffLine::new(*k, t.to_string()))
240 .collect(),
241 }
242 }
243
244 fn sample() -> Diff {
246 use DiffLineKind::*;
247 diff(&[
248 (FileHeader, "diff --git a/src/x.rs b/src/x.rs"),
249 (FileHeader, "index 1111111..2222222 100644"),
250 (FileHeader, "--- a/src/x.rs"),
251 (FileHeader, "+++ b/src/x.rs"),
252 (HunkHeader, "@@ -10,4 +10,5 @@ fn f() {"),
253 (Context, " let a = 1;"),
254 (Deletion, "- let b = 2;"),
255 (Addition, "+ let b = 3;"),
256 (Addition, "+ let c = 4;"),
257 (Context, " done();"),
258 ])
259 }
260
261 fn rows(range: std::ops::RangeInclusive<usize>) -> BTreeSet<usize> {
262 range.collect()
263 }
264
265 #[test]
266 fn empty_selection_yields_nothing() {
267 assert!(build_partial_patch(&sample(), &BTreeSet::new(), PartialMode::Stage).is_none());
268 assert!(build_partial_patch(&sample(), &rows(0..=5), PartialMode::Stage).is_none());
270 }
271
272 #[test]
273 fn stage_only_the_deletion_turns_additions_into_nothing() {
274 let patch = build_partial_patch(&sample(), &rows(6..=6), PartialMode::Stage).unwrap();
276 let expected = "\
277diff --git a/src/x.rs b/src/x.rs
278--- a/src/x.rs
279+++ b/src/x.rs
280@@ -10,3 +10,2 @@
281 let a = 1;
282- let b = 2;
283 done();
284";
285 assert_eq!(patch, expected);
286 }
287
288 #[test]
289 fn stage_only_the_additions_keeps_deletion_as_context() {
290 let patch = build_partial_patch(&sample(), &rows(7..=8), PartialMode::Stage).unwrap();
292 let expected = "\
293diff --git a/src/x.rs b/src/x.rs
294--- a/src/x.rs
295+++ b/src/x.rs
296@@ -10,3 +10,5 @@
297 let a = 1;
298 let b = 2;
299+ let b = 3;
300+ let c = 4;
301 done();
302";
303 assert_eq!(patch, expected);
304 }
305
306 #[test]
307 fn unstage_reverses_origins() {
308 let patch = build_partial_patch(&sample(), &rows(7..=8), PartialMode::Unstage).unwrap();
312 let expected = "\
313diff --git a/src/x.rs b/src/x.rs
314--- a/src/x.rs
315+++ b/src/x.rs
316@@ -10,4 +10,2 @@
317 let a = 1;
318- let b = 3;
319- let c = 4;
320 done();
321";
322 assert_eq!(patch, expected);
323 }
324
325 #[test]
326 fn second_hunk_new_start_tracks_prior_emitted_delta() {
327 use DiffLineKind::*;
328 let d = diff(&[
329 (FileHeader, "diff --git a/f b/f"),
330 (FileHeader, "--- a/f"),
331 (FileHeader, "+++ b/f"),
332 (HunkHeader, "@@ -10,2 +10,3 @@"),
333 (Context, " keep1"),
334 (Addition, "+added-a"),
335 (Context, " keep2"),
336 (HunkHeader, "@@ -50,2 +51,3 @@"),
337 (Context, " keep3"),
338 (Addition, "+added-b"),
339 (Context, " keep4"),
340 ]);
341 let mut sel = BTreeSet::new();
343 sel.insert(5);
344 sel.insert(9);
345 let patch = build_partial_patch(&d, &sel, PartialMode::Stage).unwrap();
346 let expected = "\
349diff --git a/f b/f
350--- a/f
351+++ b/f
352@@ -10,2 +10,3 @@
353 keep1
354+added-a
355 keep2
356@@ -50,2 +51,3 @@
357 keep3
358+added-b
359 keep4
360";
361 assert_eq!(patch, expected);
362 }
363
364 #[test]
365 fn unselected_hunk_is_omitted_entirely() {
366 use DiffLineKind::*;
367 let d = diff(&[
368 (FileHeader, "diff --git a/f b/f"),
369 (FileHeader, "--- a/f"),
370 (FileHeader, "+++ b/f"),
371 (HunkHeader, "@@ -10,2 +10,3 @@"),
372 (Context, " keep1"),
373 (Addition, "+added-a"),
374 (Context, " keep2"),
375 (HunkHeader, "@@ -50,2 +51,3 @@"),
376 (Context, " keep3"),
377 (Addition, "+added-b"),
378 (Context, " keep4"),
379 ]);
380 let patch = build_partial_patch(&d, &rows(9..=9), PartialMode::Stage).unwrap();
382 let expected = "\
383diff --git a/f b/f
384--- a/f
385+++ b/f
386@@ -50,2 +50,3 @@
387 keep3
388+added-b
389 keep4
390";
391 assert_eq!(patch, expected);
392 }
393}