1use core::ops::Range;
9use std::fmt::{self, Debug};
10
11trait ParsedRange {
13 fn range(&self) -> &Range<usize>;
15}
16
17impl ParsedRange for Range<usize> {
19 fn range(&self) -> &Range<usize> {
21 self
22 }
23}
24
25#[allow(dead_code)]
27#[derive(Debug, Clone)]
28pub struct Commit {
29 pub header: CommitHeader,
31 pub diff: Vec<FileDiff>,
33}
34
35#[allow(dead_code)]
37#[derive(Debug, Clone)]
38pub struct CommitHeader {
39 pub range: Range<usize>,
41 pub hash: Range<usize>,
43}
44
45#[allow(dead_code)]
47#[derive(Debug, Clone)]
48pub struct FileDiff {
49 pub range: Range<usize>,
51 pub header: DiffHeader,
53 pub hunks: Vec<Hunk>,
55}
56
57#[allow(dead_code)]
59#[derive(Debug, PartialEq, Eq, Clone, Copy)]
60pub enum Status {
61 Added,
63 Deleted,
65 Modified,
67 Renamed,
69 Copied,
71 Unmerged,
73}
74
75#[allow(dead_code)]
77#[derive(Debug, Clone)]
78pub struct DiffHeader {
79 pub range: Range<usize>,
81 pub old_file: Range<usize>,
83 pub new_file: Range<usize>,
85 pub status: Status,
87}
88
89#[allow(dead_code)]
91#[derive(Debug, Clone)]
92pub struct Hunk {
93 pub range: Range<usize>,
95 pub header: HunkHeader,
97 pub content: HunkContent,
99}
100
101#[allow(dead_code)]
103#[derive(Debug, Clone)]
104pub struct HunkContent {
105 pub range: Range<usize>,
107 pub changes: Vec<Change>,
109}
110
111#[allow(dead_code)]
113#[derive(Debug, Clone)]
114pub struct HunkHeader {
115 pub range: Range<usize>,
117 pub old_line_start: u32,
119 pub old_line_count: u32,
121 pub new_line_start: u32,
123 pub new_line_count: u32,
125 pub fn_ctx: Range<usize>,
127}
128
129#[allow(dead_code)]
131#[derive(Debug, Clone)]
132pub struct Change {
133 pub old: Range<usize>,
135 pub new: Range<usize>,
137}
138
139pub type Result<T> = std::result::Result<T, ParseError>;
141
142pub enum ParseError {
144 Expected {
146 cursor: usize,
148 expected: &'static str,
150 },
151 NotFound {
153 cursor: usize,
155 expected: &'static str,
157 },
158}
159
160impl fmt::Display for ParseError {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 match self {
165 ParseError::Expected { cursor, expected } => {
166 write!(f, "Expected {:?} at byte {:?}", expected, cursor)
167 }
168 ParseError::NotFound { cursor, expected } => {
169 write!(f, "Couldn't find {:?} from byte {:?}", expected, cursor)
170 }
171 }
172 }
173}
174
175impl fmt::Debug for ParseError {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179 f.write_fmt(format_args!("{self}"))
180 }
181}
182
183impl std::error::Error for ParseError {}
185
186#[derive(Clone, Debug)]
188pub struct Parser<'a> {
189 input: &'a str,
190 cursor: usize,
191}
192
193type ParseFn<'a, T> = fn(&mut Parser<'a>) -> Result<T>;
195
196impl<'a> Parser<'a> {
198 pub fn new(input: &'a str) -> Self {
200 Self { input, cursor: 0 }
201 }
202
203 pub fn parse_commit(&mut self) -> Result<Commit> {
205 let header = self.commit_header()?;
206 let diff = self.parse_diff()?;
207
208 Ok(Commit { header, diff })
209 }
210
211 pub fn parse_diff(&mut self) -> Result<Vec<FileDiff>> {
231 let mut diffs = vec![];
232
233 if self.input.is_empty() {
234 return Ok(vec![]);
235 }
236
237 if !diffs.is_empty() {
238 return Ok(diffs);
239 }
240
241 while self.is_at_diff_header() {
242 diffs.push(self.file_diff()?);
243 }
244
245 self.eof()?;
246
247 Ok(diffs)
248 }
249
250 fn commit_header(&mut self) -> Result<CommitHeader> {
252 let start = self.cursor;
253
254 self.consume("commit ")?;
255 let (hash, _) = self.consume_until(Self::newline)?;
256 self.skip_until_diff_header()?;
257
258 Ok(CommitHeader {
259 range: start..self.cursor,
260 hash,
261 })
262 }
263
264 fn skip_until_diff_header(&mut self) -> Result<()> {
266 while self.cursor < self.input.len() && !self.is_at_diff_header() {
267 self.consume_until(Self::newline_or_eof)?;
268 }
269
270 Ok(())
271 }
272
273 fn is_at_diff_header(&mut self) -> bool {
275 self.peek("diff") || self.peek("*")
276 }
277
278 fn file_diff(&mut self) -> Result<FileDiff> {
280 let diff_start = self.cursor;
281 let header = self.diff_header()?;
282
283 let mut hunks = vec![];
284
285 if header.status == Status::Unmerged {
286 self.skip_until_diff_header()?;
287 } else {
288 while self.peek("@@") {
289 hunks.push(self.hunk()?);
290 }
291 }
292
293 Ok(FileDiff {
294 range: diff_start..self.cursor,
295 header,
296 hunks,
297 })
298 }
299
300 fn diff_header(&mut self) -> Result<DiffHeader> {
302 let diff_header_start = self.cursor;
303 let mut diff_type = Status::Modified;
304
305 if let Ok(unmerged) = self.unmerged_file() {
306 return Ok(unmerged);
307 }
308
309 let (old_file, new_file, is_conflicted) = self
310 .conflicted_file()
311 .or_else(|_| self.old_new_file_header())?;
312
313 if is_conflicted {
314 diff_type = Status::Unmerged;
315 }
316
317 if self.peek("new file") {
318 diff_type = Status::Added;
319 self.consume_until(Self::newline)?;
320 } else if self.peek("deleted file") {
321 diff_type = Status::Deleted;
322 self.consume_until(Self::newline)?;
323 }
324
325 if self.consume("similarity index").is_ok() {
326 self.consume_until(Self::newline)?;
327 }
328
329 if self.consume("dissimilarity index").is_ok() {
330 self.consume_until(Self::newline)?;
331 }
332
333 if self.peek("index") {
334 } else if self.peek("old mode") || self.peek("new mode") {
335 self.consume_until(Self::newline)?;
336 } else if self.peek("deleted file mode") {
337 diff_type = Status::Deleted;
338 self.consume_until(Self::newline)?;
339 } else if self.peek("new file mode") {
340 diff_type = Status::Added;
341 self.consume_until(Self::newline)?;
342 } else if self.peek("copy from") {
343 diff_type = Status::Copied;
344 self.consume_until(Self::newline)?;
345 self.consume("copy to")?;
346 self.consume_until(Self::newline)?;
347 } else if self.peek("rename from") {
348 diff_type = Status::Renamed;
349 self.consume_until(Self::newline)?;
350 self.consume("rename to")?;
351 self.consume_until(Self::newline)?;
352 }
353
354 if self.peek("index") {
355 self.consume("index ")?;
356 self.consume_until(Self::newline)?;
357 }
358
359 if self.peek("Binary files ") {
360 self.consume_until(Self::newline)?;
361 }
362
363 if self.peek("---") {
364 self.consume_until(Self::newline)?;
365 self.consume("+++")?;
366 self.consume_until(Self::newline)?;
367 }
368
369 Ok(DiffHeader {
370 range: diff_header_start..self.cursor,
371 old_file,
372 new_file,
373 status: diff_type,
374 })
375 }
376
377 fn unmerged_file(&mut self) -> Result<DiffHeader> {
379 let unmerged_path_prefix = self.consume("* Unmerged path ")?;
380 let (file, _) = self.consume_until(Self::newline_or_eof)?;
381
382 Ok(DiffHeader {
383 range: unmerged_path_prefix.start..self.cursor,
384 old_file: file.clone(),
385 new_file: file,
386 status: Status::Unmerged,
387 })
388 }
389
390 fn old_new_file_header(&mut self) -> Result<(Range<usize>, Range<usize>, bool)> {
392 self.consume("diff --git")?;
393 self.diff_header_path_prefix()?;
394 let (old_path, _) = self.consume_until(Self::diff_header_path_prefix)?;
395 let (new_path, _) = self.consume_until(Self::newline)?;
396
397 Ok((old_path, new_path, false))
398 }
399
400 fn diff_header_path_prefix(&mut self) -> Result<Range<usize>> {
402 let start = self.cursor;
403 self.consume(" ")
404 .and_then(|_| self.ascii_lowercase())
405 .and_then(|_| self.consume("/"))
406 .map_err(|_| ParseError::Expected {
407 cursor: self.cursor,
408 expected: "<diff header path prefix (' a/...' or ' b/...')>",
409 })?;
410
411 Ok(start..self.cursor)
412 }
413
414 fn ascii_lowercase(&mut self) -> Result<Range<usize>> {
416 let start = self.cursor;
417 let is_ascii_lowercase = self
418 .input
419 .get(self.cursor..)
420 .and_then(|s| s.chars().next())
421 .is_some_and(|c| c.is_ascii_lowercase());
422
423 if is_ascii_lowercase {
424 self.cursor += 1;
425 Ok(start..self.cursor)
426 } else {
427 Err(ParseError::Expected {
428 cursor: self.cursor,
429 expected: "<ascii lowercase char>",
430 })
431 }
432 }
433
434 fn conflicted_file(&mut self) -> Result<(Range<usize>, Range<usize>, bool)> {
436 self.consume("diff --cc ")?;
437 let (file, _) = self.consume_until(Self::newline_or_eof)?;
438 Ok((file.clone(), file, true))
439 }
440
441 fn hunk(&mut self) -> Result<Hunk> {
443 let hunk_start = self.cursor;
444 let header = self.hunk_header()?;
445 let content = self.hunk_content()?;
446
447 Ok(Hunk {
448 range: hunk_start..self.cursor,
449 header,
450 content,
451 })
452 }
453
454 fn hunk_content(&mut self) -> Result<HunkContent> {
456 let hunk_content_start = self.cursor;
457 let mut changes = vec![];
458
459 while self.cursor < self.input.len()
460 && [" ", "-", "+", "\\"]
461 .into_iter()
462 .any(|prefix| self.peek(prefix))
463 {
464 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
465 changes.push(self.change()?);
466 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
467 }
468
469 Ok(HunkContent {
470 range: hunk_content_start..self.cursor,
471 changes,
472 })
473 }
474
475 fn hunk_header(&mut self) -> Result<HunkHeader> {
477 let hunk_header_start = self.cursor;
478
479 self.consume("@@ -")?;
480 let old_line_start = self.number()?;
481 let old_line_count = if self.consume(",").is_ok() {
482 self.number()?
483 } else {
484 1
485 };
486 self.consume(" +")?;
487 let new_line_start = self.number()?;
488 let new_line_count = if self.consume(",").is_ok() {
489 self.number()?
490 } else {
491 1
492 };
493 self.consume(" @@")?;
494 self.consume(" ").ok();
495
496 let (fn_ctx, newline) = self.consume_until(Self::newline)?;
497
498 Ok(HunkHeader {
499 range: hunk_header_start..self.cursor,
500 old_line_start,
501 old_line_count,
502 new_line_start,
503 new_line_count,
504 fn_ctx: fn_ctx.start..newline.end,
505 })
506 }
507
508 fn change(&mut self) -> Result<Change> {
510 let removed = self.consume_lines_while_prefixed(|parser| parser.peek("-"))?;
511 let removed_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
512 let added = self.consume_lines_while_prefixed(|parser| parser.peek("+"))?;
513 let added_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
514
515 Ok(Change {
516 old: removed.start..removed_meta.end,
517 new: added.start..added_meta.end,
518 })
519 }
520
521 fn consume_lines_while_prefixed(&mut self, pred: fn(&Parser) -> bool) -> Result<Range<usize>> {
523 let start = self.cursor;
524 while self.cursor < self.input.len() && pred(self) {
525 self.consume_until(Self::newline_or_eof)?;
526 }
527
528 Ok(start..self.cursor)
529 }
530
531 fn number(&mut self) -> Result<u32> {
533 let digit_count = &self
534 .input
535 .get(self.cursor..)
536 .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).count())
537 .unwrap_or(0);
538
539 if digit_count == &0 {
540 return Err(ParseError::Expected {
541 cursor: self.cursor,
542 expected: "<number>",
543 });
544 }
545
546 self.cursor += digit_count;
547 Ok(self
548 .input
549 .get(self.cursor - digit_count..self.cursor)
550 .ok_or(ParseError::Expected {
551 cursor: self.cursor,
552 expected: "<number>",
553 })?
554 .parse()
555 .unwrap())
556 }
557
558 fn newline_or_eof(&mut self) -> Result<Range<usize>> {
560 self.newline()
561 .or_else(|_| self.eof())
562 .map_err(|_| ParseError::Expected {
563 cursor: self.cursor,
564 expected: "<newline or eof>",
565 })
566 }
567
568 fn newline(&mut self) -> Result<Range<usize>> {
570 self.consume("\r\n")
571 .or_else(|_| self.consume("\n"))
572 .map_err(|_| ParseError::Expected {
573 cursor: self.cursor,
574 expected: "<newline>",
575 })
576 }
577
578 fn eof(&mut self) -> Result<Range<usize>> {
580 if self.cursor == self.input.len() {
581 Ok(self.cursor..self.cursor)
582 } else {
583 Err(ParseError::Expected {
584 cursor: self.cursor,
585 expected: "<eof>",
586 })
587 }
588 }
589
590 fn consume_until<T: ParsedRange>(
594 &mut self,
595 parse_fn: fn(&mut Parser<'a>) -> Result<T>,
596 ) -> Result<(Range<usize>, T)> {
597 let start = self.cursor;
598 let found = self.find(parse_fn)?;
599 self.cursor = found.range().end;
600 Ok((start..found.range().start, found))
601 }
602
603 fn find<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> Result<T> {
607 let mut sub_parser = self.clone();
608 let mut error = None;
609
610 for pos in self.cursor..=self.input.len() {
611 sub_parser.cursor = pos;
612 match parse_fn(&mut sub_parser) {
613 Ok(result) => return Ok(result),
614 Err(err) => {
615 if error.is_none() {
616 error = Some(err);
617 }
618 continue;
619 }
620 }
621 }
622
623 Err(ParseError::NotFound {
624 cursor: self.cursor,
625 expected: match error.unwrap() {
626 ParseError::Expected {
627 cursor: _,
628 expected,
629 } => expected,
630 ParseError::NotFound {
631 cursor: _,
632 expected,
633 } => expected,
634 },
635 })
636 }
637
638 fn consume(&mut self, expected: &'static str) -> Result<Range<usize>> {
641 let start = self.cursor;
642
643 if !self.peek(expected) {
644 return Err(ParseError::Expected {
645 cursor: self.cursor,
646 expected,
647 });
648 }
649
650 self.cursor += expected.len();
651 Ok(start..self.cursor)
652 }
653
654 fn peek(&self, pattern: &str) -> bool {
656 self.input
657 .get(self.cursor..)
658 .is_some_and(|s| s.starts_with(pattern))
659 }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665
666 #[test]
667 fn parse_empty_input() {
668 let mut parser = Parser::new("");
669 let diffs = parser.parse_diff().unwrap();
670 assert!(diffs.is_empty(), "Expected empty vector for empty input");
671 }
672
673 #[test]
674 fn parse_valid_diff() {
675 let input = "diff --git a/file1.txt b/file2.txt\n\
676 index 0000000..1111111 100644\n\
677 --- a/file1.txt\n\
678 +++ b/file2.txt\n\
679 @@ -1,2 +1,2 @@ fn main() {\n\
680 -foo\n\
681 +bar\n";
682 let mut parser = Parser::new(input);
683 let diffs = parser.parse_diff().unwrap();
684 assert_eq!(diffs.len(), 1, "Expected one diff block");
685
686 let diff = &diffs[0];
687 let old_file_str = &input[diff.header.old_file.clone()];
688 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
689
690 let new_file_str = &input[diff.header.new_file.clone()];
691 assert_eq!(new_file_str, "file2.txt", "New file does not match");
692
693 assert_eq!(diff.hunks.len(), 1, "Expected one hunk");
694 let hunk = &diff.hunks[0];
695
696 assert_eq!(hunk.header.old_line_start, 1, "Old line start should be 1");
697 assert_eq!(hunk.header.old_line_count, 2, "Old line count should be 2");
698 assert_eq!(hunk.header.new_line_start, 1, "New line start should be 1");
699 assert_eq!(hunk.header.new_line_count, 2, "New line count should be 2");
700
701 let func_ctx = &input[hunk.header.fn_ctx.clone()];
702 assert_eq!(func_ctx, "fn main() {\n", "Expected function context");
703
704 assert_eq!(
705 hunk.content.changes.len(),
706 1,
707 "Expected one change in the hunk"
708 );
709 let change = &hunk.content.changes[0];
710 let removed_str = &input[change.old.clone()];
711 assert_eq!(removed_str, "-foo\n", "Removed line does not match");
712 let added_str = &input[change.new.clone()];
713 assert_eq!(added_str, "+bar\n", "Added line does not match");
714 }
715
716 #[test]
717 fn parse_multiple_diffs() {
718 let input = "diff --git a/file1.txt b/file1.txt\n\
719 index 0000000..1111111 100644\n\
720 --- a/file1.txt\n\
721 +++ b/file1.txt\n\
722 @@ -1,1 +1,1 @@\n\
723 -foo\n\
724 +bar\n\
725 diff --git a/file2.txt b/file2.txt\n\
726 index 2222222..3333333 100644\n\
727 --- a/file2.txt\n\
728 +++ b/file2.txt\n\
729 @@ -2,2 +2,2 @@\n\
730 -baz\n\
731 +qux\n";
732 let mut parser = Parser::new(input);
733 let diffs = parser.parse_diff().unwrap();
734 assert_eq!(diffs.len(), 2, "Expected two diff blocks");
735
736 let diff1 = &diffs[0];
737 let old_file1 = &input[diff1.header.old_file.clone()];
738 assert_eq!(old_file1, "file1.txt", "First diff old file mismatch");
739
740 let diff2 = &diffs[1];
741 let old_file2 = &input[diff2.header.old_file.clone()];
742 assert_eq!(old_file2, "file2.txt", "Second diff old file mismatch");
743 }
744
745 #[test]
746 fn parse_crlf_input() {
747 let input = "diff --git a/file.txt b/file.txt\r\n\
748 index 0000000..1111111 100644\r\n\
749 --- a/file.txt\r\n\
750 +++ b/file.txt\r\n\
751 @@ -1,1 +1,1 @@\r\n\
752 -foo\r\n\
753 +bar\r\n";
754 let mut parser = Parser::new(input);
755 let diffs = parser.parse_diff().unwrap();
756 assert_eq!(diffs.len(), 1, "Expected one diff block for CRLF input");
757 let diff = &diffs[0];
758 let old_file = &input[diff.header.old_file.clone()];
759 assert_eq!(
760 old_file, "file.txt",
761 "Old file does not match in CRLF input"
762 );
763 }
764
765 #[test]
766 fn parse_malformed_input_missing_diff_header() {
767 let input = "--- a/file.txt\n+++ b/file.txt\n";
768 let mut parser = Parser::new(input);
769 assert!(parser.parse_diff().is_err());
770 }
771
772 #[test]
773 fn parse_malformed_input_missing_hunk_header() {
774 let input = "diff --git a/file.txt b/file.txt\n\
775 index 0000000..1111111 100644\n\
776 --- a/file.txt\n\
777 +++ b/file.txt\n\
778 foo\n";
779 let mut parser = Parser::new(input);
780 assert!(parser.parse_diff().is_err());
781 }
782
783 #[test]
784 fn parse_malformed_input_invalid_number() {
785 let input = "diff --git a/file.txt b/file.txt\n\
786 index 0000000..1111111 100644\n\
787 --- a/file.txt\n\
788 +++ b/file.txt\n\
789 @@ -a,1 +1,1 @@\n\
790 -foo\n\
791 +bar\n";
792 let mut parser = Parser::new(input);
793 assert!(parser.parse_diff().is_err());
794 }
795
796 #[test]
797 fn parse_malformed_input_extra_characters() {
798 let input = "diff --git a/file.txt b/file.txt\n\
799 index 0000000..1111111 100644\n\
800 --- a/file.txt\n\
801 +++ b/file.txt\n\
802 @@ -1,1 +1,1 @@\n\
803 -foo\n\
804 +bar\n\
805 unexpected\n";
806 let mut parser = Parser::new(input);
807 assert!(parser.parse_diff().is_err());
808 }
809
810 #[test]
811 fn unified_diff_break() {
812 let input = "diff --git a/file.txt b/file.txt\r\n\
813 index 0000000..1111111 100644\r\n\
814 --- a/file.txt\r\n\
815 +++ b/file.txt\r\n\
816 @@ -1,1 +1,1 @@\r\n\
817 -foo\r\n\
818 +bar\r\n";
819 let mut parser = Parser::new(input);
820 let _ = parser.parse_diff();
821 }
822
823 #[test]
824 fn new_file() {
825 let input = "diff --git a/file.txt b/file.txt\r\n\
826 new file mode 100644\r\n\
827 index 0000000..1111111\r\n\
828 --- /dev/null\r\n\
829 +++ b/file.txt\r\n\
830 @@ -0,0 +1,1 @@\r\n\
831 +bar\r\n";
832 let mut parser = Parser::new(input);
833 let diffs = parser.parse_diff().unwrap();
834 assert_eq!(diffs.len(), 1, "Expected one diff block for new file test");
835 let diff = &diffs[0];
836 let old_file_str = &input[diff.header.old_file.clone()];
837 assert_eq!(old_file_str, "file.txt",);
838 let new_file_str = &input[diff.header.new_file.clone()];
839 assert_eq!(new_file_str, "file.txt",);
840 }
841
842 #[test]
843 fn omitted_line_count() {
844 let input = "diff --git a/file.txt b/file.txt\r\n\
845 index 0000000..1111111 100644\r\n\
846 --- a/file.txt\r\n\
847 +++ b/file.txt\r\n\
848 @@ -1 +1 @@\r\n\
849 -foo\r\n\
850 +bar\r\n";
851 let mut parser = Parser::new(input);
852 let diffs = parser.parse_diff().unwrap();
853 assert_eq!(
854 diffs.len(),
855 1,
856 "Expected one diff block for omitted line count test"
857 );
858 let diff = &diffs[0];
859 let hunk = &diff.hunks[0];
860 assert_eq!(hunk.header.old_line_count, 1, "Old line count should be 1");
861 assert_eq!(hunk.header.new_line_count, 1, "New line count should be 1");
862 }
863
864 #[test]
865 fn new_empty_files() {
866 let input = "diff --git a/file-a b/file-a\n\
867 new file mode 100644\n\
868 index 0000000..e69de29\n\
869 diff --git a/file-b b/file-b\n\
870 new file mode 100644\n\
871 index 0000000..e69de29\n";
872 let mut parser = Parser::new(input);
873 let diffs = parser.parse_diff().unwrap();
874 assert_eq!(diffs.len(), 2, "Expected two diff blocks for new files");
875 assert_eq!(diffs[0].header.status, Status::Added);
876 assert_eq!(diffs[0].hunks.len(), 0, "Expected no hunks in first diff");
877 assert_eq!(diffs[1].header.status, Status::Added);
878 assert_eq!(diffs[1].hunks.len(), 0, "Expected no hunks in second diff");
879 }
880
881 #[test]
882 fn deleted_file() {
883 let input = "diff --git a/Cargo.lock b/Cargo.lock\n\
884 deleted file mode 100644\n\
885 index 6ae58a0..0000000\n\
886 --- a/Cargo.lock\n\
887 +++ /dev/null\n";
888 let mut parser = Parser::new(input);
889 let diffs = parser.parse_diff().unwrap();
890 assert_eq!(diffs.len(), 1, "Expected two diff blocks for new files");
891 assert_eq!(diffs[0].header.status, Status::Deleted);
892 }
893
894 #[test]
895 fn commit() {
896 let input = "commit 9318f4040de9e6cf60033f21f6ae91a0f2239d38\n\
897 Author: altsem <alltidsemester@pm.me>\n\
898 Date: Wed Feb 19 19:25:37 2025 +0100\n\
899 \n\
900 chore(release): prepare for v0.28.2\n\
901 \n\
902 diff --git a/.recent-changelog-entry b/.recent-changelog-entry\n\
903 index 7c59f63..b3d843c 100644\n\
904 --- a/.recent-changelog-entry\n\
905 +++ b/.recent-changelog-entry\n\
906 @@ -1,7 +1,6 @@\n\
907 -## [0.28.1] - 2025-02-13\n\
908 +## [0.28.2] - 2025-02-19\n ### 🐛 Bug Fixes\n \n\
909 -- Change logging level to reduce inotify spam\n\
910 -- Don't refresh on `gitu.log` writes (gitu --log)\n\
911 +- Rebase menu opening after closing Neovim\n";
912 let mut parser = Parser::new(input);
913 let commit = parser.parse_commit().unwrap();
914 assert!(input[commit.header.range.clone()].starts_with("commit 931"));
915 assert!(input[commit.header.range.clone()].ends_with("28.2\n\n"));
916 assert_eq!(
917 &input[commit.header.hash.clone()],
918 "9318f4040de9e6cf60033f21f6ae91a0f2239d38"
919 );
920 }
921
922 #[test]
923 fn empty_commit() {
924 let input = "commit 6c9991b0006b38b439605eb68baff05f0c0ebf95\nAuthor: altsem <alltidsemester@pm.me>\nDate: Sun Jun 16 19:01:00 2024 +0200\n\n feat: -n argument to limit log\n \n ";
925
926 let mut parser = Parser::new(input);
927 let commit = parser.parse_commit().unwrap();
928 assert_eq!(commit.diff.len(), 0);
929 }
930
931 #[test]
932 fn binary_file() {
933 let input = "commit 664b2f5a3223f48d3cf38c7b517014ea98b9cb55\nAuthor: altsem <alltidsemester@pm.me>\nDate: Sat Apr 20 13:43:23 2024 +0200\n\n update vhs/rec\n\ndiff --git a/vhs/help.png b/vhs/help.png\nindex 876e6a1..8c46810 100644\nBinary files a/vhs/help.png and b/vhs/help.png differ\ndiff --git a/vhs/rec.gif b/vhs/rec.gif\nindex 746d957..333bc94 100644\nBinary files a/vhs/rec.gif and b/vhs/rec.gif differ\ndiff --git a/vhs/rec.tape b/vhs/rec.tape\nindex bd36591..fd56c37 100644\n--- a/vhs/rec.tape\n+++ b/vhs/rec.tape\n@@ -4,7 +4,7 @@ Set Height 800\n Set Padding 5\n \n Hide\n-Type \"git checkout 3259529\"\n+Type \"git checkout f613098b14ed99fab61bd0b78a4a41e192d90ea2\"\n Enter\n Type \"git checkout -b demo-branch\"\n Enter\n";
934
935 let mut parser = Parser::new(input);
936 let commit = parser.parse_commit().unwrap();
937 assert_eq!(commit.diff.len(), 3);
938 }
939
940 #[test]
941 fn conflicted_file() {
942 let input = "diff --cc new-file\nindex 32f95c0,2b31011..0000000\n--- a/new-file\n+++ b/new-file\n@@@ -1,1 -1,1 +1,5 @@@\n- hi\n -hey\n++<<<<<<< HEAD\n++hi\n++=======\n++hey\n++>>>>>>> other-branch\n";
943
944 let mut parser = Parser::new(input);
945 let diffs = parser.parse_diff().unwrap();
946 assert_eq!(diffs.len(), 1);
947 assert_eq!(diffs[0].header.status, Status::Unmerged);
948 assert_eq!(&input[diffs[0].header.old_file.clone()], "new-file");
949 assert_eq!(&input[diffs[0].header.new_file.clone()], "new-file");
950 }
951
952 #[test]
953 fn unmerged_path() {
954 let input = "* Unmerged path new-file\n* Unmerged path new-file-2\n";
955 let mut parser = Parser::new(input);
956 let diff = parser.parse_diff().unwrap();
957
958 assert_eq!(diff.len(), 2);
959 assert_eq!(diff[0].header.status, Status::Unmerged);
960 assert_eq!(&input[diff[0].header.old_file.clone()], "new-file");
961 assert_eq!(&input[diff[0].header.new_file.clone()], "new-file");
962 assert!(diff[0].hunks.is_empty());
963 assert_eq!(diff[1].header.status, Status::Unmerged);
964 assert_eq!(&input[diff[1].header.old_file.clone()], "new-file-2");
965 assert_eq!(&input[diff[1].header.new_file.clone()], "new-file-2");
966 assert!(diff[1].hunks.is_empty());
967 }
968
969 #[test]
970 fn missing_newline_before_final() {
971 let input = "diff --git a/vitest.config.ts b/vitest.config.ts\nindex 97b017f..bcd28a0 100644\n--- a/vitest.config.ts\n+++ b/vitest.config.ts\n@@ -14,4 +14,4 @@ export default defineConfig({\n globals: true,\n setupFiles: ['./src/test/setup.ts'],\n },\n-})\n\\ No newline at end of file\n+});";
972
973 let mut parser = Parser::new(input);
974 let diffs = parser.parse_diff().unwrap();
975 assert_eq!(diffs.len(), 1);
976 assert_eq!(diffs[0].header.status, Status::Modified);
977 assert_eq!(diffs[0].hunks.len(), 1);
978 let changes = &diffs[0].hunks[0].content.changes;
979 assert_eq!(changes.len(), 1);
980 assert_eq!(
981 &input[changes[0].old.clone()],
982 "-})\n\\ No newline at end of file\n"
983 );
984 assert_eq!(&input[changes[0].new.clone()], "+});");
985 }
986
987 #[test]
988 fn filenames_with_spaces() {
989 let input = "diff --git a/file one.txt b/file two.txt\nindex 5626abf..f719efd 100644\n--- a/file one.txt \n+++ b/file two.txt \n@@ -1 +1 @@\n-one\n+two\n";
990 let mut parser = Parser::new(input);
991 let diffs = parser.parse_diff().unwrap();
992 assert_eq!(&input[diffs[0].header.old_file.clone()], "file one.txt");
993 assert_eq!(&input[diffs[0].header.new_file.clone()], "file two.txt");
994 }
995
996 #[test]
997 fn partially_unmerged() {
998 let input = "diff --git a/src/config.rs b/src/config.rs\nindex a22a438..095d9c7 100644\n--- a/src/config.rs\n+++ b/src/config.rs\n@@ -15,6 +15,7 @@ const DEFAULT_CONFIG: &str = include_str!(\"default_config.toml\");\n pub(crate) struct Config {\n pub general: GeneralConfig,\n pub style: StyleConfig,\n+ pub editor: EditorConfig,\n pub bindings: BTreeMap<Menu, BTreeMap<Op, Vec<String>>>,\n }\n \n@@ -148,6 +149,13 @@ pub struct SymbolStyleConfigEntry {\n mods: Option<Modifier>,\n }\n \n+#[derive(Default, Debug, Deserialize)]\n+pub struct EditorConfig {\n+ pub default: Option<String>,\n+ pub show: Option<String>,\n+ pub commit: Option<String>,\n+}\n+\n impl From<&StyleConfigEntry> for Style {\n fn from(val: &StyleConfigEntry) -> Self {\n Style {\ndiff --git a/src/default_config.toml b/src/default_config.toml\nindex eaf97e7..b5a29fa 100644\n--- a/src/default_config.toml\n+++ b/src/default_config.toml\n@@ -10,6 +10,10 @@ confirm_quit.enabled = false\n collapsed_sections = []\n refresh_on_file_change.enabled = true\n \n+[editor]\n+# show = \"zed -a\"\n+# commit = \"zile\"\n+\n [style]\n # fg / bg can be either of:\n # - a hex value: \"#707070\"\n* Unmerged path src/ops/show.rs";
999
1000 let mut parser = Parser::new(input);
1001 let diffs = parser.parse_diff().unwrap();
1002 assert_eq!(diffs.len(), 3);
1003 assert_eq!(diffs[2].header.status, Status::Unmerged);
1004 assert_eq!(&input[diffs[2].header.old_file.clone()], "src/ops/show.rs");
1005 assert_eq!(&input[diffs[2].header.new_file.clone()], "src/ops/show.rs");
1006 }
1007
1008 #[test]
1009 fn parse_custom_prefixes() {
1010 let input = "diff --git i/file1.txt w/file2.txt\n\
1011 index 0000000..1111111 100644\n\
1012 --- i/file1.txt\n\
1013 +++ w/file2.txt\n\
1014 @@ -1,2 +1,2 @@ fn main() {\n\
1015 -foo\n\
1016 +bar\n";
1017 let mut parser = Parser::new(input);
1018 let diffs = parser.parse_diff().unwrap();
1019 assert_eq!(diffs.len(), 1, "Expected one diff block");
1020
1021 let diff = &diffs[0];
1022 let old_file_str = &input[diff.header.old_file.clone()];
1023 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
1024
1025 let new_file_str = &input[diff.header.new_file.clone()];
1026 assert_eq!(new_file_str, "file2.txt", "New file does not match");
1027 }
1028}