1use core::ops::Range;
9use std::fmt::{self, Debug};
10
11trait ParsedRange {
12 fn range(&self) -> &Range<usize>;
13}
14
15impl ParsedRange for Range<usize> {
16 fn range(&self) -> &Range<usize> {
17 self
18 }
19}
20
21#[allow(dead_code)]
22#[derive(Debug, Clone)]
23pub struct Commit {
24 pub header: CommitHeader,
25 pub diff: Vec<FileDiff>,
26}
27
28#[allow(dead_code)]
29#[derive(Debug, Clone)]
30pub struct CommitHeader {
31 pub range: Range<usize>,
32 pub hash: Range<usize>,
33}
34
35#[allow(dead_code)]
36#[derive(Debug, Clone)]
37pub struct FileDiff {
38 pub range: Range<usize>,
39 pub header: DiffHeader,
40 pub hunks: Vec<Hunk>,
41}
42
43#[allow(dead_code)]
44#[derive(Debug, PartialEq, Eq, Clone, Copy)]
45pub enum Status {
46 Added,
47 Deleted,
48 Modified,
49 Renamed,
50 Copied,
51 Unmerged,
52}
53
54#[allow(dead_code)]
55#[derive(Debug, Clone)]
56pub struct DiffHeader {
57 pub range: Range<usize>,
58 pub old_file: Range<usize>,
59 pub new_file: Range<usize>,
60 pub status: Status,
61}
62
63#[allow(dead_code)]
64#[derive(Debug, Clone)]
65pub struct Hunk {
66 pub range: Range<usize>,
67 pub header: HunkHeader,
68 pub content: HunkContent,
69}
70
71#[allow(dead_code)]
72#[derive(Debug, Clone)]
73pub struct HunkContent {
74 pub range: Range<usize>,
75 pub changes: Vec<Change>,
76}
77
78#[allow(dead_code)]
79#[derive(Debug, Clone)]
80pub struct HunkHeader {
81 pub range: Range<usize>,
82 pub old_line_start: u32,
83 pub old_line_count: u32,
84 pub new_line_start: u32,
85 pub new_line_count: u32,
86 pub fn_ctx: Range<usize>,
87}
88
89#[allow(dead_code)]
90#[derive(Debug, Clone)]
91pub struct Change {
92 pub old: Range<usize>,
93 pub new: Range<usize>,
94}
95
96pub type Result<T> = std::result::Result<T, ParseError>;
97
98pub enum ParseError {
99 Expected {
100 cursor: usize,
101 expected: &'static str,
102 },
103 NotFound {
104 cursor: usize,
105 expected: &'static str,
106 },
107}
108
109impl fmt::Display for ParseError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 ParseError::Expected { cursor, expected } => {
113 write!(f, "Expected {:?} at byte {:?}", expected, cursor)
114 }
115 ParseError::NotFound { cursor, expected } => {
116 write!(f, "Couldn't find {:?} from byte {:?}", expected, cursor)
117 }
118 }
119 }
120}
121
122impl fmt::Debug for ParseError {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.write_fmt(format_args!("{self}"))
125 }
126}
127
128impl std::error::Error for ParseError {}
129
130#[derive(Clone, Debug)]
131pub struct Parser<'a> {
132 input: &'a str,
133 cursor: usize,
134}
135
136type ParseFn<'a, T> = fn(&mut Parser<'a>) -> Result<T>;
137
138impl<'a> Parser<'a> {
139 pub fn new(input: &'a str) -> Self {
140 Self { input, cursor: 0 }
141 }
142
143 pub fn parse_commit(&mut self) -> Result<Commit> {
144 let header = self.commit_header()?;
145 let diff = self.parse_diff()?;
146
147 Ok(Commit { header, diff })
148 }
149
150 pub fn parse_diff(&mut self) -> Result<Vec<FileDiff>> {
169 let mut diffs = vec![];
170
171 if self.input.is_empty() {
172 return Ok(vec![]);
173 }
174
175 if !diffs.is_empty() {
176 return Ok(diffs);
177 }
178
179 while self.is_at_diff_header() {
180 diffs.push(self.file_diff()?);
181 }
182
183 self.eof()?;
184
185 Ok(diffs)
186 }
187
188 fn commit_header(&mut self) -> Result<CommitHeader> {
189 let start = self.cursor;
190
191 self.consume("commit ")?;
192 let (hash, _) = self.consume_until(Self::newline)?;
193 self.skip_until_diff_header()?;
194
195 Ok(CommitHeader {
196 range: start..self.cursor,
197 hash,
198 })
199 }
200
201 fn skip_until_diff_header(&mut self) -> Result<()> {
202 while self.cursor < self.input.len() && !self.is_at_diff_header() {
203 self.consume_until(Self::newline_or_eof)?;
204 }
205
206 Ok(())
207 }
208
209 fn is_at_diff_header(&mut self) -> bool {
210 self.peek("diff") || self.peek("*")
211 }
212
213 fn file_diff(&mut self) -> Result<FileDiff> {
214 let diff_start = self.cursor;
215 let header = self.diff_header()?;
216
217 let mut hunks = vec![];
218
219 if header.status == Status::Unmerged {
220 self.skip_until_diff_header()?;
221 } else {
222 while self.peek("@@") {
223 hunks.push(self.hunk()?);
224 }
225 }
226
227 Ok(FileDiff {
228 range: diff_start..self.cursor,
229 header,
230 hunks,
231 })
232 }
233
234 fn diff_header(&mut self) -> Result<DiffHeader> {
235 let diff_header_start = self.cursor;
236 let mut diff_type = Status::Modified;
237
238 if let Ok(unmerged) = self.unmerged_file() {
239 return Ok(unmerged);
240 }
241
242 let (old_file, new_file, is_conflicted) = self
243 .conflicted_file()
244 .or_else(|_| self.old_new_file_header())?;
245
246 if is_conflicted {
247 diff_type = Status::Unmerged;
248 }
249
250 if self.peek("new file") {
251 diff_type = Status::Added;
252 self.consume_until(Self::newline)?;
253 } else if self.peek("deleted file") {
254 diff_type = Status::Deleted;
255 self.consume_until(Self::newline)?;
256 }
257
258 if self.consume("similarity index").is_ok() {
259 self.consume_until(Self::newline)?;
260 }
261
262 if self.consume("dissimilarity index").is_ok() {
263 self.consume_until(Self::newline)?;
264 }
265
266 if self.peek("index") {
267 } else if self.peek("old mode") || self.peek("new mode") {
268 self.consume_until(Self::newline)?;
269 } else if self.peek("deleted file mode") {
270 diff_type = Status::Deleted;
271 self.consume_until(Self::newline)?;
272 } else if self.peek("new file mode") {
273 diff_type = Status::Added;
274 self.consume_until(Self::newline)?;
275 } else if self.peek("copy from") {
276 diff_type = Status::Copied;
277 self.consume_until(Self::newline)?;
278 self.consume("copy to")?;
279 self.consume_until(Self::newline)?;
280 } else if self.peek("rename from") {
281 diff_type = Status::Renamed;
282 self.consume_until(Self::newline)?;
283 self.consume("rename to")?;
284 self.consume_until(Self::newline)?;
285 }
286
287 if self.peek("index") {
288 self.consume("index ")?;
289 self.consume_until(Self::newline)?;
290 }
291
292 if self.peek("Binary files ") {
293 self.consume_until(Self::newline)?;
294 }
295
296 if self.peek("---") {
297 self.consume_until(Self::newline)?;
298 self.consume("+++")?;
299 self.consume_until(Self::newline)?;
300 }
301
302 Ok(DiffHeader {
303 range: diff_header_start..self.cursor,
304 old_file,
305 new_file,
306 status: diff_type,
307 })
308 }
309
310 fn unmerged_file(&mut self) -> Result<DiffHeader> {
311 let unmerged_path_prefix = self.consume("* Unmerged path ")?;
312 let (file, _) = self.consume_until(Self::newline_or_eof)?;
313
314 Ok(DiffHeader {
315 range: unmerged_path_prefix.start..self.cursor,
316 old_file: file.clone(),
317 new_file: file,
318 status: Status::Unmerged,
319 })
320 }
321
322 fn old_new_file_header(&mut self) -> Result<(Range<usize>, Range<usize>, bool)> {
323 self.consume("diff --git")?;
324 self.diff_header_path_prefix()?;
325 let (old_path, _) = self.consume_until(Self::diff_header_path_prefix)?;
326 let (new_path, _) = self.consume_until(Self::newline)?;
327
328 Ok((old_path, new_path, false))
329 }
330
331 fn diff_header_path_prefix(&mut self) -> Result<Range<usize>> {
332 let start = self.cursor;
333 self.consume(" ")
334 .and_then(|_| self.ascii_lowercase())
335 .and_then(|_| self.consume("/"))
336 .map_err(|_| ParseError::Expected {
337 cursor: self.cursor,
338 expected: "<diff header path prefix (' a/...' or ' b/...')>",
339 })?;
340
341 Ok(start..self.cursor)
342 }
343
344 fn ascii_lowercase(&mut self) -> Result<Range<usize>> {
345 let start = self.cursor;
346 let is_ascii_lowercase = self
347 .input
348 .get(self.cursor..)
349 .and_then(|s| s.chars().next())
350 .is_some_and(|c| c.is_ascii_lowercase());
351
352 if is_ascii_lowercase {
353 self.cursor += 1;
354 Ok(start..self.cursor)
355 } else {
356 Err(ParseError::Expected {
357 cursor: self.cursor,
358 expected: "<ascii lowercase char>",
359 })
360 }
361 }
362
363 fn conflicted_file(&mut self) -> Result<(Range<usize>, Range<usize>, bool)> {
364 self.consume("diff --cc ")?;
365 let (file, _) = self.consume_until(Self::newline_or_eof)?;
366 Ok((file.clone(), file, true))
367 }
368
369 fn hunk(&mut self) -> Result<Hunk> {
370 let hunk_start = self.cursor;
371 let header = self.hunk_header()?;
372 let content = self.hunk_content()?;
373
374 Ok(Hunk {
375 range: hunk_start..self.cursor,
376 header,
377 content,
378 })
379 }
380
381 fn hunk_content(&mut self) -> Result<HunkContent> {
382 let hunk_content_start = self.cursor;
383 let mut changes = vec![];
384
385 while self.cursor < self.input.len()
386 && [" ", "-", "+", "\\"]
387 .into_iter()
388 .any(|prefix| self.peek(prefix))
389 {
390 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
391 changes.push(self.change()?);
392 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
393 }
394
395 Ok(HunkContent {
396 range: hunk_content_start..self.cursor,
397 changes,
398 })
399 }
400
401 fn hunk_header(&mut self) -> Result<HunkHeader> {
402 let hunk_header_start = self.cursor;
403
404 self.consume("@@ -")?;
405 let old_line_start = self.number()?;
406 let old_line_count = if self.consume(",").is_ok() {
407 self.number()?
408 } else {
409 1
410 };
411 self.consume(" +")?;
412 let new_line_start = self.number()?;
413 let new_line_count = if self.consume(",").is_ok() {
414 self.number()?
415 } else {
416 1
417 };
418 self.consume(" @@")?;
419 self.consume(" ").ok();
420
421 let (fn_ctx, newline) = self.consume_until(Self::newline)?;
422
423 Ok(HunkHeader {
424 range: hunk_header_start..self.cursor,
425 old_line_start,
426 old_line_count,
427 new_line_start,
428 new_line_count,
429 fn_ctx: fn_ctx.start..newline.end,
430 })
431 }
432
433 fn change(&mut self) -> Result<Change> {
434 let removed = self.consume_lines_while_prefixed(|parser| parser.peek("-"))?;
435 let removed_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
436 let added = self.consume_lines_while_prefixed(|parser| parser.peek("+"))?;
437 let added_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
438
439 Ok(Change {
440 old: removed.start..removed_meta.end,
441 new: added.start..added_meta.end,
442 })
443 }
444
445 fn consume_lines_while_prefixed(&mut self, pred: fn(&Parser) -> bool) -> Result<Range<usize>> {
446 let start = self.cursor;
447 while self.cursor < self.input.len() && pred(self) {
448 self.consume_until(Self::newline_or_eof)?;
449 }
450
451 Ok(start..self.cursor)
452 }
453
454 fn number(&mut self) -> Result<u32> {
455 let digit_count = &self
456 .input
457 .get(self.cursor..)
458 .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).count())
459 .unwrap_or(0);
460
461 if digit_count == &0 {
462 return Err(ParseError::Expected {
463 cursor: self.cursor,
464 expected: "<number>",
465 });
466 }
467
468 self.cursor += digit_count;
469 Ok(self
470 .input
471 .get(self.cursor - digit_count..self.cursor)
472 .ok_or(ParseError::Expected {
473 cursor: self.cursor,
474 expected: "<number>",
475 })?
476 .parse()
477 .unwrap())
478 }
479
480 fn newline_or_eof(&mut self) -> Result<Range<usize>> {
481 self.newline()
482 .or_else(|_| self.eof())
483 .map_err(|_| ParseError::Expected {
484 cursor: self.cursor,
485 expected: "<newline or eof>",
486 })
487 }
488
489 fn newline(&mut self) -> Result<Range<usize>> {
490 self.consume("\r\n")
491 .or_else(|_| self.consume("\n"))
492 .map_err(|_| ParseError::Expected {
493 cursor: self.cursor,
494 expected: "<newline>",
495 })
496 }
497
498 fn eof(&mut self) -> Result<Range<usize>> {
499 if self.cursor == self.input.len() {
500 Ok(self.cursor..self.cursor)
501 } else {
502 Err(ParseError::Expected {
503 cursor: self.cursor,
504 expected: "<eof>",
505 })
506 }
507 }
508
509 fn consume_until<T: ParsedRange>(
513 &mut self,
514 parse_fn: fn(&mut Parser<'a>) -> Result<T>,
515 ) -> Result<(Range<usize>, T)> {
516 let start = self.cursor;
517 let found = self.find(parse_fn)?;
518 self.cursor = found.range().end;
519 Ok((start..found.range().start, found))
520 }
521
522 fn find<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> Result<T> {
526 let mut sub_parser = self.clone();
527 let mut error = None;
528
529 for pos in self.cursor..=self.input.len() {
530 sub_parser.cursor = pos;
531 match parse_fn(&mut sub_parser) {
532 Ok(result) => return Ok(result),
533 Err(err) => {
534 if error.is_none() {
535 error = Some(err);
536 }
537 continue;
538 }
539 }
540 }
541
542 Err(ParseError::NotFound {
543 cursor: self.cursor,
544 expected: match error.unwrap() {
545 ParseError::Expected {
546 cursor: _,
547 expected,
548 } => expected,
549 ParseError::NotFound {
550 cursor: _,
551 expected,
552 } => expected,
553 },
554 })
555 }
556
557 fn consume(&mut self, expected: &'static str) -> Result<Range<usize>> {
560 let start = self.cursor;
561
562 if !self.peek(expected) {
563 return Err(ParseError::Expected {
564 cursor: self.cursor,
565 expected,
566 });
567 }
568
569 self.cursor += expected.len();
570 Ok(start..self.cursor)
571 }
572
573 fn peek(&self, pattern: &str) -> bool {
575 self.input
576 .get(self.cursor..)
577 .is_some_and(|s| s.starts_with(pattern))
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn parse_empty_input() {
587 let mut parser = Parser::new("");
588 let diffs = parser.parse_diff().unwrap();
589 assert!(diffs.is_empty(), "Expected empty vector for empty input");
590 }
591
592 #[test]
593 fn parse_valid_diff() {
594 let input = "diff --git a/file1.txt b/file2.txt\n\
595 index 0000000..1111111 100644\n\
596 --- a/file1.txt\n\
597 +++ b/file2.txt\n\
598 @@ -1,2 +1,2 @@ fn main() {\n\
599 -foo\n\
600 +bar\n";
601 let mut parser = Parser::new(input);
602 let diffs = parser.parse_diff().unwrap();
603 assert_eq!(diffs.len(), 1, "Expected one diff block");
604
605 let diff = &diffs[0];
606 let old_file_str = &input[diff.header.old_file.clone()];
607 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
608
609 let new_file_str = &input[diff.header.new_file.clone()];
610 assert_eq!(new_file_str, "file2.txt", "New file does not match");
611
612 assert_eq!(diff.hunks.len(), 1, "Expected one hunk");
613 let hunk = &diff.hunks[0];
614
615 assert_eq!(hunk.header.old_line_start, 1, "Old line start should be 1");
616 assert_eq!(hunk.header.old_line_count, 2, "Old line count should be 2");
617 assert_eq!(hunk.header.new_line_start, 1, "New line start should be 1");
618 assert_eq!(hunk.header.new_line_count, 2, "New line count should be 2");
619
620 let func_ctx = &input[hunk.header.fn_ctx.clone()];
621 assert_eq!(func_ctx, "fn main() {\n", "Expected function context");
622
623 assert_eq!(
624 hunk.content.changes.len(),
625 1,
626 "Expected one change in the hunk"
627 );
628 let change = &hunk.content.changes[0];
629 let removed_str = &input[change.old.clone()];
630 assert_eq!(removed_str, "-foo\n", "Removed line does not match");
631 let added_str = &input[change.new.clone()];
632 assert_eq!(added_str, "+bar\n", "Added line does not match");
633 }
634
635 #[test]
636 fn parse_multiple_diffs() {
637 let input = "diff --git a/file1.txt b/file1.txt\n\
638 index 0000000..1111111 100644\n\
639 --- a/file1.txt\n\
640 +++ b/file1.txt\n\
641 @@ -1,1 +1,1 @@\n\
642 -foo\n\
643 +bar\n\
644 diff --git a/file2.txt b/file2.txt\n\
645 index 2222222..3333333 100644\n\
646 --- a/file2.txt\n\
647 +++ b/file2.txt\n\
648 @@ -2,2 +2,2 @@\n\
649 -baz\n\
650 +qux\n";
651 let mut parser = Parser::new(input);
652 let diffs = parser.parse_diff().unwrap();
653 assert_eq!(diffs.len(), 2, "Expected two diff blocks");
654
655 let diff1 = &diffs[0];
656 let old_file1 = &input[diff1.header.old_file.clone()];
657 assert_eq!(old_file1, "file1.txt", "First diff old file mismatch");
658
659 let diff2 = &diffs[1];
660 let old_file2 = &input[diff2.header.old_file.clone()];
661 assert_eq!(old_file2, "file2.txt", "Second diff old file mismatch");
662 }
663
664 #[test]
665 fn parse_crlf_input() {
666 let input = "diff --git a/file.txt b/file.txt\r\n\
667 index 0000000..1111111 100644\r\n\
668 --- a/file.txt\r\n\
669 +++ b/file.txt\r\n\
670 @@ -1,1 +1,1 @@\r\n\
671 -foo\r\n\
672 +bar\r\n";
673 let mut parser = Parser::new(input);
674 let diffs = parser.parse_diff().unwrap();
675 assert_eq!(diffs.len(), 1, "Expected one diff block for CRLF input");
676 let diff = &diffs[0];
677 let old_file = &input[diff.header.old_file.clone()];
678 assert_eq!(
679 old_file, "file.txt",
680 "Old file does not match in CRLF input"
681 );
682 }
683
684 #[test]
685 fn parse_malformed_input_missing_diff_header() {
686 let input = "--- a/file.txt\n+++ b/file.txt\n";
687 let mut parser = Parser::new(input);
688 assert!(parser.parse_diff().is_err());
689 }
690
691 #[test]
692 fn parse_malformed_input_missing_hunk_header() {
693 let input = "diff --git a/file.txt b/file.txt\n\
694 index 0000000..1111111 100644\n\
695 --- a/file.txt\n\
696 +++ b/file.txt\n\
697 foo\n";
698 let mut parser = Parser::new(input);
699 assert!(parser.parse_diff().is_err());
700 }
701
702 #[test]
703 fn parse_malformed_input_invalid_number() {
704 let input = "diff --git a/file.txt b/file.txt\n\
705 index 0000000..1111111 100644\n\
706 --- a/file.txt\n\
707 +++ b/file.txt\n\
708 @@ -a,1 +1,1 @@\n\
709 -foo\n\
710 +bar\n";
711 let mut parser = Parser::new(input);
712 assert!(parser.parse_diff().is_err());
713 }
714
715 #[test]
716 fn parse_malformed_input_extra_characters() {
717 let input = "diff --git a/file.txt b/file.txt\n\
718 index 0000000..1111111 100644\n\
719 --- a/file.txt\n\
720 +++ b/file.txt\n\
721 @@ -1,1 +1,1 @@\n\
722 -foo\n\
723 +bar\n\
724 unexpected\n";
725 let mut parser = Parser::new(input);
726 assert!(parser.parse_diff().is_err());
727 }
728
729 #[test]
730 fn unified_diff_break() {
731 let input = "diff --git a/file.txt b/file.txt\r\n\
732 index 0000000..1111111 100644\r\n\
733 --- a/file.txt\r\n\
734 +++ b/file.txt\r\n\
735 @@ -1,1 +1,1 @@\r\n\
736 -foo\r\n\
737 +bar\r\n";
738 let mut parser = Parser::new(input);
739 let _ = parser.parse_diff();
740 }
741
742 #[test]
743 fn new_file() {
744 let input = "diff --git a/file.txt b/file.txt\r\n\
745 new file mode 100644\r\n\
746 index 0000000..1111111\r\n\
747 --- /dev/null\r\n\
748 +++ b/file.txt\r\n\
749 @@ -0,0 +1,1 @@\r\n\
750 +bar\r\n";
751 let mut parser = Parser::new(input);
752 let diffs = parser.parse_diff().unwrap();
753 assert_eq!(diffs.len(), 1, "Expected one diff block for new file test");
754 let diff = &diffs[0];
755 let old_file_str = &input[diff.header.old_file.clone()];
756 assert_eq!(old_file_str, "file.txt",);
757 let new_file_str = &input[diff.header.new_file.clone()];
758 assert_eq!(new_file_str, "file.txt",);
759 }
760
761 #[test]
762 fn omitted_line_count() {
763 let input = "diff --git a/file.txt b/file.txt\r\n\
764 index 0000000..1111111 100644\r\n\
765 --- a/file.txt\r\n\
766 +++ b/file.txt\r\n\
767 @@ -1 +1 @@\r\n\
768 -foo\r\n\
769 +bar\r\n";
770 let mut parser = Parser::new(input);
771 let diffs = parser.parse_diff().unwrap();
772 assert_eq!(
773 diffs.len(),
774 1,
775 "Expected one diff block for omitted line count test"
776 );
777 let diff = &diffs[0];
778 let hunk = &diff.hunks[0];
779 assert_eq!(hunk.header.old_line_count, 1, "Old line count should be 1");
780 assert_eq!(hunk.header.new_line_count, 1, "New line count should be 1");
781 }
782
783 #[test]
784 fn new_empty_files() {
785 let input = "diff --git a/file-a b/file-a\n\
786 new file mode 100644\n\
787 index 0000000..e69de29\n\
788 diff --git a/file-b b/file-b\n\
789 new file mode 100644\n\
790 index 0000000..e69de29\n";
791 let mut parser = Parser::new(input);
792 let diffs = parser.parse_diff().unwrap();
793 assert_eq!(diffs.len(), 2, "Expected two diff blocks for new files");
794 assert_eq!(diffs[0].header.status, Status::Added);
795 assert_eq!(diffs[0].hunks.len(), 0, "Expected no hunks in first diff");
796 assert_eq!(diffs[1].header.status, Status::Added);
797 assert_eq!(diffs[1].hunks.len(), 0, "Expected no hunks in second diff");
798 }
799
800 #[test]
801 fn deleted_file() {
802 let input = "diff --git a/Cargo.lock b/Cargo.lock\n\
803 deleted file mode 100644\n\
804 index 6ae58a0..0000000\n\
805 --- a/Cargo.lock\n\
806 +++ /dev/null\n";
807 let mut parser = Parser::new(input);
808 let diffs = parser.parse_diff().unwrap();
809 assert_eq!(diffs.len(), 1, "Expected two diff blocks for new files");
810 assert_eq!(diffs[0].header.status, Status::Deleted);
811 }
812
813 #[test]
814 fn commit() {
815 let input = "commit 9318f4040de9e6cf60033f21f6ae91a0f2239d38\n\
816 Author: altsem <alltidsemester@pm.me>\n\
817 Date: Wed Feb 19 19:25:37 2025 +0100\n\
818 \n\
819 chore(release): prepare for v0.28.2\n\
820 \n\
821 diff --git a/.recent-changelog-entry b/.recent-changelog-entry\n\
822 index 7c59f63..b3d843c 100644\n\
823 --- a/.recent-changelog-entry\n\
824 +++ b/.recent-changelog-entry\n\
825 @@ -1,7 +1,6 @@\n\
826 -## [0.28.1] - 2025-02-13\n\
827 +## [0.28.2] - 2025-02-19\n ### 🐛 Bug Fixes\n \n\
828 -- Change logging level to reduce inotify spam\n\
829 -- Don't refresh on `gitu.log` writes (gitu --log)\n\
830 +- Rebase menu opening after closing Neovim\n";
831 let mut parser = Parser::new(input);
832 let commit = parser.parse_commit().unwrap();
833 assert!(input[commit.header.range.clone()].starts_with("commit 931"));
834 assert!(input[commit.header.range.clone()].ends_with("28.2\n\n"));
835 assert_eq!(
836 &input[commit.header.hash.clone()],
837 "9318f4040de9e6cf60033f21f6ae91a0f2239d38"
838 );
839 }
840
841 #[test]
842 fn empty_commit() {
843 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 ";
844
845 let mut parser = Parser::new(input);
846 let commit = parser.parse_commit().unwrap();
847 assert_eq!(commit.diff.len(), 0);
848 }
849
850 #[test]
851 fn binary_file() {
852 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";
853
854 let mut parser = Parser::new(input);
855 let commit = parser.parse_commit().unwrap();
856 assert_eq!(commit.diff.len(), 3);
857 }
858
859 #[test]
860 fn conflicted_file() {
861 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";
862
863 let mut parser = Parser::new(input);
864 let diffs = parser.parse_diff().unwrap();
865 assert_eq!(diffs.len(), 1);
866 assert_eq!(diffs[0].header.status, Status::Unmerged);
867 assert_eq!(&input[diffs[0].header.old_file.clone()], "new-file");
868 assert_eq!(&input[diffs[0].header.new_file.clone()], "new-file");
869 }
870
871 #[test]
872 fn unmerged_path() {
873 let input = "* Unmerged path new-file\n* Unmerged path new-file-2\n";
874 let mut parser = Parser::new(input);
875 let diff = parser.parse_diff().unwrap();
876
877 assert_eq!(diff.len(), 2);
878 assert_eq!(diff[0].header.status, Status::Unmerged);
879 assert_eq!(&input[diff[0].header.old_file.clone()], "new-file");
880 assert_eq!(&input[diff[0].header.new_file.clone()], "new-file");
881 assert!(diff[0].hunks.is_empty());
882 assert_eq!(diff[1].header.status, Status::Unmerged);
883 assert_eq!(&input[diff[1].header.old_file.clone()], "new-file-2");
884 assert_eq!(&input[diff[1].header.new_file.clone()], "new-file-2");
885 assert!(diff[1].hunks.is_empty());
886 }
887
888 #[test]
889 fn missing_newline_before_final() {
890 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+});";
891
892 let mut parser = Parser::new(input);
893 let diffs = parser.parse_diff().unwrap();
894 assert_eq!(diffs.len(), 1);
895 assert_eq!(diffs[0].header.status, Status::Modified);
896 assert_eq!(diffs[0].hunks.len(), 1);
897 let changes = &diffs[0].hunks[0].content.changes;
898 assert_eq!(changes.len(), 1);
899 assert_eq!(
900 &input[changes[0].old.clone()],
901 "-})\n\\ No newline at end of file\n"
902 );
903 assert_eq!(&input[changes[0].new.clone()], "+});");
904 }
905
906 #[test]
907 fn filenames_with_spaces() {
908 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";
909 let mut parser = Parser::new(input);
910 let diffs = parser.parse_diff().unwrap();
911 assert_eq!(&input[diffs[0].header.old_file.clone()], "file one.txt");
912 assert_eq!(&input[diffs[0].header.new_file.clone()], "file two.txt");
913 }
914
915 #[test]
916 fn partially_unmerged() {
917 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";
918
919 let mut parser = Parser::new(input);
920 let diffs = parser.parse_diff().unwrap();
921 assert_eq!(diffs.len(), 3);
922 assert_eq!(diffs[2].header.status, Status::Unmerged);
923 assert_eq!(&input[diffs[2].header.old_file.clone()], "src/ops/show.rs");
924 assert_eq!(&input[diffs[2].header.new_file.clone()], "src/ops/show.rs");
925 }
926
927 #[test]
928 fn parse_custom_prefixes() {
929 let input = "diff --git i/file1.txt w/file2.txt\n\
930 index 0000000..1111111 100644\n\
931 --- i/file1.txt\n\
932 +++ w/file2.txt\n\
933 @@ -1,2 +1,2 @@ fn main() {\n\
934 -foo\n\
935 +bar\n";
936 let mut parser = Parser::new(input);
937 let diffs = parser.parse_diff().unwrap();
938 assert_eq!(diffs.len(), 1, "Expected one diff block");
939
940 let diff = &diffs[0];
941 let old_file_str = &input[diff.header.old_file.clone()];
942 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
943
944 let new_file_str = &input[diff.header.new_file.clone()];
945 assert_eq!(new_file_str, "file2.txt", "New file does not match");
946 }
947}