1use std::fmt;
7
8use crate::diff::{DiffLine, LineSource};
9
10const CONTEXT_LINES: usize = 3;
12
13#[derive(Debug, Clone)]
15struct PatchLine {
16 prefix: char,
18 content: String,
20 old_line: Option<usize>,
22 new_line: Option<usize>,
24}
25
26#[derive(Debug)]
28struct Hunk {
29 old_start: usize,
31 old_count: usize,
33 new_start: usize,
35 new_count: usize,
37 lines: Vec<PatchLine>,
39}
40
41impl Hunk {
42 fn header(&self) -> String {
44 let old_range = format_range(self.old_start, self.old_count);
45 let new_range = format_range(self.new_start, self.new_count);
46 format!("@@ -{} +{} @@", old_range, new_range)
47 }
48}
49
50fn format_range(start: usize, count: usize) -> String {
52 if count == 1 {
53 start.to_string()
54 } else {
55 format!("{},{}", start, count)
56 }
57}
58
59impl fmt::Display for Hunk {
60 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61 writeln!(f, "{}", self.header())?;
62 for line in &self.lines {
63 writeln!(f, "{}{}", line.prefix, line.content)?;
64 }
65 Ok(())
66 }
67}
68
69#[derive(Debug)]
71struct FilePatch {
72 path: String,
73 hunks: Vec<Hunk>,
74 is_new_file: bool,
75 is_deleted_file: bool,
76}
77
78impl FilePatch {
79 fn new(path: String, hunks: Vec<Hunk>) -> Self {
80 let is_new_file = !hunks.is_empty() && hunks.iter().all(|h| h.old_count == 0);
82 let is_deleted_file = !hunks.is_empty() && hunks.iter().all(|h| h.new_count == 0);
84
85 Self {
86 path,
87 hunks,
88 is_new_file,
89 is_deleted_file,
90 }
91 }
92}
93
94impl fmt::Display for FilePatch {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 if self.hunks.is_empty() {
97 return Ok(());
98 }
99
100 writeln!(f, "diff --git a/{} b/{}", self.path, self.path)?;
101
102 if self.is_new_file {
104 writeln!(f, "new file mode 100644")?;
105 writeln!(f, "--- /dev/null")?;
106 writeln!(f, "+++ b/{}", self.path)?;
107 } else if self.is_deleted_file {
108 writeln!(f, "deleted file mode 100644")?;
109 writeln!(f, "--- a/{}", self.path)?;
110 writeln!(f, "+++ /dev/null")?;
111 } else {
112 writeln!(f, "--- a/{}", self.path)?;
113 writeln!(f, "+++ b/{}", self.path)?;
114 }
115
116 for hunk in &self.hunks {
117 write!(f, "{}", hunk)?;
118 }
119
120 Ok(())
121 }
122}
123
124fn line_source_to_prefix(source: &LineSource) -> Option<char> {
127 match source {
128 LineSource::Base => Some(' '),
130
131 LineSource::Committed | LineSource::Staged | LineSource::Unstaged => Some('+'),
133
134 LineSource::DeletedBase | LineSource::DeletedCommitted | LineSource::DeletedStaged => {
136 Some('-')
137 }
138
139 LineSource::CanceledCommitted
141 | LineSource::CanceledStaged
142 | LineSource::FileHeader
143 | LineSource::Elided => None,
144 }
145}
146
147fn diff_lines_to_patch_lines(lines: &[DiffLine]) -> Vec<PatchLine> {
149 let mut patch_lines = Vec::new();
150 let mut old_line_num = 0usize;
151 let mut new_line_num = 0usize;
152
153 for diff_line in lines {
154 let Some(prefix) = line_source_to_prefix(&diff_line.source) else {
155 continue;
156 };
157
158 let (old_line, new_line) = match prefix {
160 ' ' => {
161 old_line_num += 1;
163 new_line_num += 1;
164 (Some(old_line_num), Some(new_line_num))
165 }
166 '-' => {
167 old_line_num += 1;
169 (Some(old_line_num), None)
170 }
171 '+' => {
172 new_line_num += 1;
174 (None, Some(new_line_num))
175 }
176 _ => (None, None),
177 };
178
179 patch_lines.push(PatchLine {
180 prefix,
181 content: diff_line.content.clone(),
182 old_line,
183 new_line,
184 });
185 }
186
187 patch_lines
188}
189
190fn find_change_indices(lines: &[PatchLine]) -> Vec<usize> {
192 lines
193 .iter()
194 .enumerate()
195 .filter(|(_, line)| line.prefix == '+' || line.prefix == '-')
196 .map(|(i, _)| i)
197 .collect()
198}
199
200fn build_hunks(lines: &[PatchLine]) -> Vec<Hunk> {
202 if lines.is_empty() {
203 return Vec::new();
204 }
205
206 let change_indices = find_change_indices(lines);
207 if change_indices.is_empty() {
208 return Vec::new();
209 }
210
211 let mut included = vec![false; lines.len()];
213
214 for &idx in &change_indices {
215 included[idx] = true;
217
218 let start = idx.saturating_sub(CONTEXT_LINES);
220 for item in included.iter_mut().take(idx).skip(start) {
221 *item = true;
222 }
223
224 let end = (idx + CONTEXT_LINES + 1).min(lines.len());
226 for item in included.iter_mut().take(end).skip(idx + 1) {
227 *item = true;
228 }
229 }
230
231 let mut hunks = Vec::new();
233 let mut hunk_start: Option<usize> = None;
234
235 for (i, &inc) in included.iter().enumerate() {
236 match (inc, hunk_start) {
237 (true, None) => {
238 hunk_start = Some(i);
239 }
240 (false, Some(start)) => {
241 hunks.push(create_hunk(&lines[start..i]));
242 hunk_start = None;
243 }
244 _ => {}
245 }
246 }
247
248 if let Some(start) = hunk_start {
250 hunks.push(create_hunk(&lines[start..]));
251 }
252
253 hunks
254}
255
256fn create_hunk(lines: &[PatchLine]) -> Hunk {
258 let mut old_count = 0;
259 let mut new_count = 0;
260 let mut old_start = None;
261 let mut new_start = None;
262
263 for line in lines {
264 match line.prefix {
265 ' ' => {
266 old_count += 1;
267 new_count += 1;
268 if old_start.is_none() {
269 old_start = line.old_line;
270 }
271 if new_start.is_none() {
272 new_start = line.new_line;
273 }
274 }
275 '-' => {
276 old_count += 1;
277 if old_start.is_none() {
278 old_start = line.old_line;
279 }
280 }
281 '+' => {
282 new_count += 1;
283 if new_start.is_none() {
284 new_start = line.new_line;
285 }
286 }
287 _ => {}
288 }
289 }
290
291 let old_start = if old_count == 0 { 0 } else { old_start.unwrap_or(1) };
296 let new_start = if new_count == 0 { 0 } else { new_start.unwrap_or(1) };
297
298 Hunk {
299 old_start,
300 old_count,
301 new_start,
302 new_count,
303 lines: lines.to_vec(),
304 }
305}
306
307pub fn generate_patch(lines: &[DiffLine]) -> String {
312 let mut files: Vec<(String, Vec<&DiffLine>)> = Vec::new();
314 let mut current_path: Option<String> = None;
315
316 for line in lines {
317 let path = match &line.file_path {
318 Some(p) if !p.is_empty() => p.clone(),
319 _ => continue, };
321
322 if current_path.as_ref() != Some(&path) {
323 files.push((path.clone(), Vec::new()));
324 current_path = Some(path);
325 }
326
327 if let Some((_, file_lines)) = files.last_mut() {
328 file_lines.push(line);
329 }
330 }
331
332 let mut output = String::new();
334
335 for (path, file_lines) in files {
336 let owned_lines: Vec<DiffLine> = file_lines.into_iter().cloned().collect();
337 let patch_lines = diff_lines_to_patch_lines(&owned_lines);
338 let hunks = build_hunks(&patch_lines);
339
340 let file_patch = FilePatch::new(path, hunks);
341 output.push_str(&file_patch.to_string());
342 }
343
344 output
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn make_diff_line(source: LineSource, content: &str, file_path: &str) -> DiffLine {
352 DiffLine {
353 source,
354 content: content.to_string(),
355 prefix: match source {
356 LineSource::Base => ' ',
357 LineSource::Committed | LineSource::Staged | LineSource::Unstaged => '+',
358 LineSource::DeletedBase
359 | LineSource::DeletedCommitted
360 | LineSource::DeletedStaged => '-',
361 _ => ' ',
362 },
363 line_number: None,
364 file_path: Some(file_path.to_string()),
365 inline_spans: Vec::new(),
366 old_content: None,
367 change_source: None,
368 in_current_bookmark: None,
369 }
370 }
371
372 #[test]
373 fn test_simple_addition() {
374 let lines = vec![
375 make_diff_line(LineSource::Base, "line 1", "test.txt"),
376 make_diff_line(LineSource::Base, "line 2", "test.txt"),
377 make_diff_line(LineSource::Base, "line 3", "test.txt"),
378 make_diff_line(LineSource::Committed, "new line", "test.txt"),
379 make_diff_line(LineSource::Base, "line 4", "test.txt"),
380 make_diff_line(LineSource::Base, "line 5", "test.txt"),
381 make_diff_line(LineSource::Base, "line 6", "test.txt"),
382 ];
383
384 let patch = generate_patch(&lines);
385
386 assert!(patch.contains("diff --git a/test.txt b/test.txt"));
387 assert!(patch.contains("--- a/test.txt"));
388 assert!(patch.contains("+++ b/test.txt"));
389 assert!(patch.contains("+new line"));
390 assert!(patch.contains("@@ -"));
391 }
392
393 #[test]
394 fn test_simple_deletion() {
395 let lines = vec![
396 make_diff_line(LineSource::Base, "line 1", "test.txt"),
397 make_diff_line(LineSource::Base, "line 2", "test.txt"),
398 make_diff_line(LineSource::Base, "line 3", "test.txt"),
399 make_diff_line(LineSource::DeletedCommitted, "deleted line", "test.txt"),
400 make_diff_line(LineSource::Base, "line 4", "test.txt"),
401 make_diff_line(LineSource::Base, "line 5", "test.txt"),
402 make_diff_line(LineSource::Base, "line 6", "test.txt"),
403 ];
404
405 let patch = generate_patch(&lines);
406
407 assert!(patch.contains("-deleted line"));
408 }
409
410 #[test]
411 fn test_mixed_changes() {
412 let lines = vec![
413 make_diff_line(LineSource::Base, "context", "test.txt"),
414 make_diff_line(LineSource::DeletedStaged, "old line", "test.txt"),
415 make_diff_line(LineSource::Staged, "new line", "test.txt"),
416 make_diff_line(LineSource::Base, "more context", "test.txt"),
417 ];
418
419 let patch = generate_patch(&lines);
420
421 assert!(patch.contains("-old line"));
422 assert!(patch.contains("+new line"));
423 }
424
425 #[test]
426 fn test_multiple_files() {
427 let lines = vec![
428 make_diff_line(LineSource::Base, "file1 line", "file1.txt"),
429 make_diff_line(LineSource::Committed, "file1 addition", "file1.txt"),
430 make_diff_line(LineSource::Base, "file2 line", "file2.txt"),
431 make_diff_line(LineSource::Unstaged, "file2 addition", "file2.txt"),
432 ];
433
434 let patch = generate_patch(&lines);
435
436 assert!(patch.contains("diff --git a/file1.txt b/file1.txt"));
437 assert!(patch.contains("diff --git a/file2.txt b/file2.txt"));
438 assert!(patch.contains("+file1 addition"));
439 assert!(patch.contains("+file2 addition"));
440 }
441
442 #[test]
443 fn test_skips_canceled_lines() {
444 let lines = vec![
445 make_diff_line(LineSource::Base, "context", "test.txt"),
446 make_diff_line(LineSource::CanceledCommitted, "canceled", "test.txt"),
447 make_diff_line(LineSource::Committed, "actual change", "test.txt"),
448 ];
449
450 let patch = generate_patch(&lines);
451
452 assert!(!patch.contains("canceled"));
453 assert!(patch.contains("+actual change"));
454 }
455
456 #[test]
457 fn test_skips_file_header() {
458 let lines = vec![
459 make_diff_line(LineSource::FileHeader, "src/test.txt", "test.txt"),
460 make_diff_line(LineSource::Base, "context", "test.txt"),
461 make_diff_line(LineSource::Committed, "change", "test.txt"),
462 ];
463
464 let patch = generate_patch(&lines);
465
466 let lines: Vec<&str> = patch.lines().collect();
468 assert!(!lines.iter().any(|l| *l == "+src/test.txt" || *l == "-src/test.txt"));
469 }
470
471 #[test]
472 fn test_empty_diff() {
473 let lines: Vec<DiffLine> = vec![];
474 let patch = generate_patch(&lines);
475 assert!(patch.is_empty());
476 }
477
478 #[test]
479 fn test_no_changes() {
480 let lines = vec![
481 make_diff_line(LineSource::Base, "line 1", "test.txt"),
482 make_diff_line(LineSource::Base, "line 2", "test.txt"),
483 ];
484
485 let patch = generate_patch(&lines);
486
487 assert!(!patch.contains("@@"));
489 }
490
491 #[test]
492 fn test_hunk_header_format_with_counts() {
493 let hunk = Hunk {
494 old_start: 10,
495 old_count: 5,
496 new_start: 12,
497 new_count: 7,
498 lines: vec![],
499 };
500
501 assert_eq!(hunk.header(), "@@ -10,5 +12,7 @@");
502 }
503
504 #[test]
505 fn test_hunk_header_omits_count_when_one() {
506 let hunk = Hunk {
508 old_start: 5,
509 old_count: 1,
510 new_start: 7,
511 new_count: 1,
512 lines: vec![],
513 };
514
515 assert_eq!(hunk.header(), "@@ -5 +7 @@");
516 }
517
518 #[test]
519 fn test_hunk_header_mixed_counts() {
520 let hunk = Hunk {
521 old_start: 10,
522 old_count: 1,
523 new_start: 12,
524 new_count: 3,
525 lines: vec![],
526 };
527
528 assert_eq!(hunk.header(), "@@ -10 +12,3 @@");
529 }
530
531 #[test]
532 fn test_hunk_header_zero_counts() {
533 let hunk = Hunk {
535 old_start: 0,
536 old_count: 0,
537 new_start: 1,
538 new_count: 5,
539 lines: vec![],
540 };
541
542 assert_eq!(hunk.header(), "@@ -0,0 +1,5 @@");
543 }
544
545 #[test]
546 fn test_context_limiting() {
547 let mut lines = Vec::new();
549 for i in 1..=20 {
550 lines.push(make_diff_line(
551 LineSource::Base,
552 &format!("line {}", i),
553 "test.txt",
554 ));
555 }
556 lines.insert(
558 10,
559 make_diff_line(LineSource::Committed, "new line", "test.txt"),
560 );
561
562 let patch = generate_patch(&lines);
563
564 let line_count = patch.lines().count();
566 assert!(line_count < 15, "Patch should be limited: {}", line_count);
568 }
569
570 #[test]
571 fn test_all_change_types_combined() {
572 let lines = vec![
573 make_diff_line(LineSource::Base, "context 1", "test.txt"),
574 make_diff_line(LineSource::DeletedBase, "deleted base", "test.txt"),
575 make_diff_line(LineSource::Committed, "committed add", "test.txt"),
576 make_diff_line(LineSource::Base, "context 2", "test.txt"),
577 make_diff_line(LineSource::DeletedCommitted, "deleted committed", "test.txt"),
578 make_diff_line(LineSource::Staged, "staged add", "test.txt"),
579 make_diff_line(LineSource::Base, "context 3", "test.txt"),
580 make_diff_line(LineSource::DeletedStaged, "deleted staged", "test.txt"),
581 make_diff_line(LineSource::Unstaged, "unstaged add", "test.txt"),
582 ];
583
584 let patch = generate_patch(&lines);
585
586 assert!(patch.contains("-deleted base"));
587 assert!(patch.contains("+committed add"));
588 assert!(patch.contains("-deleted committed"));
589 assert!(patch.contains("+staged add"));
590 assert!(patch.contains("-deleted staged"));
591 assert!(patch.contains("+unstaged add"));
592 }
593
594 #[test]
595 fn test_new_file_uses_dev_null() {
596 let lines = vec![
598 make_diff_line(LineSource::Committed, "line 1", "new_file.txt"),
599 make_diff_line(LineSource::Committed, "line 2", "new_file.txt"),
600 make_diff_line(LineSource::Committed, "line 3", "new_file.txt"),
601 ];
602
603 let patch = generate_patch(&lines);
604
605 assert!(patch.contains("new file mode 100644"));
606 assert!(patch.contains("--- /dev/null"));
607 assert!(patch.contains("+++ b/new_file.txt"));
608 assert!(patch.contains("@@ -0,0 +1,3 @@"));
609 }
610
611 #[test]
612 fn test_deleted_file_uses_dev_null() {
613 let lines = vec![
615 make_diff_line(LineSource::DeletedCommitted, "line 1", "deleted.txt"),
616 make_diff_line(LineSource::DeletedCommitted, "line 2", "deleted.txt"),
617 ];
618
619 let patch = generate_patch(&lines);
620
621 assert!(patch.contains("deleted file mode 100644"));
622 assert!(patch.contains("--- a/deleted.txt"));
623 assert!(patch.contains("+++ /dev/null"));
624 assert!(patch.contains("@@ -1,2 +0,0 @@"));
625 }
626
627 #[test]
628 fn test_line_numbers_are_correct() {
629 let lines = vec![
631 make_diff_line(LineSource::Base, "line 1", "test.txt"),
632 make_diff_line(LineSource::Base, "line 2", "test.txt"),
633 make_diff_line(LineSource::Base, "line 3", "test.txt"),
634 make_diff_line(LineSource::Committed, "inserted", "test.txt"),
635 make_diff_line(LineSource::Base, "line 4", "test.txt"),
636 ];
637
638 let patch = generate_patch(&lines);
639
640 assert!(
644 patch.contains("@@ -1,4 +1,5 @@"),
645 "Unexpected hunk header in:\n{}",
646 patch
647 );
648 }
649
650 #[test]
651 fn test_deletion_line_numbers() {
652 let lines = vec![
653 make_diff_line(LineSource::Base, "keep 1", "test.txt"),
654 make_diff_line(LineSource::Base, "keep 2", "test.txt"),
655 make_diff_line(LineSource::DeletedCommitted, "removed", "test.txt"),
656 make_diff_line(LineSource::Base, "keep 3", "test.txt"),
657 make_diff_line(LineSource::Base, "keep 4", "test.txt"),
658 ];
659
660 let patch = generate_patch(&lines);
661
662 assert!(
665 patch.contains("@@ -1,5 +1,4 @@"),
666 "Unexpected hunk header in:\n{}",
667 patch
668 );
669 }
670
671 #[test]
672 fn test_path_with_spaces() {
673 let lines = vec![
675 make_diff_line(LineSource::Base, "content", "path with spaces/file name.txt"),
676 make_diff_line(LineSource::Committed, "new", "path with spaces/file name.txt"),
677 ];
678
679 let patch = generate_patch(&lines);
680
681 assert!(patch.contains("diff --git a/path with spaces/file name.txt b/path with spaces/file name.txt"));
682 assert!(patch.contains("--- a/path with spaces/file name.txt"));
683 assert!(patch.contains("+++ b/path with spaces/file name.txt"));
684 }
685
686 #[test]
687 fn test_lines_without_file_path_are_skipped() {
688 fn make_line_no_path(source: LineSource, content: &str) -> DiffLine {
689 DiffLine {
690 source,
691 content: content.to_string(),
692 prefix: ' ',
693 line_number: None,
694 file_path: None,
695 inline_spans: Vec::new(),
696 old_content: None,
697 change_source: None,
698 in_current_bookmark: None,
699 }
700 }
701
702 let lines = vec![
703 make_line_no_path(LineSource::Base, "orphan line"),
704 make_diff_line(LineSource::Base, "context", "test.txt"),
705 make_diff_line(LineSource::Committed, "change", "test.txt"),
706 ];
707
708 let patch = generate_patch(&lines);
709
710 assert!(!patch.contains("orphan"));
712 assert!(patch.contains("+change"));
714 }
715
716 #[test]
717 fn test_lines_with_empty_file_path_are_skipped() {
718 fn make_line_empty_path(source: LineSource, content: &str) -> DiffLine {
719 DiffLine {
720 source,
721 content: content.to_string(),
722 prefix: ' ',
723 line_number: None,
724 file_path: Some(String::new()),
725 inline_spans: Vec::new(),
726 old_content: None,
727 change_source: None,
728 in_current_bookmark: None,
729 }
730 }
731
732 let lines = vec![
733 make_line_empty_path(LineSource::Committed, "orphan"),
734 make_diff_line(LineSource::Committed, "real change", "test.txt"),
735 ];
736
737 let patch = generate_patch(&lines);
738
739 assert!(!patch.contains("orphan"));
740 assert!(patch.contains("+real change"));
741 }
742
743 #[test]
744 fn test_format_range_helper() {
745 assert_eq!(format_range(1, 1), "1");
746 assert_eq!(format_range(5, 1), "5");
747 assert_eq!(format_range(1, 3), "1,3");
748 assert_eq!(format_range(10, 0), "10,0");
749 assert_eq!(format_range(0, 0), "0,0");
750 }
751}